Scriptbook.psm1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")]
param(
    [parameter(Mandatory = $false)][HashTable]$ImportVars
)

Write-Verbose "Start loading Scriptbook module";

# check if single file module
if (Test-Path (Join-Path $PSScriptRoot Public))
{
    $dotSourceParams = @{
        Filter      = '*.ps1'
        Recurse     = $true
        ErrorAction = 'Stop'
    }
    $public = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public') @dotSourceParams )
    $private = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Private/*.ps1') @dotSourceParams)
    foreach ($import in @($private + $public))
    {
        try
        {
            . $import.FullName
        }
        catch
        {
            throw "Unable to dot source [$($import.FullName)]"
        }
    }
}

$verbose = $false
if ($ImportVars -and $ImportVars.ContainsKey('Verbose'))
{
    $verbose = $ImportVars.Verbose
}
if ($verbose)
{
    $VerbosePreference = 'Continue'
}

$strict = $true
if ($ImportVars -and $ImportVars.ContainsKey('NoStrict'))
{
    $strict = -not $ImportVars.NoStrict
}
if ($strict)
{
    Set-StrictMode -Version Latest
}

$quiet = $false
if ($ImportVars -and $ImportVars.ContainsKey('Quiet'))
{
    $quiet = $ImportVars.Quiet
}
if (!($quiet))
{
    $module = 'Scriptbook'
    $scriptDir = Split-Path $MyInvocation.MyCommand.Path
    $manifestPath = Join-Path $scriptDir "$module.psd1"
    $manifest = Test-ModuleManifest -Path $manifestPath -WarningAction SilentlyContinue
    $version = $manifest.Version.ToString()
    $copyright = $manifest.Copyright
    $author = $manifest.Author
    Write-Host "$module Version $version by $author"
    Write-Host "Proudly created in Schiedam (NLD), $copyright"
}

$core = $false
if ($ImportVars -and $ImportVars.ContainsKey('Core'))
{
    $core = $ImportVars.Core
}
if ($core)
{
    if ($PSVersionTable.PSVersion.Major -lt 6)
    {
        Write-Host ''.PadRight(78, '=')
        Throw "PowerShell version $($PSVersionTable.PSVersion) not supported by this Script"        
    }
}

$checkModules = $true
$cacheTimeFile = Join-Path $home './cacheTimeFile.json'

$importFormat = 'Scriptbook'
# load dependencies from file if not override by arguments
if ($null -eq $ImportVars -or ($ImportVars.ContainsKey('SettingsFile') -or ($ImportVars.ContainsKey('Quiet') -and $ImportVars.Count -eq 1)) )
{
    if ($ImportVars -and $ImportVars.ContainsKey('SettingsFile'))
    {
        $importFile = $ImportVars.SettingsFile
    }
    else
    {
        $importFile = ''    
    }
    $scriptName = $Script:MyInvocation.ScriptName
    if ([string]::IsNullOrEmpty($scriptName))
    {
        $scriptName = Join-Path $PSScriptRoot 'Scriptbook.ps1'
    }
    if (Test-Path variable:Profile)
    {
        $profileLocation = Join-Path (Split-Path $Profile) 'Scriptbook.psd1'
    }
    else
    {
        $profileLocation = ''
    }
    $importFiles = @( 
        $importFile,
        [IO.Path]::ChangeExtension($scriptName, 'psd1'),
        [IO.Path]::ChangeExtension((Join-Path (Split-Path $scriptName) ".$(Split-Path $scriptName -Leaf)"), 'psd1'),
        (Join-Path (Split-Path $scriptName) 'Scriptbook.psd1'),
        (Join-Path (Split-Path $scriptName) '.Scriptbook.psd1'),
        './depends.psd1',
        './variables.psd1',
        './.depends.psd1',
        './.variables.psd1',
        './requirements.psd1',
        $profileLocation
    )
    foreach ($f in $importFiles)
    {
        if ( ![string]::IsNullOrEmpty($f) -and (Test-Path -Path $f -ErrorAction Ignore) )
        {
            $ImportVars = Import-PowerShellDataFile -Path $f
            if ($f.Contains('requirements.psd1'))
            {
                $importFormat = 'Requirements'
            }
            break;
        }
    }
    if ($null -eq $ImportVars)
    {
        $ImportVars = @{}
    }
}

if ($ImportVars -and $importFormat -eq 'Requirements')
{    
    <#
        Import modules from requirements format: https://docs.microsoft.com/azure/azure-functions/functions-reference-powershell

        sample:
            @{
                'Az.Accounts' = '2.*'
                'Az.Compute' = '4.*'
            }
    #>

    $ImportVarsNew = @{}
    $modules = [System.Collections.ArrayList]@()
    foreach ($m in $ImportVars.GetEnumerator())
    {
        $modules.Add(@{ 
                Module         = $m.Key
                MinimumVersion = $m.Value
            }
        ) | Out-Null
    }
    $ImportVarsNew.Add('Depends', $modules) | Out-Null
    $ImportVars = $ImportVarsNew
}

if ($ImportVars.ContainsKey('Reset') -and $ImportVars.Reset)
{
    # default is reset
}
else
{
    if (Test-Path $cacheTimeFile)
    {
        # determine if we need to load the modules from repository again
        # now we check once a day the repository feed if new version are available
        # to speed up the start-time of our workbooks
        $moduleCache = Get-Content -Path $cacheTimeFile -Raw | ConvertFrom-Json
        if ($moduleCache.Time.Value -is [string])
        {
            
            $md = [System.DateTime]::Parse($moduleCache.Time.Value).Date
        }
        else
        {
            $md = $moduleCache.Time.Value.Date
        }
        if ($md -eq ((Get-Date).Date))
        {
            $checkModules = $false
        }
    }
}

if ($checkModules -eq $true)
{
    Set-Content -Path $cacheTimeFile -Value (@{ Time = (Get-Date) } | ConvertTo-Json) -Force
    Write-Verbose 'Loading modules...'
}

$depends = $null
if ($ImportVars -and $ImportVars.ContainsKey('Depends'))
{
    $depends = $ImportVars.Depends
}

if ($depends)
{
    # TODO !!EH Add install modules from git repo
    # TODO !!EH Add cache module option (create copy local and import from local), used in scenarios without internet access (deployments) / isolated containers
    Write-Host "Loading Scriptbook dependencies..."
    $pref = $global:ProgressPreference
    $global:ProgressPreference = 'SilentlyContinue'
    foreach ($dependency in $depends)
    {
        if (!$dependency.ContainsKey('Module'))
        {
            throw "Module not found in dependency: $dependency"
        }

        $skip = if ($dependency.ContainsKey('Skip')) { $dependency.Skip } else { $false }
        if ($skip)
        {
            continue
        }

        $minimumVersion = if ($dependency.ContainsKey('MinimumVersion')) { $dependency.MinimumVersion } else { '' }
        $maximumVersion = if ($dependency.ContainsKey('MaximumVersion')) { $dependency.MaximumVersion } else { '' }

        $extraParams = @{}
        $credentialLocation = if ($dependency.ContainsKey('Credential')) { $dependency.Credential } else { $null }
        if ($credentialLocation)
        {
            if ($credentialLocation.StartsWith('https://'))
            {
                # dependency on TD.Util Module, load this module first
                if (Get-Command Get-AzureDevOpsCredential -ErrorAction Ignore)
                {
                    $cred = Get-AzureDevOpsCredential -Url $credentialLocation
                    $extraParams.Add('Credential', $cred) | Out-Null
                }
            }
            else
            {
                try
                {
                    $cred = Get-LocalCredential -Name $credentialLocation
                    $extraParams.Add('Credential', $cred) | Out-Null
                }
                catch
                {
                    Write-Warning $_.Exception.Message
                }                
            }
        }

        $repository = if ($dependency.ContainsKey('Repository')) { $dependency.Repository } else { 'PSGallery' }
        if ($repository -ne 'PSGallery')
        {
            $repo = Get-PSRepository -Name $repository -ErrorAction Ignore
            if ($null -eq $repo)
            {
                $repositoryUrl = if ($dependency.ContainsKey('RepositoryUrl')) { $dependency.RepositoryUrl } else { $null }
                if ($repositoryUrl)
                {
                    if (Get-Command Register-AzureDevOpsPackageSource -ErrorAction Ignore)
                    {
                        Register-AzureDevOpsPackageSource -Name $repository -Url $repositoryUrl @extraParams
                    }
                    else
                    {
                        Register-PSRepository -Name $repository -SourceLocation $repositoryUrl -InstallationPolicy Trusted @extraParams
                    }
                }
            }
        }

        if (Get-Module -Name $dependency.Module -ListAvailable -ErrorAction Ignore)
        {
            if ($checkModules)
            {
                if ($null -ne (Get-InstalledModule -Name $dependency.Module -ErrorAction Ignore) )
                {                
                    $force = if ($dependency.ContainsKey('Force')) { $dependency.Force } else { $false }
                    if ($minimumVersion)
                    {
                        $v1 = (Get-Module -Name $dependency.Module -ListAvailable | Select-Object -First 1).Version
                        $v2 = [version]$minimumVersion
                        if ($v2 -gt $v1)
                        {
                            Write-Verbose "Updating module $($dependency.Module) with MinimumVersion $minimumVersion"
                            Update-Module -Name $dependency.Module -Force:$force -RequiredVersion $minimumVersion @extraParams
                        }
                    }
                    else
                    {
                        Write-Verbose "Updating module $($dependency.Module) with MaximumVersion $maximumVersion"
                        Update-Module -Name $dependency.Module -Force:$force -MaximumVersion $maximumVersion @extraParams
                    }
                }
                else
                {
                    Write-Warning "Module $($dependency.Module) not installed by Install-Module, cannot update module via Update-Module, using forced Install-Module"
                    Write-Verbose "Installing module $($dependency.Module)"
                    Install-Module -Name $dependency.Module -Force -Repository $repository -Scope CurrentUser -MinimumVersion $minimumVersion -MaximumVersion $maximumVersion -AllowClobber @extraParams
                }
            }
        }
        else
        {
            Write-Verbose "Installing module $($dependency.Module)"
            # TODO !!EH using -Force to install from untrusty repositories or do we need to handle this via Force attribute
            Install-Module -Name $dependency.Module -Force -Repository $repository -Scope CurrentUser -MinimumVersion $minimumVersion -MaximumVersion $maximumVersion -AllowClobber @extraParams
        }

        if ($dependency.ContainsKey('Args'))
        {
            Import-Module -Name $dependency.Module -ArgumentList $dependency.Args -Global
        }
        else
        {
            Import-Module -Name $dependency.Module -Global
        }
    }
    $global:ProgressPreference = $pref
}

$variables = $null
if ($ImportVars -and $ImportVars.ContainsKey('Variables'))
{
    $variables = $ImportVars.Variables
}
if ($variables)
{
    foreach ($kv in $variables.GetEnumerator())
    {
        Set-Variable -Scope Global -Name $kv.Key -Value $kv.Value -Force
    }
}

# snapshot global vars, excluding parameters
$Global:GlobalVarNames = @{}
Get-Variable -Scope Global | ForEach-Object {
    if (!$_.Attributes)
    {
        $Global:GlobalVarNames.Add($_.Name, $null) | Out-Null
    }
    elseif ($_.Attributes.GetType().Name -ne 'PSVariableAttributeCollection' -or $_.Attributes.Count -eq 0)
    {
        $Global:GlobalVarNames.Add($_.Name, $null) | Out-Null
    }
    elseif ($_.Attributes[0].GetType().Name -ne 'ParameterAttribute')
    {
        $Global:GlobalVarNames.Add($_.Name, $null) | Out-Null
    }
}

# cleanup module script scope
@(
    'variables',
    'author',
    'checkModules',
    'cacheTimeFile',
    'copyright',
    'depends',
    'dotSourceParams',
    'importFile',
    'importFiles',
    'importFormat',
    'ImportVars',
    'manifest',
    'manifestPath',
    'module',
    'moduleCache',
    'private',
    'profileLocation',
    'public',
    'quiet',
    'scriptDir',
    'scriptName',
    'version',
    'verbose',
    'f',
    'strict',
    'import'
) | ForEach-Object { Remove-Variable -Force -ErrorAction Ignore -Scope Script -Name $_ }

Write-Verbose "Finished loading Scriptbook module";
# end import
;

# experimental

Set-Alias -Name Info -Value New-Info -Scope Global -Force -WhatIf:$false
Set-Alias -Name Documentation -Value New-Info -Scope Global -Force -WhatIf:$false
function New-Info
{   
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [ScriptBlock] $Code,
        [string]$Comment,
        [Switch]$NoDisplay,
        [Switch]$Skip,
        [Switch]$AsDocumentation
    )

    if ($null -eq $Code)
    {
        Throw "No info script block is provided. (Have you put the open curly brace on the next line?)"
    }

    if ($Skip.IsPresent)
    {
        return
    }

    if ($PSCmdlet.ShouldProcess("New-Info"))
    {
        if ($PSCmdlet.MyInvocation.InvocationName -eq 'Documentation')
        {
            $AsDocumentation = $true
        }

        $text = $null
        if ($Comment)
        {
            $text = $Comment + [System.Environment]::NewLine
        }
        $text += Get-CommentFromCode -ScriptBlock $Code
    
        if ($AsDocumentation.IsPresent)
        {
            $ctx = Get-RootContext
            [void]$ctx.Infos.Add($text)
        }
        elseif (!($NoDisplay.IsPresent) -or ($VerbosePreference -eq 'Continue') )
        {
            Write-Info ($text | Out-String | Show-Markdown)
        }
    }
}


# experimental

Set-Alias -Name S -Value New-Section -Scope Global -Force -WhatIf:$false
Set-Alias -Name Section -Value New-Section -Scope Global -Force -WhatIf:$false
Set-Alias -Name Markdown -Value New-Section -Scope Global -Force -WhatIf:$false
function New-Section
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        $Text,
        [Switch]$Skip,
        [ScriptBlock] $Code
    
    )
    if ($Skip.IsPresent)
    {
        return
    }

    if ($PSCmdlet.ShouldProcess("New-Section"))
    {

        if ($Text -is [ScriptBlock])
        {
            Write-ScriptBlock $Text
        }
        else
        {
            Write-StringResult "$Text"
            Write-StringResult ''
            if ($null -eq $Code)
            {
                Throw "No section script block is provided. (Have you put the open curly brace on the next line?)"
            }
            Write-ScriptBlock $Code
        }
        Write-StringResult ''
    }
}

<#
.SYNOPSIS
Asserts/Checks the supplied boolean condition

.DESCRIPTION
Asserts/Checks the supplied boolean condition. Throws an exception with message details if fails

.PARAMETER Condition
Boolean value of condition to check

.PARAMETER Value
Actual value to check

.PARAMETER Operator
Check comparison operator like: -eq, -ne, -gt

.PARAMETER Expected
Expected value for check

.PARAMETER Message
The message to display when assert fails

.EXAMPLE
Assert-Condition -Condition $false 'Error checking this condition'

.EXAMPLE
Assert -c (Test-Path $myFile) 'File not found'

.EXAMPLE
$cnt = 5
Assert-Condition ($cnt -eq 5) 'Error checking this condition'

.EXAMPLE
$cnt = 5
Assert-Condition ($cnt -eq 5) 'Error checking this condition'

.EXAMPLE
$cnt = 5
Assert-Condition -Value $cnt -Expected 5 'Error checking this condition'

.EXAMPLE
$cnt = 4
Assert-Condition -Value $cnt -Operator '-ne' -Expected 5 'Error checking this condition'

#>


Set-Alias -Name Assert -Value Assert-Condition -Scope Global -Force -WhatIf:$false
function Assert-Condition
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingInvokeExpression", "")]
    [CmdletBinding(DefaultParameterSetName = 'Condition', SupportsShouldProcess)]
    param(
        [bool][Alias('c')][Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'Condition')]$Condition,
        [Alias('v', 'Actual', 'Real')][Parameter(Mandatory = $true, ParameterSetName = 'Comparison')]$Value,
        [Alias('o')][Parameter(ParameterSetName = 'Comparison')]$Operator = '-eq',
        [Alias('e', 'v2', 'Value2')][Parameter(Mandatory = $true, ParameterSetName = 'Comparison')]$Expected = '-eq',
        [Alias('m')]
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Condition')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Comparison')]
        [string]$Message
    )

    if ($PSCmdlet.ParameterSetName -eq 'Condition')
    {
        $expMsg = "Check: $Condition"
    }
    else
    {
        $expMsg = "Check: ($Value $Operator $Expected)"
    }
    if ($PSCmdlet.ShouldProcess("Assert-Condition", $expMsg))
    {
        if ($PSCmdlet.ParameterSetName -eq 'Condition')
        {
            if (-not $Condition)
            {
                Write-Verbose "Assert-Condition: $Message"
                Throw "Assert-Condition: $Message"
            }    
        }
        else
        {
            if ($Value -is [string])
            {
                $check = Invoke-Expression "'$Value' $Operator '$Expected'"
            }
            else
            {
                $check = Invoke-Expression "$Value $Operator $Expected"
            }
            if (-not $check)
            {
                Write-Verbose "Assert-Condition expected '$Expected' actual '$Value' with operation '$Operator' $Message"
                Throw "Assert-Condition: Expected '$Expected' Actual '$Value' with Operation '$Operator' $Message"
            }
        }
    }
}
<#
.SYNOPSIS
Checks the minimum required version of command

.DESCRIPTION
Checks the minimum required version of command. Command includes Powershell commands and native commands. Select -Minimum to allow higher major versions.
Default higher minimum versions are allowed. For example Version is 4.1 then 4.1-4.9 are valid versions. remarks. Version/Minimum check only works on Windows.

.PARAMETER Command
The command

.PARAMETER Version
Version of command required. Higher minor versions are allowed

.PARAMETER Minimum
Minimum version of command required. Higher Major version are allowed

.EXAMPLE
Assert-Version -Command cmd -Version 1.0
#>

function Assert-Version([Parameter(Mandatory = $true)][string]$Command, [Parameter(Mandatory = $true)][string]$Version, [switch]$Minimum)
{
    if (!$IsWindows)
    {
        $cmdNative = $Command.Replace('.exe', '')
    }
    else
    {
        $cmdNative = $Command
    }
    $cm = Get-Command $cmdNative -ErrorAction Ignore
    if ($cm)
    {
        if ($IsWindows)
        {
            $v = [Version]$Version
            if ($Minimum.IsPresent)
            {
                if ($cm.Version.Major -ge $v.Major)
                {
                    return
                }
            }
            else
            {
                if ($cm.Version.Major -eq $v.Major)
                {
                    if ($cm.Version.minor -ge $v.Minor)
                    {
                        # okay
                        return
                    }
                }
            }
            Throw "Invalid version of $Command found '$($cm.Version)' expected $v"
        }
        else
        {
            Write-Verbose "Assert-Version: No version/Minimum check on platforms other than Windows"
        }
    }
    else
    {
        Throw "$Command not installed"
    }
}
<#
.SYNOPSIS
Decrypts secret with Seed value

.DESCRIPTION
Decrypts secret with Seed value. Seed complexity is
    - At least one upper case letter [A-Z]
    - At least one lower case letter [a-z]
    - At least one number [0-9]
    - At least one special character (!,@,%,^,&,$,_)
    - Password length must be 7 to 25 characters.
.PARAMETER Value
Value to decrypt

.PARAMETER Seed
Seed value used to decrypt value
#>

function Get-DecryptedSecret
{
 
    [CmdletBinding()]
    param(
        [ValidateNotNullOrEmpty()]
        [alias('v')]
        $Value,
        [ValidateNotNullOrEmpty()]
        [ValidateLength(8, 1024)]
        [ValidatePattern('^((?=.*[a-z])(?=.*[A-Z])(?=.*\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]))([A-Za-z\d@#$%^&amp;£*\-_+=[\]{}|\\:();!]|\.(?!@)){8,16}$')]
        [alias('s', 'k', 'Key')]
        $Seed
    )

    $hash = (New-Object System.Security.Cryptography.SHA256CryptoServiceProvider).ComputeHash(([system.Text.Encoding]::Unicode).GetBytes($Seed));
    $iv = New-Object byte[] 16;
    $key = New-Object byte[] 16;
    [System.Buffer]::BlockCopy($hash, 0, $key, 0, $key.Length)
    $decryptor = ([System.Security.Cryptography.AesCryptoServiceProvider]::Create()).CreateDecryptor($key, $iv)
    $buffer = [System.Convert]::FromBase64String($Value);
    $decryptedBlob = $deCryptor.TransformFinalBlock($buffer, 0, $buffer.Length);
    return [System.Text.Encoding]::Unicode.GetString($decryptedBlob)
}
<#
.SYNOPSIS
Encrypts secret with Seed value

.DESCRIPTION
Encrypts secret with Seed value. Seed complexity is
    - At least one upper case letter [A-Z]
    - At least one lower case letter [a-z]
    - At least one number [0-9]
    - At least one special character (!,@,%,^,&,$,_)
    - Password length must be 7 to 25 characters.
.PARAMETER Value
Value to encrypts

.PARAMETER Seed
Seed value used to encrypts value
#>

function Get-EncryptedSecret
{
    [CmdletBinding()]
    param(
        [ValidateNotNullOrEmpty()]
        [alias('v')]
        $Value,
        [ValidateNotNullOrEmpty()]
        [ValidateLength(8, 1024)]
        [ValidatePattern('^((?=.*[a-z])(?=.*[A-Z])(?=.*\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]))([A-Za-z\d@#$%^&amp;£*\-_+=[\]{}|\\:();!]|\.(?!@)){8,16}$')]
        [alias('s', 'k', 'Key')]
        $Seed
    )

    $hash = (New-Object System.Security.Cryptography.SHA256CryptoServiceProvider).ComputeHash(([system.Text.Encoding]::Unicode).GetBytes($Seed));
    $iv = New-Object byte[] 16;
    $key = New-Object byte[] 16;
    [System.Buffer]::BlockCopy($hash, 0, $key, 0, $key.Length)
    $encryptor = ([System.Security.Cryptography.AesCryptoServiceProvider]::Create()).CreateEncryptor($key, $iv)
    $buffer = [System.Text.Encoding]::Unicode.GetBytes($Value);
    $encryptedBlob = $encryptor.TransformFinalBlock($buffer, 0, $buffer.Length);
    
    return [System.Convert]::ToBase64String($encryptedBlob)
}
<#
.SYNOPSIS
Get Credential from Local Credential cache in User profile on Windows.

.DESCRIPTION
Get Credential from Local Credential cache in User profile on Windows if found. Otherwise in interactive sessions Get-Credential is used to query for Credentials and store them in local cache encrypted.

.PARAMETER Name
Name of credential for reference only

#>

function Get-LocalCredential([Parameter(Mandatory = $true)][string]$Name)
{
    #TODO !!EH Windows Only or by design?
    $credPath = Join-Path $home "Cred_$Name.xml"
    if ( Test-Path $credPath )
    {
        $cred = Import-Clixml -Path $credPath
    }
    else
    {
        # not fail safe but better than nothing
        if ( ((Get-Host).Name -eq 'ConsoleHost') -and ([bool]([Environment]::GetCommandLineArgs() -like '-noni*')) )
        {
            Throw "Get-LocalCredential not working when running script in -NonInteractive Mode, unable to prompt for Credentials"
        }

        $parent = Split-Path $credPath -Parent
        if ( -not ( Test-Path $parent ) )
        {
            New-Item -ItemType Directory -Force -Path $parent | Out-Null
        }
        $cred = Get-Credential -Title "Provide '$Name' username/password"
        $cred | Export-Clixml -Path $credPath
    }
    return $cred
}
function FormatTaskName([Parameter(Mandatory = $true)]$Format)
{
    # not supported/needed
}
function Include
{
    [CmdletBinding()]
    param(
        [ScriptBlock] $Code
    )
    if ($null -eq $Code)
    {
        Throw "No include script block is provided. (Have you put the open curly brace on the next line?)"
    }
    & $code
}

function Invoke-Task([Parameter(Mandatory = $true)]$TaskName)
{
    Invoke-PerformIfDefined -Command "Action-$TaskName" -ThrowError $true -Manual
}
function Properties
{
    [CmdletBinding()]
    param(
        [ScriptBlock] $Code
    )
    if ($null -eq $Code)
    {
        Throw "No properties script block is provided. (Have you put the open curly brace on the next line?)"
    }
    $Script:PsakeProperties = $Code
}

Set-Alias -Name Invoke-PSake -Value Start-PSakeWorkflow -Scope Global -Force -WhatIf:$false
function Start-PSakeWorkflow
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory = $true, Position = 0)][string] $File,
        [Parameter(Position = 1)][HashTable] $Parameters,
        [Parameter(Position = 2)][HashTable] $Properties
    )

    if ($PSCmdlet.ShouldProcess("Start-PSakeWorkflow"))
    {
        $Script:PsakeInvocationParameters = $Parameters
        $Script:PsakeInvocationProperties = $Properties

        . $File
    }
}

# compatibility with PSake
function Task
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)][string] $Name,
        [String[]] $Tag = @(),
        [String[]] $Depends = @(),
        $Parameters = @{},
        [Switch]$AsJob,
        [String]$Description,
        [ScriptBlock]$PreCondition = { $true },
        [ScriptBlock]$PostCondition, # not implemented
        [Switch]$ContinueOnError,
        [String]$FromModule,
        [Parameter(Position = 1)]
        [ScriptBlock] $Code
    )

    $lName = $Name -replace 'Invoke-', ''
    if ($FromModule)
    {
        if (!($taskModule = Get-Module -Name $FromModule))
        {
            $taskModule = Get-Module -Name $FromModule -ListAvailable -ErrorAction Ignore -Verbose:$False | Sort-Object -Property Version -Descending | Select-Object -First 1
        }
        $psakeFilePath = Join-Path -Path $taskModule.ModuleBase -ChildPath 'psakeFile.ps1'
        if (Test-Path $psakeFilePath)
        {
            . $psakeFilePath
        }
    }
    else
    {
        if ($lName -ne 'Default' -and $lName -ne '.' )
        {
            if ($null -eq $Code)
            {
                Write-Verbose "Task: $lName No code script block is provided. (Have you put the open curly brace on the next line?)"
                $Code = {}
            }
            if ($null -ne $PostCondition)
            {
                Write-Warning "Post Condition not implemented"
            }
            $eaValue = 'Stop'
            if ($ContinueOnError.IsPresent)
            {
                $eaValue = 'Ignore'
            }
            Register-Action -Name $Name -Tag $Tag -Depends $Depends -Parameters $Parameters -AsJob:$AsJob.IsPresent -If $PreCondition -ErrorAction $eaValue -Description $Description -Code $Code -TypeName Task
        }
    }

    # start build-in Action: Start Workflow
    if ($lName -eq 'Default' -or $lName -eq '.')
    {
        Start-Workflow -Actions $Depends -Parameters $Parameters -Location (Get-Location)
    }
}
#Set-Alias -Name ActionSetup -Value TaskSetup -Scope Global -Force -WhatIf:$false
Set-Alias -Name Initialize-Action -Value TaskSetup -Scope Global -Force -WhatIf:$false
function TaskSetup
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [scriptblock]$Setup
    )
    $Script:PsakeSetupTask = $Setup
}
#Set-Alias -Name ActionTearDown -Value TaskTearDown -Scope Global -Force -WhatIf:$false
Set-Alias -Name Complete-Action -Value TaskTearDown -Scope Global -Force -WhatIf:$false
function TaskTearDown
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [scriptblock]$TearDown
    )
    $Script:PsakeTearDownTask = $TearDown
}
<#
.SYNOPSIS
Defines an action executed by Workflow

.DESCRIPTION
Defines an action executed by Workflow. At least one action is required by workflow. Actions are executed in workflow sequence and/or their dependency order. By using the If condition an action can be excluded from the workflow execution.
An action scriptblock can be executed multiple times by using the For expression. Default the action runs in the workflow of the current Powershell session. Optionally action scriptblock can be executed in a Docker Container or a Remote Powershell Session.

Action in Docker Container
- requires docker installed on current machine

Action in Remote Powershell Session
- requires valid PSSession with access to remote host (PS Remoting or SSH Remoting)

.PARAMETER Name
Set the Name of the action

.PARAMETER Tag
Set the tag(s) array of the action

.PARAMETER Depends
Set list of dependent actions. Dependent actions are executed before this action in the order of the workflow dependency graph

.PARAMETER Parameters
Parameters to pass to Workflow

.PARAMETER AsJob
Run action in Powershell Job (Separate process)

.PARAMETER Description
Contains description of Action

.PARAMETER If
Boolean expression to determine if action if executed, like { $value -eq $true }

.PARAMETER Disabled
Determines if action is disabled, exempt from workflow

.PARAMETER NextAction
Next Action to execute when action is finished

.PARAMETER For
Sequence expression for setting the number of executing the same action scriptblock, like { 1..10 } or { 'hello','and','goodby' }

.PARAMETER Parallel
Determines if the action scriptblock is executed in parallel. Used by the For expression when running in Powershell 7+ and when used with switch -AsJob

.PARAMETER Container
Determines if action is run in container

.PARAMETER ContainerOptions
Determines the container options when running action in a container. ContainerOptions.Image contains the container image used.

.PARAMETER Session
Contains the remote session object to run the action scriptblock on remote host via Powershell Remoting

.PARAMETER Isolated
Determines when if user ps-modules, scriptbook module and script/workflow are copied/used by (remote) container or remote-session.

.PARAMETER Unique
Always generates unique name for Action. Prevent collision with required unique Action names

.PARAMETER RequiredVariables
Enables checking for required variables before action starts. If variable not available in global, script or local scope action fails.

.PARAMETER Comment
Add Comment to Action for documentation purpose

.PARAMETER SuppressOutput
if present suppresses write/output of Action return value or output stream to console

.PARAMETER Code
Contains the action code/scriptblock to execute when action is enabled (default)

.EXAMPLE

.NOTES

#>

Set-Alias -Name Test -Value Action -Scope Global -Force -WhatIf:$false
Set-Alias -Name Step -Value Action -Scope Global -Force -WhatIf:$false
Set-Alias -Name Activity -Value Action -Scope Global -Force -WhatIf:$false
Set-Alias -Name Job -Value Action -Scope Global -Force -WhatIf:$false
Set-Alias -Name Chore -Value Action -Scope Global -Force -WhatIf:$false
Set-Alias -Name Stage -Value Action -Scope Global -Force -WhatIf:$false
Set-Alias -Name Override -Value Action -WhatIf:$false
function Action
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)][string] $Name,
        [String[]] $Tag = @(),
        [String[]] $Depends = @(),
        [HashTable]$Parameters = @{},
        [Switch]$AsJob,
        [ValidateNotNullOrEmpty()]
        [String]$Description,
        [ScriptBlock] $If,
        [alias('Skip')][switch] $Disabled,
        [ValidateNotNullOrEmpty()]
        [string][alias('next')] $NextAction,
        [ScriptBlock] $For,
        [switch] $Parallel,
        [switch] $Container,
        [HashTable] $ContainerOptions = @{},
        [AllowNull()]
        $Session,
        [switch]$Isolated,
        [switch]$Unique,
        [ValidateNotNull()]
        [string[]]$RequiredVariables = @(),
        [string]$Comment,
        [switch]$SuppressOutput,
        [Parameter(Position = 1)]
        [ScriptBlock] $Code
    )

    $lName = $Name -replace 'Invoke-', ''
    if ($PSCmdlet.MyInvocation.InvocationName -eq 'Test')
    {
        $Name = "Test.$Name"
        $lName = $Name
    }
    if ($lName -ne 'Default' -and $lName -ne '.' )
    {
        if ($null -eq $Code)
        {
            if ([string]::IsNullOrEmpty($Name))
            {
                Throw "No code script block is provided and Name property is mandatory. (Have you put the open curly brace on the next line?)"
            }
            else
            {
                $n = $Name.Split("`n")
                if ($n.Count -gt 1)
                {
                    Throw "No Name provide for Action, Name is required, found scriptblock { $Name } instead."
                }
                else
                {
                    Throw "No code script block is provided for '$Name'. (Have you put the open curly brace on the next line?)"
                }
            }
        }
        Register-Action -Name $Name -Tag $Tag -Depends $Depends -Parameters $Parameters -ErrorAction $ErrorActionPreference -AsJob:$AsJob.IsPresent -If $If -Description $Description -Code $Code -Disabled $Disabled.IsPresent -TypeName $PSCmdlet.MyInvocation.InvocationName -NextAction $NextAction -For $For -Parallel:$Parallel.IsPresent -Container:$Container.IsPresent -ContainerOptions $ContainerOptions -Session $Session -Isolated:$Isolated.IsPresent -Unique:$Unique.IsPresent -RequiredVariables $RequiredVariables -Comment $Comment -SuppressOutput:$SuppressOutput.IsPresent
    }

    # Start build-in Action: Start Workflow
    if ($lName -eq 'Default' -or $lName -eq '.')
    {
        Start-Workflow -Actions $Depends -Parameters $Parameters -Location (Get-Location)
    }
}

<#
.SYNOPSIS
Disables action to execute

.DESCRIPTION
Disables action to execute, can change during run-time

.PARAMETER Name
Name of the Action

#>

function Disable-Action([ValidateNotNullOrEmpty()][string]$Name)
{
    $ctx = Get-RootContext
    if ($ctx.Actions.Count -eq 0)
    {
        throw "No actions defined or workflow finished."
    }

    $action = $ctx.Actions["Action-$($Name.Replace('Action-',''))"]
    if ($action)
    {
        $action.Disabled = $true
    }
    else
    {
        throw "Action $Name not found in Disable-Action"
    }

}

<#
.SYNOPSIS
Enables action to execute

.DESCRIPTION
Enabled action to execute, can change during run-time

.PARAMETER Name
Name of the Action

#>

function Enable-Action([ValidateNotNullOrEmpty()][string]$Name)
{
    $ctx = Get-RootContext
    if ($ctx.Actions.Count -eq 0)
    {
        throw "No actions defined or workflow finished."
    }

    $action = $ctx.Actions["Action-$($Name.Replace('Action-',''))"]
    if ($action)
    {
        $action.Disabled = $false
    }
    else
    {
        throw "Action $Name not found in Enable-Action"
    }

}
<#
.SYNOPSIS
Returns Action return-value or the output written to output streams

.DESCRIPTION
Returns Action return-value or the output written to output streams. Each Action can return output for use in other Actions. Normally output is ignored and does not interfere with workflow

.PARAMETER Name
Name of the Action

#>

Set-Alias -Name Get-ActionOutput -Value Get-ActionReturnValue -Scope Global -Force -WhatIf:$false
Set-Alias -Name Get-ActionOutputValue -Value Get-ActionReturnValue -Scope Global -Force -WhatIf:$false
function Get-ActionReturnValue
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    param($Name)

    $ctx = Get-RootContext
    if ($ctx.Actions.Count -eq 0)
    {
        throw "No actions defined or workflow finished."
    }

    $returnValue = $null
    $action = $ctx.Actions["Action-$($Name.Replace('Action-',''))"]
    if ($action)
    {
        $script:InvokedCommandsResult | ForEach-Object { if ($_.Command -eq $action.Name) { $returnValue = $_.ReturnValue } }
    }
    else
    {
        throw "Action $Name not found in Get-ActionReturnValue"
    }
    return $returnValue
}
<#
.SYNOPSIS
Imports one or more Actions from File with parent defined parameters

.DESCRIPTION
Imports one or more Actions from File with parent defined parameters. Use this in your startup scripts.

.PARAMETER File
Name of the File

.EXAMPLE
Script File with One Action (myAction.ps1):

param(
    [ValidateNotNullOrEmpty()]
    $MyParam1
)

Action -Name MyAction {
    Write-Info "Script param value: $MyParam1"
    Write-Info 'MyAction'
}

Startup Script (startup.ps1):

param(
    $MyParam1 = 'Hello'
)

Set-Location $PSScriptRoot
Import-Module Scriptbook
Import-Action ./myAction.ps1 #-Context $ExecutionContext

Start-Workflow

.EXAMPLE
Alternative is in startup script:

$parameters = Get-BoundParametersWithDefaultValue
. ./myAction.ps1 @parameters
#>

Set-Alias -Name Import-Test -Value Import-Action -Scope Global -Force -WhatIf:$false
Set-Alias -Name Import-Step -Value Import-Action -Scope Global -Force -WhatIf:$false
#Set-Alias -Name Import-Activity -Value Import-Action -Scope Global -Force -WhatIf:$false
#Set-Alias -Name Import-Job -Value Import-Action -Scope Global -Force -WhatIf:$false
#Set-Alias -Name Import-Stage -Value Import-Action -Scope Global -Force -WhatIf:$false
#Set-Alias -Name Import-Flow -Value Import-Action -Scope Global -Force -WhatIf:$false
function Import-Action
{
    [CmdletBinding()]
    param (
        [ValidateNotNullOrEmpty()]
        $File,
        #$Context,
        $Invocation = $Global:MyInvocation
    )
    $parameters = Get-BoundParametersWithDefaultValue $Invocation
    $localVars = @{}
    Get-Variable -Scope Local | ForEach-Object { [void]$localVars.Add($_.Name, $null) }

    Get-ChildItem $File | Sort-Object -Property FullName | ForEach-Object {
        $parameterList = (Get-Command -Name $_.FullName).Parameters;
        $parameterSelector = $parameters.Clone()
        foreach ($pm in $parameterSelector.Keys)
        {
            if (!$parameterList.ContainsKey($pm)) {$parameters.Remove($pm)}
        }
        . $($_.FullName) @parameters        
    }

    $newLocalVars = Get-Variable -Scope Local
    foreach ($var in $newLocalVars.GetEnumerator())
    {
        if (!$localVars.ContainsKey($var.Name))
        {
            Set-Variable -Scope Global -Name $var.Name -Value $var.Value -Force -Visibility Public
        }
    }
}

<#
    Scoping Experiments. Unable to run import in Caller Scope, local vars missing --> copy local vars for now

    # in module scope
    $m = Get-Module Scriptbook
    & $m {
        param($File, $Parameters);
        . ./$File @Parameters
    } -File $File -Parameters $parameters
    return

    # in current scope
    . ./$File @parameters

    # in current scope
    Invoke-Command { param($File, $Parameters); . ./$File @parameters } -ArgumentList $File, $parameters #-NoNewScope

    # in caller context scope
    $module = [PSModuleInfo]::New($true)
    $module.SessionState = $Context.SessionState
    & {
        param($File, $Parameters);
        . ./$File @Parameters
    } -File $File -Parameters $parameters
    
    # in caller context scope
    . $module ./$File @parameters

    # in context scope
    $sb = { . ./configure.actions.ps1 -OrganizationPrefix 'td' -Environments @('dev', 'tst') -SubscriptionId '45f8a4be-d177-489e-8ec2-e1a53d87aadc' }
    $Context.InvokeCommand.InvokeScript($Context.SessionState, $sb, @())
#>

<#
.SYNOPSIS
Invokes an Action by name. If action is not found error is generated

.DESCRIPTION
Invokes an Action by name. If action is not found error is generated. Allows to call an Action in another Action scriptblock.

.PARAMETER Name
Name of Action

.EXAMPLE
Invoke-Action -Name Hello

#>


Set-Alias -Name Invoke-Step -Value Invoke-Action -Scope Global -Force -WhatIf:$false
function Invoke-Action([Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()]$Name)
{
    $cnt = $script:InvokedCommands.Count
    Invoke-PerformIfDefined -Command "Action-$($Name.Replace('Action-',''))" -ThrowError $true -Manual
    if ($script:InvokedCommands.Count -eq ($cnt + 1) )
    {
        return $script:InvokedCommands[$cnt]
    }
    else
    {
        return $null
    }
}

<#
.SYNOPSIS
Executes a ScriptBlock with commands

.DESCRIPTION
Executes a ScriptBlock with commands. If ScriptBlock contains native commands LastExitCode is checked.

.PARAMETER ScriptBlock
The scriptblock with commands to execute

.PARAMETER Message
The message to display when command fails. Use it to hide secrets or long cmd lines.

.PARAMETER Location
Current Location or working directory of command

.PARAMETER IgnoreExitCode
Ignores the LastExitCode check

.PARAMETER AsJson
Return result as Json Object if possible

.EXAMPLE
Execute { cmd.exe /c }

.EXAMPLE
Invoke-ScriptBlock { cmd.exe /c } -Message 'cmd' -Location c:\ -IgnoreExitCode

#>

Set-Alias -Name Execute -Value Invoke-ScriptBlock -Scope Global -Force -WhatIf:$false
Set-Alias -Name Exec -Value Invoke-ScriptBlock -Scope Global -Force -WhatIf:$false
function Invoke-ScriptBlock
{
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $true, Position = 0)][Scriptblock]
        $ScriptBlock,
        [alias('m')]
        [string][ValidateNotNullOrEmpty()]
        $Message,
        [alias('wd', 'WorkingDirectory')][ValidateNotNullOrEmpty()]
        $Location,
        [switch]$IgnoreExitCode,
        [switch]$AsJson
        #,[string]$Image # run in container image, TODO !!EH Mem, Cpu, disk, returns out-files/volume
    )

    function ConvertFrom-JsonInternal
    {
        [CmdletBinding()]
        param(
            [Parameter(ValueFromPipeline)] [string] $line
        )
        begin
        {
            $lines = [System.Collections.ArrayList]@()
        }
        process
        {
            [void]$lines.Add($line)
        }
        end
        {
            # only valid json passes, otherwise return original stream as strings
            try
            {
                return  $lines | ConvertFrom-Json
            }
            catch
            {
                return $lines | ConvertTo-String
            }
        }
    }
    
    # prevent accidental scope name collision
    $internalMessage = $Message
    Remove-Variable -Name Message -Scope Local
    $internalLocation = $Location
    Remove-Variable -Name Location -Scope Local
    $internalScriptBlock = $ScriptBlock
    Remove-Variable -Name ScriptBlock -Scope Local

    Write-Verbose "Start Executing $internalMessage"

    if (-not $PSCmdlet.ShouldProcess($internalLocation))
    {
        return
    }

    if ($internalLocation)
    {
        Push-Location $internalLocation
    }
    try
    {
        $Global:LastExitCode = 0
        # https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_scopes?view=powershell-7.1
        #. $internalScriptBlock # current scope == module scope for functions in modules (scripts are in user scope)
        # current scope == child scope
        if ($AsJson.IsPresent)
        {
            Invoke-Command -ScriptBlock $internalScriptBlock | ConvertFrom-JsonInternal
        }
        else
        {
            Invoke-Command -ScriptBlock $internalScriptBlock
        }
        [Console]::ResetColor() # some programs mess this up
        if ( ($Global:LastExitCode -ne 0) -and !$IgnoreExitCode.IsPresent )
        {
            # to prevent secret leaks use Message parameter in commands to hide secrets/passwords
            $msg = $internalMessage
            if (!$msg) { $msg = $internalScriptBlock.ToString() }
            Throw "Executing $msg failed with exit-code $($Global:LastExitCode)"
        }
    }
    finally
    {
        if ($internalLocation)
        {
            Pop-Location
        }
        Write-Verbose "Finish Executing $internalMessage"
    }
}

<#
.SYNOPSIS
Global function hooks which can be used at runtime to get more detailed information about the running workflow and actions

.DESCRIPTION
Global function hooks which can be used at runtime to get more detailed information about the running workflow and actions

#>


function Global:Invoke-BeforeWorkflow($Commands) { return $true }
function Global:Invoke-AfterWorkflow($Commands, $ErrorRecord) { }
function Global:Invoke-BeforePerform($Command) { return $true }
function Global:Invoke-AfterPerform($Command, $ErrorRecord) { }
function Global:Write-OnLog($Msg) {}
function Global:Write-OnLogException($Exception) {}

<#
.SYNOPSIS
DependsOn attribute to register function dependencies

.DESCRIPTION
DependsOn attribute to register function dependencies. Allows for using functions like Actions with dependency graph support.

.EXAMPLE

# implicit invokes function Invoke-Hello because function Invoke-Goodby is dependent on it

function Invoke-Hello
{
    Write-Info "Hello"
}

function Invoke-GoodBy
{
    [DependsOn(("Hello"))]param()
    Write-Info "GoodBy"
}

Start-Workflow Goodby

#>

class DependsOn : System.Attribute
{
    [string[]]$Name
    DependsOn([string[]]$name)
    {
        $this.Name = $name
    }
}

<#
.SYNOPSIS
Registers and validates a new Action for Workflow.

.DESCRIPTION
Registers and validates a new Action for Workflow. Action is recorded but not executed until the workflow starts.

.PARAMETER Name
.PARAMETER IsGroup
.PARAMETER Tag
.PARAMETER Depends
.PARAMETER Parameters
.PARAMETER AsJob
.PARAMETER If
.PARAMETER Description
.PARAMETER Disabled
.PARAMETER TypeName
.PARAMETER NextAction
.PARAMETER For
.PARAMETER Parallel
.PARAMETER Container
.PARAMETER ContainerOptions
.PARAMETER Session
.PARAMETER Isolated
.PARAMETER Unique
.PARAMETER RequiredVariables
.PARAMETER Comment
.PARAMETER SuppressOutput
.PARAMETER Code

.EXAMPLE
Register-Action

#>

function Register-Action
{
    param(
        [Parameter(Mandatory = $true, Position = 0)][string][ValidateNotNullOrEmpty()]$Name,
        [switch]$IsGroup,
        [string[]] $Tag = @(),
        [string[]] $Depends = @(),
        [AllowNull()]
        $Parameters = @{},
        [AllowNull()]
        [switch]$AsJob,
        [ScriptBlock] $If,
        [String]$Description,
        [bool]$Disabled = $false,
        [ValidateNotNullOrEmpty()]
        [string]$TypeName,
        [string]$NextAction,
        [ScriptBlock] $For,
        [switch]$Parallel,
        [switch]$Container,
        [HashTable]$ContainerOptions = @{},
        [AllowNull()]
        $Session,
        [switch]$Isolated,
        [switch]$Unique,
        [string[]]$RequiredVariables,
        [string]$Comment,
        [switch]$SuppressOutput,
        [ScriptBlock] $Code
    )

    $ctx = Get-RootContext
    $lName = $Name -replace 'Invoke-', ''
    if ($Unique.IsPresent)
    {
        $ctx.UniqueIdCounter += 1
        $lName = "$lName-$("{0:00}" -f $ctx.UniqueIdCounter)"
    }

    # Get Comments
    $text = $null
    if ($Comment)
    {
        $text = $Comment + [System.Environment]::NewLine
    }
    $text += Get-CommentFromCode -ScriptBlock $Code

    $lAction = New-Object PSObject -Property @{
        Code              = $Code
        Name              = "Action-$lName"
        DisplayName       = $lName
        Id                = (New-Guid)
        Tags              = $Tag
        ErrorAction       = $ErrorActionPreference
        Depends           = $Depends
        Parameters        = $Parameters
        AsJob             = [bool]$AsJob.IsPresent
        If                = $If
        Description       = $Description
        IsGroup           = $IsGroup.IsPresent
        Disabled          = $Disabled
        TypeName          = $TypeName
        NextAction        = $NextAction
        For               = $For
        Parallel          = $Parallel.IsPresent
        Container         = $Container.IsPresent
        ContainerOptions  = $ContainerOptions
        Session           = $Session
        Isolated          = $Isolated
        RequiredVariables = $RequiredVariables
        Comment           = ($text | Out-String)
        SuppressOutput    = $SuppressOutput
    }
    if ($ctx.Actions.ContainsKey($lAction.Name))
    {
        $displayName = $lAction.Name.Replace('Action-', '').Replace('Invoke-', '')
        Throw "Duplicate Name '$displayName' found, use unique name for each action/step/job/flow"
    }

    # add
    if ($ctx.InAction)
    {
        [void]$ctx.NestedActions.Add($lAction)
    }
    else
    {
        [void]$ctx.ActionSequence.Add($lAction)
    }
    [void]$ctx.Actions.Add($lAction.Name, $lAction)
    Set-Alias -Name $Name -Value Action -Scope Global -Force -ErrorAction Ignore -WhatIf:$false
}
<#
.SYNOPSIS
Resets the workflow global state/variables

.DESCRIPTION
Resets the workflow global state/variables and prepares for next workflow start in current session, enables support of multiple workflows in one session. Also used for unit testing purposes.

.EXAMPLE
Reset-Workflow

#>

function Reset-Workflow
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [switch]$Soft
    )

    if ($PSCmdlet.ShouldProcess("Reset-Workflow"))
    {
        $Script:RootContext = New-RootContext -WhatIf:$false -Soft:$Soft.IsPresent

        # compatibility with PSake
        $Script:PSakeProperties = $null
        $Script:PSakeInvocationParameters = $null
        $Script:PSakeInvocationProperties = $null
        $Script:PSakeSetupTask = $null
        $Script:PSakeTearDownTask = $null
    }
}
function Global:Start-Scriptbook
{
    param(
        $File,
        $Actions,
        $Parameters,
        [switch]$Container,
        [HashTable]$ContainerOptions = @{}
    )

    if ($Container.IsPresent -or ($ContainerOptions.Count -gt 0) -and !$env:InScriptbookContainer)
    {
        Start-ScriptInContainer -File $Script:MyInvocation.ScriptName -Options $ContainerOptions -Parameters $Parameters
        return
    }
    else
    {
        . $File -Actions $Actions -Parameters $Parameters
    }
} 
<#
.SYNOPSIS
Starts a Workflow defined by Workflow Actions or PowerShell Functions

.DESCRIPTION
Starts a Workflow defined by Workflow Actions or PowerShell Functions. Each action scriptblock is executed once in the order given by the workflow. Use the workflow report to see the final execution order/stack. The workflow is
executed by the order of actions found in the script Workflow file or by the actions parameter. When actions have dependencies they are resolved at run-time and executed according the dependency graph.

To influence the execution of actions use the action 'If' property

.PARAMETER Actions
Contains the Action(s) to execute in sequential order. Overrides the order found in the script file and limits the actions to execute. Depended actions are always executed except when switch NoDepends is used.

.PARAMETER Parameters
Parameters to pass to Workflow

.PARAMETER Name
Set the name of the workflow

.PARAMETER Tag
Set the workflow Tag(s)

.PARAMETER Location
Set current directory to location specified

.PARAMETER File
Starts the workflow actions from Workflow file specified

.PARAMETER NoReport
Disables the action report at the end of the workflow

.PARAMETER NoLogging
Disables the start/finish action logging

.PARAMETER NoDepends
Disables the calling of dependent actions. Allows for executing one specific Action

.PARAMETER Test
Starts workflow in 'TestWorkflow' Mode. No actions are executed except Test Actions

.PARAMETER Transcript
Creates a record of PowerShell Workflow session and saves this to a file.

.PARAMETER Container
Determines if workflow is run in container

.PARAMETER ContainerOptions
Determines the container options when running in a container. ContainerOptions.Image contains the container image used.

.PARAMETER Parallel
Determines if the workflow actions are executed in parallel. !Experimental

.PARAMETER WhatIf / Plan
Shows the Workflow execution plan

.PARAMETER Documentation
Shows the Workflow documentation and execution plan

.EXAMPLE
Start-WorkFlow

.REMARKS
- Workflow File not working yet --> Include ?

.NOTES
Workflow
Workflow is modeled as a set of actions invoked in some sequence where the completion of one action flows directly into the start of the next action

#>

Set-Alias -Name Start-Flow -Value Start-Workflow -Scope Global -Force -WhatIf:$false
Set-Alias -Name Start-Saga -Value Start-Workflow -Scope Global -Force -WhatIf:$false
Set-Alias -Name Start-Pipeline -Value Start-Workflow -Scope Global -Force -WhatIf:$false
function Start-Workflow
{
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Position = 0)]
        [AllowNull()]
        [array][alias('Actions', 'Steps', 'Jobs', 'Activities')]$WorkflowActions,
        [AllowNull()]
        [alias('Parameters')]
        $WorkflowParameters,
        [ValidateNotNullOrEmpty()]
        [alias('Name')]
        [string]$WorkflowName = '.',
        [String[]] $Tag = @(),        
        [alias('Location')]
        [string]$WorkflowLocation,
        [alias('File')]
        [string]$WorkflowFile,
        [switch]$NoReport,
        [switch]$NoLogging,
        [switch]$NoDepends,
        [alias('Test')]
        [switch]$TestWorkflow,
        [alias('Transcript')]
        [switch]$WorkflowTranscript,
        [alias('Container')]
        [switch]$WorkflowContainer,
        [alias('ContainerOptions')]
        [HashTable]$WorkflowContainerOptions = @{},
        [alias('Parallel')]
        [switch]$WorkflowParallel,
        [switch]$Plan,
        [switch]$Documentation
    )

    if ($WorkflowContainer.IsPresent -and !$env:InScriptbookContainer)
    {
        # TODO !!EH supply all the parameters of the script caller?
        Start-ScriptInContainer -File $Script:MyInvocation.ScriptName -Options $WorkflowContainerOptions # -Parameters ([Hashtable]$Global:MyInvocation.MyCommand.WorkflowParameters)
        return;
    }

    $Global:LastExitCode = 0
    $script:InvokedCommands = @()
    $script:InvokedCommandsResult = @()
    if ($WorkflowLocation)
    {
        $Script:WorkflowLocation = $WorkflowLocation
    }
    else
    {
        $Script:WorkflowLocation = Get-Location
    }
    $ctx = Get-RootContext
    $ctx.NoLogging = $NoLogging.IsPresent
    $isWhatIf = -not $PSCmdlet.ShouldProcess($WorkflowName, "Workflow")
    if ($Plan.IsPresent -or $Documentation.IsPresent)
    {
        $WhatIfPreference = $true
        $isWhatIf = $WhatIfPreference
    }

    $scriptName = $Script:MyInvocation.ScriptName
    if ([string]::IsNullOrEmpty($scriptName))
    {
        $scriptName = Join-Path $PSScriptRoot 'Scriptbook.ps1'
    }

    $hasErrors = $false
    $workflowStopwatch = [System.Diagnostics.Stopwatch]::StartNew();
    Write-ScriptLog @{action = "Workflow-Started"; param = $WorkflowActions; } -AsWorkflow
    $currentLocation = Get-Location
    try
    {
        if ($WorkflowTranscript.IsPresent)
        {
            Start-Transcript -Path "$scriptName.log" -Append -Force -IncludeInvocationHeader
        }
    
        try
        {
            if ($WorkflowFile -and (Test-Path $WorkflowFile) )
            {
                . $WorkflowFile
            }
            try
            {
                if (Global:Invoke-BeforeWorkflow -Commands $WorkflowActions)
                {
                    if ($null -ne $WorkflowActions -and ($WorkflowActions.count -gt 0) )
                    {
                        foreach ($action in $WorkflowActions)
                        {
                            if (!($action.StartsWith('!')))
                            {
                                Invoke-PerformIfDefined -Command "Invoke-$($action.Replace('Invoke-', ''))" -ThrowError $true -ActionParameters $WorkflowParameters -NoDepends:$NoDepends.IsPresent -Test:$TestWorkflow.IsPresent -WhatIf:$isWhatIf
                            }
                        }
                    }
                    else
                    {
                        Invoke-ActionSequence -Actions $ctx.ActionSequence -ThrowError $true -Test:$TestWorkflow.IsPresent -WhatIf:$isWhatIf -Parallel:$WorkflowParallel.IsPresent
                    }
                }
            }
            finally
            {
                Global:Invoke-AfterWorkflow -Commands $WorkflowActions
            }
        }
        catch
        {
            $hasErrors = $true
            Write-ExceptionMessage $_ -TraceLineCnt 15
            Global:Write-OnLogException -Exception $_.Exception
            Global:Invoke-AfterWorkflow -Commands $WorkflowActions -ErrorRecord $_ | Out-Null
            Throw
        }
        if ($Global:LastExitCode -ne 0) 
        {
            Write-Warning "Workflow: Unsuspected LastExitCode found from native commands/executable: $($Global:LastExitCode), check logging." 
            $Global:LastExitCode = 0;
        }        
    }
    finally
    {
        Set-Location $currentLocation
        $workflowStopwatch.Stop()
        
        if (!$NoReport.IsPresent)
        {            
            #TODO !!EH: Fix issue ansi escape sequences and Format-Table (invalid sizing)
            $script:InvokedCommandsResult | ForEach-Object { $hasErrors = if ($null -ne $_.Exception) { $true } else { $hasErrors }; <#if ($_.Exception) { $_.Name = "`e[37;41m$($_.Name)`e[0m" } else { $_.Name = "`e[00;00m$($_.Name)`e[0m" }; #> }

            if ($TestWorkflow.IsPresent)
            {
                $reportTitle = 'Workflow (Test) Report'
            }
            elseif ($Documentation.IsPresent)
            {
                $reportTitle = "Workflow Documentation"
            }
            elseif ($isWhatIf)
            {
                $reportTitle = "Workflow ($(if ($Plan.IsPresent) { 'Plan' } else { 'WhatIf' })) Report"
            }
            else
            {
                $reportTitle = 'Workflow Report'
            }

            Write-ScriptLog @{action = "Workflow-Finished"; } -AsWorkflow -AsError:$hasErrors

            if ($Documentation.IsPresent)
            {
                Write-Experimental "Workflow Documentation"
            }

            Write-Info ''.PadRight(78, '-')
            if ($hasErrors)
            {
                Write-Info "$reportTitle '$WorkflowName' with errors $((Get-Date).ToString('s'))" -ForegroundColor White -BackgroundColor Red
            }
            else
            {
                Write-Info "$reportTitle '$WorkflowName' $((Get-Date).ToString('s'))"
            }
            if ($Tag)
            {
                Write-Info $Tag
            }

            Write-Info ''.PadRight(78, '-')

            $script:InvokedCommandsResult | ForEach-Object { if ($_.Exception) { $_.Exception = $_.Exception.Message } }
            $script:InvokedCommandsResult | ForEach-Object { $_.Name = ''.PadLeft(($_.Indent) * 2, '-') + $_.Name }

            if ($Documentation.IsPresent)
            {
                if (Test-Path $scriptName)
                {
                    $text = Get-CommentFromCode -File $scriptName -First 1
                    Write-Info ($text | Out-String)
                    Write-Info ''.PadRight(78, '-')
                }

                $ctx = Get-RootContext
                if ($ctx.Infos.Count -gt 0)
                {
                    foreach ($info in $ctx.Infos)
                    {
                        Write-Info ($info | Out-String)
                    }
                    Write-Info ''.PadRight(78, '-')
                }

                $script:InvokedCommandsResult | ForEach-Object { 
                    $item = [PSCustomObject]$_
                    Write-Info "Action `e[0;36m$($item.Name)`e[0m" -ForegroundColor Magenta
                    Write-Info ''.PadRight(78, '-')
                    if (![string]::IsNullOrEmpty($item.Comment))
                    {
                        Write-Info "$($item.Comment)"
                    }
                    else
                    {
                        Write-Info '<no documentation>'
                    }
                    Write-Info ''.PadRight(78, '-')
                }
                $script:InvokedCommandsResult += @{ Name = ''; Duration = '================'; }
                Write-Info ''
                Write-Info "Workflow Sequence" -ForegroundColor Magenta
                $script:InvokedCommandsResult += @{ Name = 'Total'; Duration = $workflowStopwatch.Elapsed; }
    
                $script:InvokedCommandsResult | ForEach-Object { [PSCustomObject]$_ } | Format-Table -AutoSize -Property Name, Duration | Out-String | Write-Info
            }
            else
            {
                $script:InvokedCommandsResult += @{ Name = ''; Duration = '================'; }
                $script:InvokedCommandsResult += @{ Name = 'Total'; Duration = $workflowStopwatch.Elapsed; }
    
                $script:InvokedCommandsResult | ForEach-Object { [PSCustomObject]$_ } | Format-Table -AutoSize Name, Duration, Exception, @{Label = 'Output' ; Expression = { $_.ReturnValue } } | Out-String | Write-Info
            }
            Write-Info ''.PadRight(78, '-')
        }

        if ($WorkflowTranscript.IsPresent)
        {
            Stop-Transcript
        }
                
        Reset-Workflow -WhatIf:$false -Soft
    }

}
<#
.SYNOPSIS
Use workflow inline

.DESCRIPTION
Use workflow inline and aliases Flow and Pipeline

.REMARK
See parameters of Start-Workflow

.EXAMPLE

Use-Workflow -Name Workflow1 {
    Action Hello {
        Write-Info "Hello from Workflow1 1"
    }

    Action GoodBy {
        Write-Info "GoodBy from Workflow1 1"
    }
}

#>

Set-Alias -Name Flow -Value Use-Workflow -Scope Global -Force -WhatIf:$false
Set-Alias -Name Pipeline -Value Use-Workflow -Scope Global -Force -WhatIf:$false
function Use-Workflow
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [alias('Name')]
        [string]$WorkflowName,
        [array][alias('a', 'Action', 'Actions')]$WorkflowActions,
        [alias('Parameters')]
        $WorkflowParameters,
        [String[]] $Tag = @(),
        [alias('Location')]
        [string]$WorkflowLocation,
        [alias('File')]
        [string]$WorkflowFile,
        [switch]$NoReport,
        [switch]$NoLogging,
        [switch]$NoDepends,
        [alias('Test')]
        [switch]$TestWorkflow,
        [Parameter(Position = 1)]
        [alias('Code')]
        [ScriptBlock] $WorkflowCode
    )

    $invokeErrorAction = $ErrorActionPreference

    if ($null -eq $WorkflowCode)
    {
        Throw "No workflow code script block is provided and Name property is mandatory. (Have you put the open curly brace on the next line?)"
    }

    try 
    {
        & $WorkflowCode $WorkflowParameters
    }
    catch
    {        
        if ($invokeErrorAction -eq 'Continue')
        {
            Write-ScriptLog $_.Exception.Message -AsError
        }
        elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
        {
            Throw
        }
    }

    # force clear context on start each workflow
    Start-Workflow -Actions $WorkflowActions -Parameters $WorkflowParameters -Name $WorkflowName -Tag $Tag -Location $WorkflowLocation -WorkflowFile $WorkflowFile -NoReport:$NoReport.IsPresent -NoLogging:$NoLogging.IsPresent -NoDepends:$NoDepends.IsPresent -Test:$TestWorkflow.IsPresent
    Reset-Workflow -WhatIf:$false
}
<#
.SYNOPSIS
Get Invocation Bound parameters with default values.

.DESCRIPTION
Get Invocation Bound parameters with default values. $PSBoundParameters does not contain default values

.PARAMETER Invocation
Contains the $MyInVocation of the script / function

.EXAMPLE

Script with :

[CmdletBinding(SupportsShouldProcess = $True)]
Param(
    $Param1,
    $Param2,
)

$parameters = Get-BoundParametersWithDefaultValue $MyInvocation

Now call script with @parameters

. ./myScript @parameters

or function with @parameters

myFunction @parameters

#>

function Get-BoundParametersWithDefaultValue
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    param(
        $Invocation = $Global:MyInvocation
    )

    $parameters = @{}
    foreach ($parameter in $Invocation.MyCommand.Parameters.GetEnumerator())
    {
        try
        {
            $key = $parameter.Key
            $val = Get-Variable -Name $key -ErrorAction Stop | Select-Object -ExpandProperty Value -ErrorAction Stop
            [void]$parameters.Add($key, $val)
        }
        catch {}
    }
    return $parameters
}
<#
.SYNOPSIS
Sets an environment variable

.DESCRIPTION
Sets an environment variable, supports empty environment variable

.PARAMETER Name
Name of the environment variable

.PARAMETER Value
Value of the environment variable

.PARAMETER ToUpper
Forces environment variable name to UpperCase

#>

function Set-EnvironmentVariable
{
    [CmdletBinding(SupportsShouldProcess = $True)]
    param(
        [alias('n')][string]
        $Name,
        [alias('v')][string]
        $Value,
        [alias('g')][switch]$Global,
        [switch]
        $ToUpper
    )

    if ($Name)
    {
        $n = if ($ToUpper.IsPresent) { $Name.ToUpper() } else { $Name }
        if (!$Value) 
        {
            $v = [char]0x2422
        }
        else
        {
            $v = $Value
        }
        $g = if ($Global.IsPresent) { 'Machine' } else { 'Process' }
        if ($PSCmdlet.ShouldProcess('Set-EnvironmentVar'))
        {
            [Environment]::SetEnvironmentVariable($n, $v, $g )
        }
    }
}
<#
.SYNOPSIS
Starts a shell command and waits for it to finish

.DESCRIPTION
Starts a shell command with arguments in working directory and waits for it to finish. Optionally supply credential under which execution will take place.

.PARAMETER Command
Name of executable to start

.PARAMETER Arguments
Arguments to pass to executable

.PARAMETER Credential
Credential to start process with

.PARAMETER WorkingDirectory
Working directory of executable

.PARAMETER NoOutput
Suppress output of executable

.PARAMETER EnvVars
Additionally supply environment variables to process

.OUTPUTS
Returns Process StdOut, StdErr and ExitCode

.EXAMPLE
Start-ShellCmd -Command 'cmd.exe' -Arguments '/c'

.EXAMPLE
$r = Start-ShellCmd -Command 'pwsh' -Arguments '-NonInteractive -NoLogo -OutputFormat Text -ExecutionPolicy Bypass -Command Get-Process'
if ($r.ExitCode -ne 0) { throw "Invalid ExitCode returned from pwsh.exe : $($r.ExitCode)"}

.EXAMPLE
Start-ShellCmd -c 'pwsh' -a '-Command Get-Service' | Out-Null

#>

function Start-ShellCmd
{
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [alias('c')]
        $Command,
        [alias('a')]
        $Arguments,
        [PSCredential]
        $Credential,
        [alias('w')]
        $WorkingDirectory = '',
        [alias('no')][switch]
        $NoOutput,
        $EnvVars,
        [switch]
        $Progress
    )

    try
    {
        $pInfo = New-Object System.Diagnostics.ProcessStartInfo
        $pInfo.FileName = $Command
        $pInfo.RedirectStandardError = $true
        $pInfo.RedirectStandardOutput = $true
        $pInfo.UseShellExecute = $false
        $pInfo.Arguments = $Arguments
        $pInfo.ErrorDialog = $false
        if ((!$WorkingDirectory) -or ($WorkingDirectory -eq ''))
        {
            $pInfo.WorkingDirectory = Get-Location
        }
        else
        {
            $pInfo.WorkingDirectory = $WorkingDirectory
        }
        if ($env:SystemRoot)
        {
            $pInfo.LoadUserProfile = $true
        }
        if ($Credential)
        {
            $pInfo.UserName = $Credential.GetNetworkCredential().UserName
            if ($Credential.GetNetworkCredential().Domain)
            {
                $pInfo.Domain = $Credential.GetNetworkCredential().Domain
            }
            $pInfo.Password = $Credential.GetNetworkCredential().SecurePassword
        }    
        if ($EnvVars)
        {
            foreach ($v in $EnvVars.GetEnumerator())
            {
                if ($v.Key) { $pInfo.EnvironmentVariables[$v.Key] = $v.Value; }
            }
        }
        if ($PSCmdlet.ShouldProcess('Start-ShellCmd'))
        {
            $p = New-Object System.Diagnostics.Process
            $p.StartInfo = $pInfo
            if ($Progress.IsPresent)
            {
                $stdOutBuilder = New-Object -TypeName System.Text.StringBuilder
                $stdErrBuilder = New-Object -TypeName System.Text.StringBuilder
                $eventHandler = `
                {
                    if (![String]::IsNullOrEmpty($EventArgs.Data))
                    {
                        $Event.MessageData.Builder.AppendLine($EventArgs.Data)
                        if ($Event.MessageData.ShowOutput)
                        {
                            Write-Host $EventArgs.Data
                        }
                    }
                }
                $mdo = [PSCustomObject]@{
                    ShowOutput = !($NoOutput.IsPresent)
                    Builder    = $stdOutBuilder
                }
                $mde = [PSCustomObject]@{
                    ShowOutput = !($NoOutput.IsPresent)
                    Builder    = $stdErrBuilder
                }
                $stdOutEvent = Register-ObjectEvent -InputObject $p -Action $eventHandler -EventName 'OutputDataReceived' -MessageData $mdo
                $stdErrEvent = Register-ObjectEvent -InputObject $p -Action $eventHandler -EventName 'ErrorDataReceived' -MessageData $mde
                $p.Start() | Out-Null
                $handle = $p.Handle # cache handle to prevent $null ExitCode issue
                $p.BeginOutputReadLine()
                $p.BeginErrorReadLine()
                While (-not ($p.HasExited))
                {
                    $p.Refresh()
                }
                Unregister-Event -SourceIdentifier $stdOutEvent.Name; $stdOutEvent = $null;
                Unregister-Event -SourceIdentifier $stdErrEvent.Name; $stdErrEvent = $null;
                $so = $stdOutBuilder.ToString().TrimEnd("`r", "`n");
                $se = $stdErrBuilder.ToString()
            }
            else
            {
                $p.Start() | Out-Null
                $handle = $p.Handle # cache handle to prevent $null ExitCode issue
                $so = $p.StandardOutput.ReadToEnd()
                $se = $p.StandardError.ReadToEnd()
                $p.WaitForExit()
                if (!($NoOutput.IsPresent)) { Write-Info "$so $se" }
            }
            Write-Debug "Start-ShellCmd: Process Handle: $handle"
            [PSCustomObject]@{
                StdOut   = $so
                StdErr   = $se
                ExitCode = $p.ExitCode
            }
            $handle = $null
        }
    }
    catch
    {
        if ($_.Exception.Message.Contains('The stub received bad data'))
        {
            Throw "No domain name specified, add domain name to user '$($pInfo.UserName)' : $($_.Exception.Message)"
        }
        else
        {
            Throw
        }
    }
}
<#
.SYNOPSIS
Writes Info to console host.

.DESCRIPTION
Writes Info to console host.

.PARAMETER Object
Objects to display in the host.

.PARAMETER NoNewline
The string representations of the input objects are concatenated to form the output. No spaces or newlines are inserted between the output strings. No newline is added after the last output string.

.PARAMETER Separator
Specifies a separator string to insert between objects displayed by the host.

.PARAMETER ForegroundColor
Specifies the text color.

.PARAMETER BackgroundColor
Specifies the background color.

#>

function Write-Info
{
    [CmdletBinding()]
    param(
        [parameter(ValueFromPipeline = $True)]
        $Object,
        [switch]$NoNewline,
        $Separator,
        $ForegroundColor,
        $BackgroundColor
    )

    Begin
    {
        $parameters = @{ Object = $null }
        if ($NoNewline.IsPresent)
        {
            $parameters.Add('NoNewLine', $true)
        }
        if ($Separator)
        {
            $parameters.Add('Separator', $Separator)
        }
        # TODO !!EH Remap to ansi escape codes?
        if ($ForegroundColor)
        {
            $parameters.Add('ForegroundColor', $ForegroundColor)
        }
        if ($BackgroundColor)
        {
            $parameters.Add('BackgroundColor', $BackgroundColor)
        }
    }

    Process
    {
        $parameters.Object = $Object
        Write-Host @parameters        
    }

    End
    {
        [Console]::ResetColor()
    }
}
# experimental
function Add-FunctionOverWrite([alias('f', 'Func')]$aFunc, [alias('n', 'WithFunc')]$aWithFunc)
{
    Remove-Item "Alias:\$aFunc" -Force -ErrorAction Ignore
    New-Alias -Name $aFunc -Value $aWithFunc -Scope Global
}
# experimental
function Invoke-FunctionOverWritten([alias('f', 'Func')]$aFunc)
{
    $v = Get-Alias -Name $aFunc -Scope Global -ErrorAction Ignore
    if ($v)
    {
        Remove-Item "Alias:\$aFunc" -Force -ErrorAction Ignore
    }
    &$aFunc
    if ($v)
    {
        New-Alias -Name $aFunc -Value $v.Definition -Scope Global
    }
}

# experimental
function Remove-FunctionOverWrite
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param([alias('f', 'Func')]$aFunc)

    if ($PSCmdlet.ShouldProcess("Remove-FunctionOverWrite"))
    {    
        Remove-Item "Alias:\$aFunc" -Force -ErrorAction Ignore
    }
}

<#
.SYNOPSIS
    Returns if defined Powershell function has no body, aka is empty
#>

function Get-IsPSFunctionDefinitionEmpty
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    param([alias('Function')]$aFunction)

    try
    {
        $c = Get-Command $aFunction
        if ($c)
        {
            $d = $c.Definition
            if ($d)
            {
                return $d.Trim().Length -eq 0
            }
            else
            {
                return $true
            }
        }
    }
    catch
    {
        # ignore
    }
    return $false
}

function Get-RootContext
{
    return $Script:RootContext
}
function Invoke-ActionSequence
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "")]
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        $Actions,
        $ThrowError = $false,
        [switch]$Test,
        [switch]$Parallel
    )

    if ($null -eq $Actions -or ($Actions.Count -eq 0))
    {
        Write-Verbose "No actions/steps found to execute"
        return
    }

    if ($Parallel.IsPresent -and $PSVersionTable.PSVersion.Major -ge 7)
    {
        $hasDepends = $false
        foreach ($action in $Actions)
        {
            if ($action.Depends.Count -gt 0)
            {
                $hasDepends = $true
            }
        }

        Write-Experimental "Starting workflow in parallel mode"
        #TODO !!EH disabled for now, not working yet
        $hasDepends = $true
        if (!$HasDepends)
        {
            $mp = (Get-Module Scriptbook).Path
            $globalScriptVariables = Get-GlobalVarsForScriptblock -AsHashTable
            $rc = Get-RootContext
            $Actions | ForEach-Object -Parallel {
                $vars = $using:globalScriptVariables
                foreach ($v in $vars.GetEnumerator())
                {
                    Set-Variable $v.Key -Value $v.Value -Option ReadOnly -ErrorAction Ignore
                }
                Import-Module $using:mp -Args @{ Quiet = $true }
                $script:RootContext = $using:rc
                Invoke-PerformIfDefined -Command $_.Name -ThrowError $ThrowError -Test:$Test.IsPresent -WhatIf:$WhatIfPreference
            }
            return
        }
    }

    # sequential
    foreach ($action in $Actions)
    {
        Invoke-PerformIfDefined -Command $action.Name -ThrowError $ThrowError -Test:$Test.IsPresent -WhatIf:$WhatIfPreference
    }                    
}
<#
.SYNOPSIS
    Performs a command/action
#>

function Invoke-Perform
{
    [CmdletBinding(SupportsShouldProcess = $True)]
    param (
        [string][alias('c')]$Command,
        $Code = $null,
        [alias('d')]$Depends = $null,
        [alias('Parameters')]$ActionParameters,
        [bool]$NoReEntry,
        [bool][alias('AsJob')]$aAsJob,
        $If,
        [switch]$NoDepends,
        $NextAction, 
        $For, 
        [switch]$Parallel,
        [switch]$Container,
        [HashTable]$ContainerOptions,
        $Session,
        [switch]$Isolated,
        [switch]$Manual,
        [string]$TypeName,
        [string[]]$RequiredVariables,
        $Comment,
        [switch]$SuppressOutput
    )

    $invokeErrorAction = $ErrorActionPreference

    # only way to set Error preference in Scriptblock
    $Global:ErrorActionPreference = $invokeErrorAction

    $dependencies = $Depends

    # simple depth first dependencies with attribute on functions
    if (-not $NoDepends.IsPresent)
    {
        if (!$Code)
        {
            if ($stepCommand = Get-Command -Name $Command -CommandType Function)
            {
                $d = $stepCommand.ScriptBlock.Attributes.Where{ $_.TypeId.Name -eq 'DependsOn' }
                if ($d) { $dependencies = $d.Name }
            }
        }

        # simple depth first dependencies
        foreach ($dependency in $dependencies)
        {
            if (!$dependency.StartsWith('Invoke-')) { $dependency = "Invoke-$dependency" }
            if ($dependency -NotIn $script:InvokedCommands)
            {
                Invoke-PerformIfDefined -Command $dependency -ThrowError $true -Manual:$Manual.IsPresent
                $Script:RootContext.IndentLevel += 1
            }
        }
    }

    # Check re-entry
    if (!($NoReEntry))
    {
        if ($Command -in $script:InvokedCommands) { return; }
    }

    $cmdDisplayName = $Command.Replace('Action-', '').Replace('Invoke-', '')

    # Re-apply PSake properties and Parameters in Scope
    try
    {
        if ((Test-Path variable:Script:PSakeProperties) -and $Script:PSakeProperties)
        {
            . $Script:PSakeProperties
        }
        if ((Test-Path variable:Script:PSakeInvocationParameters) -and $Script:PSakeInvocationParameters)
        {
            $Script:PSakeInvocationParameters.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationParameters[$_] }
            $Script:PSakeInvocationParameters.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationParameters[$_] -Scope Script }
        }
        if ((Test-Path variable:Script:PSakeInvocationProperties) -and $Script:PSakeInvocationProperties)
        {
            $Script:PSakeInvocationProperties.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationProperties[$_] }
            $Script:PSakeInvocationProperties.Keys | ForEach-Object { Set-Variable -Name $_ -Value $Script:PSakeInvocationProperties[$_] -Scope Script }
        }
    }
    catch
    {
        Write-Warning "Issues in PSake properties and/or parameters"
        throw
    }

    # validate required variables
    if ($RequiredVariables -and $RequiredVariables.Count -gt 0)
    {
        $varsNotFound = [System.Collections.ArrayList]@()
        foreach ($var in $RequiredVariables)
        {
            If (!((Test-Path "variable:$var") -and ($null -ne (Get-Variable -Name $var))))
            {
                [void]$varsNotFound.Add($var)
            }
        }
        if ($varsNotFound.Count -gt 0)
        {
            Throw "Required variable(s) '$($varsNotFound -join ',')' not found in $cmdDisplayName"
        }
        $varsNotFound = $null
    }

    # check start condition
    if ($If)
    {
        $ifResult = & $If
        if (!$ifResult)
        {
            #Write-Verbose "Skipping action $cmdDisplayName If expression false"
            Write-ScriptLog @{action = "$($TypeName): $cmdDisplayName-Skipped"; time = $(Get-Date -Format s); } -AsSkipped
            $script:InvokedCommandsResult += @{ Name = "$cmdDisplayName"; Duration = 0; Indent = $Script:RootContext.IndentLevel; Exception = $null; ReturnValue = $null; Command = $Command; Comment = $Comment }
            return;
        }
    }

    $commandStopwatch = [System.Diagnostics.Stopwatch]::StartNew();
    Write-ScriptLog @{action = "$($TypeName): `e[0;36m$($cmdDisplayName)`e[0m"; time = $(Get-Date -Format s); } -AsAction

    Write-ScriptLog @{action = "$cmdDisplayName-Started"; time = $(Get-Date -Format s); } -AsAction -Verbose

    # check if we have something to execute
    if (!$Code)
    {
        if (!(Get-Item function:$Command -ErrorAction SilentlyContinue))
        {
            Throw "Required function '$Command' not found in script"
        }
    }
    
    $ex = $null
    $codeReturn = $null
    Push-Location $Script:WorkflowLocation
    $prevInAction = $Script:RootContext.InAction
    $prevNestedActions = $Script:RootContext.NestedActions
    $Script:RootContext.InAction = $true
    $Script:RootContext.NestedActions = New-Object -TypeName 'System.Collections.ArrayList'
    try
    {
        if (!$WhatIfPreference)
        {
            if ((Test-Path variable:Script:PSakeSetupTask) -and $Script:PSakeSetupTask)
            {
                & $Script:PSakeSetupTask $ActionParameters
            }
        }

        # check function without code
        if (!$Code -and (Get-IsPSFunctionDefinitionEmpty $Command)) { return; }

        $script:InvokedCommands += $Command

        if (!$WhatIfPreference)
        {
            $beforePerform = Global:Invoke-BeforePerform -Command $Command
        }
        else
        {
            $beforePerform = $true
        }

        if ($beforePerform)
        {   
            # TODO: EH!! Refactor into multiple functions/code block
            # execute the code or function
            if ($Code)
            {
                $mp = $null
                if (!$Isolated.IsPresent)
                {
                    $mp = (Get-Module Scriptbook).Path
                }

                if ($For)
                {
                    $forResult = & $For
                    if ($Parallel.IsPresent -and $PSVersionTable.PSVersion.Major -ge 7)
                    {
                        $codeReturn = @()
                        $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable
                        $codeAsString = $Code.ToString() # no scriptblock allowed in parallel ForEach :)
                        # $using:* is by ref with RunSpaces(parallel) but copy of var in Remoting
                        # $using:* needs to be thread-safe for parallel
                        if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke"))
                        {
                            $forResult | ForEach-Object -Parallel {
                                $vars = $using:globalScriptVariables
                                foreach ($v in $vars.GetEnumerator())
                                {
                                    Set-Variable $v.Key -Value $v.Value -WhatIf:$False
                                }
                                if ($using:mp)
                                {
                                    Import-Module $using:mp -Args @{ Quiet = $true }
                                }
                                $Parameters = $using:ActionParameters

                                $Parameters = $Parameters.Clone()
                                $Parameters.ForItem = $_
                                $Parameters.ForParallel = $true

                                # use local vars
                                Set-Variable ForItem -Value $_ -WhatIf:$False -Option Constant
                                Set-Variable ForParallel -Value $true -WhatIf:$False -Option Constant
                                Set-Variable Tag -Value $Parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore
                                Set-Variable Name -Value $Parameters.Name -WhatIf:$False
                                Set-Variable ActionName -Value $Parameters.ActionName -WhatIf:$False -Option ReadOnly
                                foreach ($v in $Parameters.Parameters.GetEnumerator())
                                {
                                    Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore
                                }

                                $code = [Scriptblock]::Create($using:codeAsString)
                                try 
                                {
                                    & $code $Parameters 
                                }
                                catch
                                {                                 
                                    if ($using:invokeErrorAction -eq 'Continue')
                                    {
                                        Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red
                                    }
                                    elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
                                    {
                                        Throw
                                    }
                                }
                            } | ForEach-Object { $codeReturn += $_ }
                        }
                    }
                    elseif ($aAsJob)
                    {
                        $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable
                        $codeAsString = $Code.ToString()
                        # $using:* is by ref with RunSpaces(parallel) but copy of var in Remoting
                        # $using:* needs to be thread-safe for parallel
                        if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke"))
                        {
                            $forResult | ForEach-Object { $item = $_; Start-Job -ScriptBlock {
                                    $vars = $using:globalScriptVariables
                                    foreach ($v in $vars.GetEnumerator())
                                    {
                                        Set-Variable $v.Key -Value $v.Value -WhatIf:$False
                                    }
                                    if ($using:mp)
                                    {
                                        Import-Module $using:mp -Args @{ Quiet = $true }
                                    }

                                    $Parameters = $using:ActionParameters

                                    $Parameters = $Parameters.Clone()
                                    $Parameters.ForItem = $using:item
                                    $Parameters.AsJob = $true

                                    # use local vars
                                    Set-Variable ForItem -Value $Parameters.ForItem -WhatIf:$False -Option Constant
                                    Set-Variable AsJob -Value $true -WhatIf:$False -Option Constant -ErrorAction Ignore
                                    Set-Variable Tag -Value $Parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore
                                    Set-Variable Name -Value $Parameters.Name -WhatIf:$False -Option Constant -ErrorAction Ignore
                                    Set-Variable ActionName -Value $Parameters.ActionName -WhatIf:$False -Option ReadOnly
                                    foreach ($v in $Parameters.Parameters.GetEnumerator())
                                    {
                                        Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore
                                    }

                                    $code = [Scriptblock]::Create($using:codeAsString)
                                    try
                                    {
                                        & $code $Parameters
                                    }
                                    catch
                                    {
                                        if ($using:invokeErrorAction -eq 'Continue')
                                        {
                                            Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red
                                        }
                                        elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
                                        {
                                            Throw
                                        }
                                    }
                                } 
                            } | Out-Null
                            Get-Job | Wait-Job | Out-Null
                            $codeReturn = Get-Job | Receive-Job
                            Get-Job | Remove-Job | Out-Null
                        }
                    }
                    else
                    {
                        $r = @()
                        Set-Variable AsJob -Value $true -Scope Global -WhatIf:$False
                        Set-Variable Tag -Value $ActionParameters.Tag -Scope Global -WhatIf:$False
                        Set-Variable Name -Value $ActionParameters.Name -Scope Global -WhatIf:$False
                        Set-Variable ActionName -Value $ActionParameters.ActionName -Scope Global -WhatIf:$False
                        Set-Variable Parameters -Value $ActionParameters.Parameters -Scope Global -WhatIf:$False
                        foreach ($v in $ActionParameters.Parameters.GetEnumerator())
                        {
                            Set-Variable $v.Key -Value $v.Value -Scope Global -WhatIf:$False
                        }
                        foreach ($forItem in $forResult)
                        {
                            $ActionParameters.ForItem = $forItem
                            $ActionParameters.ForParallel = $false
                            Set-Variable ForItem -Value $forItem -Scope Global -WhatIf:$False
                            if ($PSCmdlet.ShouldProcess("$cmdDisplayName with item '$($forItem)'", "Invoke"))
                            {
                                try 
                                {
                                    $r += & $Code $ActionParameters
                                }
                                catch
                                {
                                    if ($invokeErrorAction -ne 'Ignore') { Throw }
                                }
                            }
                        }
                        $codeReturn = $r
                    }
                }
                elseif ($aAsJob)
                {
                    $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable
                    $codeAsString = $Code.ToString()
                    $job = Start-Job -ScriptBlock {
                        $vars = $using:globalScriptVariables
                        foreach ($v in $vars.GetEnumerator())
                        {
                            Set-Variable $v.Key -Value $v.Value -WhatIf:$False
                        }
                        if ($using:mp)
                        {
                            Import-Module $using:mp -Args @{ Quiet = $true }
                        }
                        $parameters = $using:ActionParameters
                        Set-Variable Tag -Value $parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore
                        Set-Variable Name -Value $parameters.Name -WhatIf:$False -Option Constant -ErrorAction Ignore
                        Set-Variable ActionName -Value $parameters.ActionName -WhatIf:$False -Option ReadOnly
                        foreach ($v in $parameters.Parameters.GetEnumerator())
                        {
                            Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore
                        }

                        $code = [Scriptblock]::Create($using:codeAsString)
                        try 
                        {
                            & $code $parameters
                        }
                        catch
                        {
                            if ($using:invokeErrorAction -eq 'Continue')
                            {
                                Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red
                            }
                            elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
                            {
                                Throw
                            }
                        }
                    }
                    if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke"))
                    {
                        $job | Wait-Job | Out-Null
                        $codeReturn = $job | Receive-Job
                        $job | Remove-Job | Out-Null
                    }
                }
                elseif ($Container.IsPresent -or ($ContainerOptions.Count -gt 0))
                {
                    #TODO !!EH No nested action/code supported yet, detect nested code and wrap in workflow of it's own or add container support to Invoke-ActionSequence
                    if ($TypeName -eq 'Stage')
                    {
                        Write-Unsupported "Running Stage in Container"
                    }
                    else
                    {
                        Start-ScriptInContainer -ActionName $cmdDisplayName -ActionType $TypeName -Options $ContainerOptions -Parameters $ActionParameters -Isolated:$Isolated.IsPresent -Code $Code
                    }
                }
                elseif ($Session)
                {
                    if ($Isolated.IsPresent)
                    {
                        $sb = $Code
                    }
                    else
                    {
                        $globalScriptVariables = Get-GlobalVarsForScriptblock -Isolated:$Isolated.IsPresent -AsHashTable
                        $codeAsString = $Code.ToString()
                        $sb = {
                            $vars = $using:globalScriptVariables
                            foreach ($v in $vars.GetEnumerator())
                            {
                                Set-Variable $v.Key -Value $v.Value
                            }

                            #TODO !!EH Do we need Scriptbook Module in remote, so yes install module remote (copy to remote first)
                            # if ($using:mp)
                            # {
                            # Import-Module $using:mp -Args @{ Quiet = $true }
                            # }

                            $parameters = $using:ActionParameters
                            Set-Variable Tag -Value $parameters.Tag -WhatIf:$False -Option Constant -ErrorAction Ignore
                            Set-Variable Name -Value $parameters.Name -WhatIf:$False -Option Constant -ErrorAction Ignore
                            Set-Variable ActionName -Value $parameters.ActionName -WhatIf:$False -Option ReadOnly
                            foreach ($v in $parameters.Parameters.GetEnumerator())
                            {
                                Set-Variable $v.Key -Value $v.Value -WhatIf:$False -Option Constant -ErrorAction Ignore
                            }
                            $code = [Scriptblock]::Create($using:codeAsString)
                            try 
                            {
                                & $code $parameters
                            }
                            catch
                            {
                                if ($using:invokeErrorAction -eq 'Continue')
                                {
                                    Write-Host $_.Exception.Message -ForegroundColor White -BackgroundColor Red
                                }
                                elseif ($using:invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
                                {
                                    Throw
                                }
                            }
                        }

                    }
                    if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke"))
                    {
                        try
                        {
                            $codeReturn = Invoke-Command -Session $Session -ScriptBlock $sb -Args $ActionParameters
                        }
                        catch
                        {
                            if ($invokeErrorAction -eq 'Continue')
                            {
                                Write-ScriptLog $_.Exception.Message -AsError
                            }
                            elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
                            {
                                Throw
                            }
                        }
                    }
                }
                else
                {
                    Set-Variable AsJob -Value $true -Scope Global -WhatIf:$False
                    Set-Variable Tag -Value $ActionParameters.Tag -Scope Global -WhatIf:$False
                    Set-Variable Name -Value $ActionParameters.Name -Scope Global -WhatIf:$False
                    Set-Variable ActionName -Value $ActionParameters.ActionName -Scope Global -WhatIf:$False
                    Set-Variable Parameters -Value $ActionParameters.Parameters -Scope Global -WhatIf:$False
                    if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke"))
                    {
                        try
                        {
                            $codeReturn = & $Code $ActionParameters
                        }
                        catch
                        {
                            if ($invokeErrorAction -eq 'Continue')
                            {
                                Write-ScriptLog $_.Exception.Message -AsError
                            }
                            elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
                            {
                                Throw
                            }
                        }
                    }
                }
            }
            else
            {
                if ($PSCmdlet.ShouldProcess($cmdDisplayName, "Invoke"))
                {
                    try 
                    {
                        $codeReturn = &"$Command"
                    }
                    catch
                    {
                        if ($invokeErrorAction -eq 'Continue')
                        {
                            Write-ScriptLog $_.Exception.Message -AsError
                        }
                        elseif ($invokeErrorAction -notin 'Ignore', 'SilentlyContinue')
                        {
                            Throw
                        }
                    }
                }
            }

            # execute nested actions
            if ($Script:RootContext.NestedActions.Count -gt 0)
            {
                $Script:RootContext.IndentLevel += 1
                try
                {
                    Invoke-ActionSequence -Actions $Script:RootContext.NestedActions -ThrowError $true -Test:$Test.IsPresent -WhatIf:$WhatIfPreference
                }
                finally
                {
                    $Script:RootContext.IndentLevel -= 1
                }
            }

            # show ReturnValue(s) on console
            if ($codeReturn -and !$SuppressOutput.IsPresent)
            {
                if ($codeReturn -is [array])
                {
                    foreach ($line in $codeReturn)
                    {
                        if ($line -is [string])
                        {
                            Write-Info $line
                        }
                        else
                        {
                            $line
                        }
                    }
                }
                else
                {
                    $codeReturn
                }
            }

            if (!$WhatIfPreference)
            {
                Global:Invoke-AfterPerform -Command $Command

                if ((Test-Path variable:Script:PSakeTearDownTask) -and $Script:PSakeTearDownTask)
                {
                    & $Script:PSakeTearDownTask $ActionParameters
                }
            }
        }
        
    }
    catch
    {
        if ($invokeErrorAction -eq 'Stop')
        {
            $ex = $_.Exception
            Global:Invoke-AfterPerform -Command $Command -ErrorRecord $_
            Global:Write-OnLogException -Exception $ex
            Throw
        }
        if ($invokeErrorAction -eq 'Continue')
        {
            $ex = $_.Exception
            Write-ExceptionMessage $_ -TraceLineCnt 5
            Global:Invoke-AfterPerform -Command $Command -ErrorRecord $_
            Global:Write-OnLogException -Exception $ex
        }
        elseif ($invokeErrorAction -eq 'ContinueSilently')
        {
            Global:Invoke-AfterPerform -Command $Command
        }
        else
        {
            # ignore
            Global:Invoke-AfterPerform -Command $Command
        }
    }
    finally
    {
        Write-ScriptLog @{action = "$cmdDisplayName-Finished"; time = $(Get-Date -Format s); } -AsError:($null -ne $ex) -AsAction -Verbose
        $Script:RootContext.InAction = $prevInAction
        $Script:RootContext.NestedActions = $prevNestedActions
        $indent = $Script:RootContext.IndentLevel
        if ($Manual.IsPresent)
        {
            $indent += 1
        }
        $script:InvokedCommandsResult += @{ Name = "$cmdDisplayName"; Duration = $commandStopwatch.Elapsed; Indent = $indent; Exception = $ex; ReturnValue = $codeReturn; Command = $Command; Comment = $Comment }
        Pop-Location
    }

    if ($NextAction)
    {
        if ($PSCmdlet.ShouldProcess($NextAction, "Invoke"))
        {
            $action = "Action-$NextAction"
            Invoke-PerformIfDefined -Command $action -ThrowError $ThrowError -Test:$Test.IsPresent -WhatIf:$WhatIfPreference
        }
    }
}

<#
.SYNOPSIS
Checks if command/action is defined in script / workflow before executing

.DESCRIPTION
Checks if command/action is defined in script / workflow before executing, In test mode only execute test actions from workflow

#>

function Invoke-PerformIfDefined
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "")]
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [string][alias('c')]$Command,
        $ThrowError = $false,
        $ActionParameters,
        [bool]$NoReEntry,
        [bool]$AsJob = $false,
        [switch]$NoDepends,
        [switch]$Test,
        [switch]$Manual
    )

    $ctx = Get-RootContext
    $action = $ctx.Actions[$Command.Replace('Invoke-', 'Action-')]
    if ($action)
    {
        if ($action.Disabled) { return }
        # in test mode only execute test actions from workflow
        # not in Test mode don't execute test actions
        if ($Test.IsPresent)
        {
            if ($action.TypeName -ne 'Test')
            {
                return
            }
        }
        else
        {
            if ($action.TypeName -eq 'Test')
            {
                return
            }
        }

        if ($ActionParameters) { $ap = $ActionParameters } else { $ap = $action.Parameters }
        if ($AsJob) { $aj = $true } else { $aj = $action.AsJob }
        #TODO/FIXME !!EH Put action into separate method?
        Invoke-Perform -Command $action.Name -Code $action.Code -Depends $action.Depends -ErrorAction $action.ErrorAction -ActionParameters @{Name = $action.DisplayName; ActionName = $action.DisplayName; Tag = $action.Tags; Parameters = $ap } -NoReEntry $NoReEntry -AsJob $aj -If $action.If -NoDepends:$NoDepends.IsPresent -WhatIf:$WhatIfPreference -NextAction $Action.NextAction -For $action.For -Parallel:$action.Parallel -Container:$action.Container -ContainerOptions:$action.ContainerOptions -Session $action.Session -Isolated:$action.Isolated -Manual:$Manual.IsPresent -TypeName $action.TypeName -RequiredVariables $action.RequiredVariables -Comment $action.Comment -SuppressOutput:$action.SuppressOutput
    }
    else
    {
        # in test mode only execute test functions from workflow
        # not in Test mode don't execute test functions
        if ($Test.IsPresent)
        {
            if (!$Command.ToLower().Contains('test'))
            {
                return
            }
        }
        else
        {
            if ($Command.ToLower().Contains('test'))
            {
                return
            }            
        }
        if (Get-Item function:$Command -ErrorAction SilentlyContinue)
        {
            Invoke-Perform -Command $Command -AsJob $AsJob -NoDepends:$NoDepends.IsPresent -WhatIf:$WhatIfPreference -TypeName 'Function'
        }
        elseif (Get-Item function:$($Command.Replace('Invoke-', '')) -ErrorAction SilentlyContinue)
        {
            Invoke-Perform -Command $Command.Replace('Invoke-', '') -AsJob $AsJob -NoDepends:$NoDepends.IsPresent -WhatIf:$WhatIfPreference -TypeName 'Function'
        }
        elseif ($ThrowError)
        {
            Throw "Action $($Command.Replace('Invoke-', '').Replace('Action-', '')) or Command $Command not found in ScriptFile(s)"
        }
    }
}

function New-RootContext
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [switch]$Soft
    )

    if ($PSCmdlet.ShouldProcess("New-RootContext"))
    {
        $actions = New-Object -TypeName 'System.Collections.Generic.Dictionary[String,object]' -ArgumentList @([System.StringComparer]::InvariantCultureIgnoreCase)
        $actionSequence  = New-Object -TypeName 'System.Collections.ArrayList'
        $infos = New-Object -TypeName 'System.Collections.ArrayList'
        if ($Soft.IsPresent)
        {
            if ($Script:RootContext)
            {
                $actions = $Script:RootContext.Actions
                $actionSequence = $Script:RootContext.ActionSequence
                $infos = $Script:RootContext.Infos
            }
        }

        New-Object PSObject -Property @{
            Actions         = $actions
            ActionSequence  = $actionSequence
            IndentLevel     = 0
            NoLogging       = $false
            Id              = New-Guid
            InAction        = $false
            NestedActions   = New-Object -TypeName 'System.Collections.ArrayList'
            UniqueIdCounter = 1
            Infos           = $infos
        }    
    }
}

$Script:RootContext = New-RootContext -WhatIf:$false

$ErrorActionPreference = 'Stop';

function InternalForceCultureEnglish
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    param()

    try { [CultureInfo]::CurrentCulture = 'en-US' } catch {}
}

# default
InternalForceCultureEnglish

function Get-TempPath()
{
    if ( $env:TEMP ) { return ([System.IO.DirectoryInfo]$env:TEMP).FullName } else { return '/tmp' }
}

function Test-FileLocked([alias('p')][parameter(Mandatory = $true)]$Path)
{
    $f = New-Object System.IO.FileInfo $Path
    if ((Test-Path -Path $Path) -eq $false)
    {
        return $false
    }
    try
    {
        $oStream = $f.Open([System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
        if ($oStream)
        {
            $oStream.Close()
        }
        return $false
    }
    catch
    {
        # file is locked by a process.
        return $true
    }
}


function Get-GlobalVarsForScriptblock
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")]
    param([switch]$Isolated, [switch]$AsUsing, [switch]$AsHashTable)

    if ($Isolated.IsPresent)
    {
        if ($AsHashTable.IsPresent)
        {
            return @{}
        }
        else
        {
            return ''
        }
    }

    if ($AsHashTable.IsPresent)
    {
        $result = @{}
        Get-Variable -Scope Global | ForEach-Object {
            if ( (!$Global:GlobalVarNames.ContainsKey($_.Name)) -and (!($_.Name.StartsWith('_'))) )
            {
                [void]$result.Add($_.Name, $_.Value)
            } 
        }
    }
    elseif ($AsUsing.IsPresent)
    {
        [string]$result = Get-Variable -Scope Global | ForEach-Object {
            if ( (!$Global:GlobalVarNames.ContainsKey($_.Name)) -and (!($_.Name.StartsWith('_'))) )
            {
                "`$$($_.Name) = `$using:$($_.Name); "
            } 
        }
    }
    else
    {
        # TODO !!EH Issue with $null values and PSCustomObjects, don't work with .ToString()...
        # reformat via ast
        # create scriptblock from string with Set-Variable 'Name' -Value $null
        # parse scriptblock with ast and set value of var
        [string]$result = Get-Variable -Scope Global | ForEach-Object {
            if ( (!$Global:GlobalVarNames.ContainsKey($_.Name)) -and (!($_.Name.StartsWith('_'))) )
            {
                if ($null -eq $_.Value)
                {
                    "Set-Variable '$($_.Name)' -Value `$null ; "
                }
                elseif ($_.Value -is [string])
                {
                    "Set-Variable '$($_.Name)' -Value '$($_.Value)' ; "
                }
                elseif ($_.Value -is [array])
                {
                    # not working yet array to string
                    "Set-Variable '$($_.Name)' -Value '$($_.Value)' ; "
                }
                elseif ($_.Value -is [PSCustomObject] )
                {
                    # for now, figure out $v.ToString()...
                    "Set-Variable '$($_.Name)' -Value `$null ; "
                }
                else
                {
                    "Set-Variable '$($_.Name)' -Value $($_.Value) ; "
                }
            } 
        }
    }
    return $result
}

function Write-ExceptionMessage([alias('e')]$ErrorRecord, [alias('f')][switch]$Full = $false, [alias('tlc')]$TraceLineCnt = 0)
{
    if (($VerbosePreference -eq 'Continue') -or $Full.IsPresent)
    {
        Write-Info ($ErrorRecord | Format-List * -Force | Out-String) -ForegroundColor White -BackgroundColor Red
    }
    else
    {
        Write-Info ''
        Write-Info 'Error:'.PadRight(78, ' ') -ForegroundColor White -BackgroundColor Red
        Write-Info $ErrorRecord.Exception.Message -ForegroundColor Red
        if ($TraceLineCnt -ne 0)
        {
            $cnt = 0;
            Write-Info ''
            Write-Info 'CallStack:'.PadRight(78, ' ') -ForegroundColor Black -BackgroundColor Yellow
            foreach ($line in $ErrorRecord.ScriptStackTrace.Split("`n"))
            {
                Write-Info $line -ForegroundColor Yellow
                $cnt++
                if ($cnt -ge $TraceLineCnt) { break; }
            }            
            Write-Info ''
        }
    }
}

function Write-Experimental($Msg)
{
    Write-Warning "Experimental: $Msg"
}

function Write-Unsupported($Msg)
{
    Write-Warning "Unsupported: $Msg"
}

function Get-CommentFromCode($ScriptBlock, $Script, $File, [int]$First = -1, [switch]$IncludeLineComments)
{
    $text = $null
    $tokens = $errors = $null
    if ($ScriptBlock)
    {
        [System.Management.Automation.Language.Parser]::ParseInput($ScriptBlock.ToString(), [ref]$tokens, [ref]$errors) | Out-Null
    }
    elseif ($Script)
    {
        [System.Management.Automation.Language.Parser]::ParseInput($Script, [ref]$tokens, [ref]$errors) | Out-Null
    }
    elseif ($File)
    {
        [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$tokens, [ref]$errors) | Out-Null
    }
    else
    {
        Throw "Get-CommentFromCode: No input supplied"
    }

    $maxTokens = $First
    $cntTokens = 0
    foreach ($token in $tokens )
    {
        if ($token.Kind -eq 'comment')
        {
            if ($token.Text)
            {
                if ($token.Text.StartsWith('#') -and !$IncludeLineComments.IsPresent)
                {
                    continue;
                }
                $txt = $token.Text -Split "`n"
                if ($txt.Length -gt 1)
                {
                    # get indent from last line and strip
                    $indent = $txt[$txt.Length-1].TrimEnd('#>');
                    if ($indent.Length -gt 0) 
                    {
                        $txt = $txt | ForEach-Object { $_.TrimStart($indent) }
                    }
                    $text += $txt | Select-Object -Skip 1 -First ($txt.Count - 2) -ErrorAction Ignore
                }
                else
                {
                    if ($txt.StartsWith('#'))
                    {
                        $text += $txt.TrimStart('# ') + "`n"
                    }
                    else
                    {
                        $text += $txt.TrimStart('<#').TrimEnd('#>') + "`n"
                    }
                }
            }
        }
        $cntTokens++
        if ( ($maxTokens -ne -1) -and ($cntTokens -ge $maxTokens) )
        {
            break
        }
    }
    return $text
}
function Start-ScriptInContainer
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    param(
        $File,
        $Options,
        $Parameters,
        $ActionName,
        $ActionType = 'Action',
        [switch]$Isolated,
        [scriptblock]$Code
    )

    if ($null -eq (Get-Command docker -ErrorAction Ignore))
    {
        Write-Warning 'Docker not installed or found on this system'
        return
    }

    # determine Scriptbook module path
    $m = Get-Module Scriptbook
    if ($null -eq $m)
    {
        Throw "Scriptbook module not found in Start-ScriptInContainer"
    }
    $scriptbookModulePath = Split-Path $m.Path -Parent
    #TODO !!EH make this safe with correct location of current user modules
    # /$home/.local/share/powershell/Modules
    # $home\Documents\PowerShell\Modules
    $userModulePath = Split-Path $m.Path -Parent
    $userModulePath = (Resolve-Path (Join-Path $userModulePath '../..')).Path

    # script path
    if ($File)
    {
        $f = Resolve-Path $File
        $scriptPath = Split-Path $f -Parent
        $scriptName = Split-Path $f -Leaf
    }
    else
    {
        $scriptPath = Get-Location
        $scriptName = ''
    }

    $root = $null
    if ($Options.ContainsKey('Root') -and ![string]::IsNullOrEmpty($Options.Root))
    {
        $root = Resolve-Path $Options.Root
        if ($File)
        {
            if ($scriptPath.Contains($root.Path))
            {
                $replacePath = Join-Path $root.Path '/'
                $scriptName = Join-Path $scriptPath.Replace($replacePath, '') $scriptName
            }
            else
            {
                throw "Script $File not found in root path '$root', orphaned paths not supported."
            }
        }
        $scriptPath = $root.Path
    }

    # Get container Os (Windows or Linux)
    $platform = 'linux'
    $windowsContainer = $false
    try
    {
        $r = docker version --format json | ConvertFrom-Json
        if ($r)
        {
            $windowsContainer = $r.Server.Os -eq 'windows'
            $platform = "$($r.Server.Os)/$($r.Server.Arch)"
        }
    }
    catch
    {
        # circumvent erratic behavior json output docker
    }
    if ($Options.ContainsKey('Platform') -and ![string]::IsNullOrEmpty($Options.Platform))
    {
        if ($Options.Platform.Contains('linux'))
        {
            $windowsContainer = $false
            $platform = $Options.Platform
        }
        elseif ($Options.Platform.Contains('windows'))
        {
            $windowsContainer = $true
            $platform = $Options.Platform
        }
    }

    $containerName = New-Guid
    $cImage = 'mcr.microsoft.com/dotnet/sdk:5.0' #TODO !!EH hardcoded for now, move to Import-Module?
    if ($Options.ContainsKey('Image') -and ![string]::IsNullOrEmpty($Options.Image))
    {
        $cImage = $Options.Image
    }

    if ($Options.ContainsKey('Isolated'))
    {
        $Isolated = $Options.Isolated
    }

    # map scriptbook module, user modules, and script
    if ($windowsContainer)
    {
        $workFolderName = 'Users\Public'
        $volumeVars = [System.Collections.ArrayList]@('-v', "`"$($scriptPath):c:\Workflow\Scripts`"", '-v', "`"$($userModulePath):c:\Workflow\ModulePath`"", '-v', "`"$($scriptbookModulePath):c:\Workflow\Scriptbook`"")
    }
    else
    {
        $workFolderName = 'home'
        $volumeVars = [System.Collections.ArrayList]@('-v', "`"$($scriptPath):/Workflow/Scripts`"", '-v', "`"$($userModulePath):/Workflow/ModulePath`"", '-v', "`"$($scriptbookModulePath):/Workflow/Scriptbook`"")
    }    
    if ($env:RUNNER_TOOLSDIRECTORY)
    {
        [void]$volumeVars.Add('-v'); [void]$volumeVars.Add("`"$($env:RUNNER_TOOLSDIRECTORY):/opt/hostedtoolcache`"")
    }

    $envVars = [System.Collections.ArrayList]@('-e', 'InScriptbookContainer=True', '-e', "Script=$scriptName", '-e', "Action=$ActionName" )
    foreach ($item in Get-ChildItem env:SCRIPTBOOK_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); }

    # Add Azure DevOps & github env vars
    if ($env:SYSTEM_TEAMPROJECT)
    {
        foreach ($item in Get-ChildItem env:BUILD_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); }
        foreach ($item in Get-ChildItem env:SYSTEM_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); }
        foreach ($item in Get-ChildItem env:AGENT_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); }
        foreach ($item in Get-ChildItem env:RUNNER_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); }
    }
    if ($env:GITHUB_ACTIONS)
    {
        foreach ($item in Get-ChildItem env:GITHUB_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); }
        foreach ($item in Get-ChildItem env:RUNNER_*) { [void]$envVars.Add('-e'); [void]$envVars.Add("$($item.Name)=$($item.Value)"); }
    }

    if (!$File)
    {
        #TODO !!EH Issue with $null values and PSCustomObjects, don't work with .ToString(). See Get-GlobalVarsForScriptblock
        $variablesToAdd = Get-GlobalVarsForScriptblock
    }

    $quiet = $false
    if ($Options.ContainsKey('Quiet'))
    {
        $quiet = $Options.Quiet
    }

    $detailed = $false
    if ($Options.ContainsKey('Detailed'))
    {
        $detailed = $Options.Detailed
    }
    if ($detailed)
    {
        $quiet = $false
    }

    $importCode = @"
`$inContainer = `$env:InScriptbookContainer;
if (`$inContainer)
{
    `$isolatedTag = '';
    if (`$$Isolated)
    {
        `$isolatedTag = ' Isolated';
    }

    `$typeTag = '$ActionType';
    `$typeName = `$env:Action;
    if (`$env:Script)
    {
        `$typeTag = 'script';
        `$typeName = `$env:Script;
    }

    $(
        if (!$quiet)
        {
@"
            Write-Host ''.PadRight(78, '=');
            Write-Host "Running `$typeTag '`$typeName'`$isolatedTag";
            Write-Host " -In Container '$cImage' On '`$([Environment]::OSVersion.VersionString)'";
            Write-Host " -As `$([Environment]::UserName) With 'PowerShell `$(`$PSVersionTable.PsVersion)' At `$((Get-Date).ToString('s'))";
            Write-Host ''.PadRight(78, '=');
"@
        }
        if ($detailed)
        {
@"
            Write-Host 'Environment variables:'
            Get-ChildItem env:* | Sort-Object -Property Name | Out-String | Write-Host;
"@

        }
    )
}

$(
    if (!$Isolated)
    {
        if ($windowsContainer)
        {
@"
            `$env:PSModulePath = `$env:PSModulePath + ';c:\Workflow\ModulePath' + ';c:\Workflow\Scriptbook';
"@
        }
        else
        {
@"
            `$env:PSModulePath = `$env:PSModulePath + ':/Workflow/ModulePath' + ':/Workflow/Scriptbook';
"@
        }
@"
        Set-Location '/Workflow/Scripts';
"@

    }
    else
    {
        if ($File)
        {
            if ($windowsContainer)
            {
@"
                `$env:PSModulePath = `$env:PSModulePath + ';c:\$workFolderName\Scriptbook';
                Set-Location '\$workFolderName\Scripts';
"@
            }
            else
            {
@"
                `$env:PSModulePath = `$env:PSModulePath + ':/$workFolderName/Scriptbook';
                Set-Location '/$workFolderName/Scripts';
"@
            }
        }
    }
)

Write-Verbose "Current location: `$(Get-Location)";

$(
    if ($File)
    {
        # run script
        Write-Output "&`"./$scriptName`" -WhatIf:!$WhatIfPreference"
    }
    else
    {
        if (!$Isolated)
        {
            # import module
            Write-Output "Import-Module /Workflow/Scriptbook/Scriptbook.psm1 -Args @{ Quiet = `$true };"
            # importing vars
            $variablesToAdd
        }
        if ($WhatIfPreference)
        {
            Write-Output "Write-Host 'What if: Performing the operation `"Invoke`" on target `"$ActionName`"'; "
            Write-Output "return;"
        }
    }
)

"@


    $finishCode = @"
`$inContainer = `$env:InScriptbookContainer;
if (`$inContainer)
{
    `$typeTag = '$ActionType';
    `$typeName = `$env:Action;
    if (`$env:Script)
    {
        `$typeTag = 'script';
        `$typeName = `$env:Script;
    }

    $(
        if (!$quiet)
        {
@"
            Write-Host ''.PadRight(78, '=');
            Write-Host "Finished `$typeTag '`$typeName'";
            Write-Host ''.PadRight(78, '=');
"@
        }
    )

    }
"@


    if ($Isolated.IsPresent)
    {
        $volumeVars = @()
    }

    if ($ActionName)
    {
        $importCode = [scriptblock]::Create($importCode + "`n" + $Code.ToString() + "`n" + $finishCode)
    }
    else
    {
        $importCode = $importCode + "`n" + $finishCode
    }

    $encodedCommand = [System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($importCode))

    $dockerContext = $null
    if ($Options.ContainsKey('Context') -and ![string]::IsNullOrEmpty($Options.Context))
    {
        $dockerContext = $Options.Context
    }

    $useSeparateDockerCommands = $true
    if ($Options.ContainsKey('Run'))
    {
        $useSeparateDockerCommands = -not $Options.Run
    }

    $containerStarted = $false
    try
    {
        Write-Verbose "Running container '$containerName' with image '$cImage' in $(Get-Location)"

        if ($dockerContext)
        {
            $r = docker context use $dockerContext
            if ($LASTEXITCODE -ne 0) { Throw "Error in docker context switch $dockerContext : $LastExitCode $r" }
        }

        if ($useSeparateDockerCommands)
        {
            $r = docker create @envVars @volumeVars --platform=$platform --tty --interactive --name "$containerName" $cImage
            if ($LASTEXITCODE -ne 0) { Throw "Error in docker create for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode $r" }

            if ($File -and $Isolated.IsPresent)
            {
                # copy script and modules when isolated
                docker cp "$scriptPath" "$($containerName):/$workFolderName/Scripts"
                if ($m.RepositorySourceLocation)
                {
                    $tmp = Join-Path (Get-TempPath) (New-Guid)
                    New-Item $tmp -ItemType Directory | Out-Null
                    try
                    {                        
                        $sPath = Join-Path (Join-Path $tmp Scriptbook ) $m.Version
                        Copy-Item $scriptbookModulePath $sPath -Recurse
                        docker cp "$tmp" "$($containerName):/$workFolderName/Scriptbook"
                    }
                    finally
                    {
                        Remove-Item $tmp -Recurse -Force -ErrorAction Ignore
                    }
                }
                else
                {
                    docker cp "$scriptbookModulePath" "$($containerName):/$workFolderName/Scriptbook"
                }
            }
            $r = docker start "$containerName"
            if ($LASTEXITCODE -ne 0) { Throw "Error in docker start for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode $r" }
            $containerStarted = $true

            $r = Start-ShellCmd -Progress -Command 'docker' -Arguments "exec `"$containerName`" pwsh -NonInteractive -NoLogo -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand $encodedCommand"
            if ($r.ExitCode -ne 0) { Throw "Error in docker exec for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode" }
            if ([string]::IsNullOrEmpty($r.StdOut)) { Throw "No output found in 'docker exec' command" }
            if (![string]::IsNullOrEmpty($r.StdErr)) 
            {
                Throw "Errors found in output 'docker exec' command $($r.StdErr)"
            }
        }
        else
        {
            docker run @envVars @volumeVars --platform=$platform --name "$containerName" $cImage pwsh -NonInteractive -NoLogo -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand $encodedCommand
            if ($LASTEXITCODE -ne 0) { Throw "Error in docker run for container '$containerName' with image '$cImage' on platform '$platform' : $LastExitCode" }
        }
    }
    finally
    {
        try
        {
            if ($containerStarted)
            {
                $r = docker stop "$containerName"
                if ($LASTEXITCODE -ne 0) { Throw "Error in docker stop for container '$containerName' : $LastExitCode $r" }
            }
        }
        finally
        {
            $r = docker container ls -a -f name=$containerName
            if ($LASTEXITCODE -ne 0) { $r = $containerName } # some context's don't support container ls --> always try to remove

            if ( "$r".Contains($containerName))
            {
                $r = docker rm --force $containerName
                if ($LASTEXITCODE -ne 0) { Throw "Error in docker remove for container '$containerName' output: $r" }
            }
        }
    }
}
function Write-ScriptBlock($ScriptBlock)
{
    Write-StringResult (Invoke-Command -ScriptBlock $ScriptBlock)
}

function Write-ScriptLog($Msg, [switch]$AsError, [switch]$AsWarning, [switch]$AsAction, [switch]$AsWorkflow, [switch]$AsSkipped, [switch]$Verbose)
{
    $ctx = Get-RootContext
    if ($ctx.NoLogging -and ($VerbosePreference -ne 'Continue') )
    {
        return
    }

    if ($Verbose.IsPresent -and ($VerbosePreference -ne 'Continue'))
    {
        return
    }

    $colors = @{}
    if ($AsError.IsPresent)
    {
        [void]$colors.Add('ForegroundColor', 'White')
        [void]$colors.Add('BackgroundColor', 'Red')
    }
    elseif ($AsWarning.IsPresent)
    {
        [void]$colors.Add('ForegroundColor', 'White')
        [void]$colors.Add('BackgroundColor', 'Yellow')
    }
    elseif ($AsAction.IsPresent)
    {
        [void]$colors.Add('ForegroundColor', 'Blue')
    }
    elseif ($AsWorkflow.IsPresent -or $AsSkipped.IsPresent)
    {
        [void]$colors.Add('ForegroundColor', 'Magenta')
    }

    if ($Msg -and $Msg.GetType().Name -eq 'HashTable')
    {
        if ($Msg.ContainsKey('action'))
        {
            $m = $Msg.action;
            $Msg.Remove('action');
        }
        elseif ($Msg.ContainsKey('command'))
        {
            $m = $Msg.command;
            $Msg.Remove('command');
        }
        Write-Info $m @colors; Global:Write-OnLog -Msg $m
        if ($VerbosePreference -eq 'Continue') 
        {
            Write-Info ($Msg.GetEnumerator() | Sort-Object -Property Name | ForEach-Object { '@{0}:{1}' -f $_.key, $_.value }) @colors
        }
    }
    else
    {
        Write-Info $Msg @colors
    }
}

function Write-StringResult($Result)
{
    if ($Result -is [array])
    {
        foreach ($l in $Result)
        {
            Write-Info $l
        }
    }
    else
    {
        Write-Info $Result
    }
}