MetaNull.InvokeScript.psm1

# Module Constants

# Set-Variable MYMODULE_CONSTANT -option Constant -value $true
Function Invoke-VisualStudioOnlineString {
<#
    .SYNOPSIS
        Process Visual Studio Online strings.
 
    .DESCRIPTION
        Process Visual Studio Online strings.
 
    .PARAMETER InputString
        The object to output to the VSO pipeline.
 
    .PARAMETER ScriptOutput
        The output of the step.
#>

[CmdletBinding(DefaultParameterSetName='Default')]
[OutputType([string])]
param(
    [Parameter(Mandatory, ValueFromPipeline)]
    [AllowEmptyString()]
    [string]$InputString,

    [Parameter(Mandatory = $false)]
    [ref]$ScriptOutput
)
Process {
    # Initialize the State variable, if not already initialized
    if($null -eq $ScriptOutput.Value) {
        $ScriptOutput.Value = [pscustomobject]@{
            Result = [pscustomobject]@{
                Message = 'Not started'
                Result = 'Failed'
            }
            Variable = @()
            Secret = @()
            Path = @()
            Upload = @()
            Log = @()
            Error = @()
            Retried = 0
        }
    }

    # Detect if the received object is a VSO command or VSO format string
    $VsoResult = $InputString | ConvertFrom-VisualStudioOnlineString
    if(-not ($VsoResult)) {
        # Input is just a string, no procesing required

        # Replace any secrets in the output
        $ScriptOutput.Value.Secret | Foreach-Object {
            $InputString = $InputString -replace [Regex]::Escape($_), '***'
        }
        # Output the message as is
        $InputString | Write-Output
        return
    }

    if($VsoResult.Format) {
        # Input is a VSO format string, no procesing required, but output the message according to the format

        # Replace any secrets in the output
        $ScriptOutput.Value.Secret | Foreach-Object {
            $VsoResult.Message = $VsoResult.Message -replace [Regex]::Escape($_), '***'
        }

        # Output the message according to the format
        switch ($VsoResult.Format) {
            'group' {
                Write-Host "[+] $($VsoResult.Message)" -ForegroundColor Magenta
                return
            }
            'endgroup' {
                Write-Host "[-] $($VsoResult.Message)" -ForegroundColor Magenta
                return
            }
            'section' {
                Write-Host "$($VsoResult.Message)" -ForegroundColor Cyan
                return
            }
            'warning' {
                Write-Host "WARNING: $($VsoResult.Message)" -ForegroundColor Yellow
                return
            }
            'error' {
                Write-Host "ERROR: $($VsoResult.Message)" -ForegroundColor Red
                return
            }
            'debug' {
                Write-Host "DEBUG: $($VsoResult.Message)" -ForegroundColor Gray
                return
            }
            'command' {
                Write-Host "$($VsoResult.Message)" -ForegroundColor Blue
                return
            }
            default {
                # Unknown format/not implemented
                Write-Warning "Format [$($VsoResult.Format)] is not implemented"

                # Do not return! Output is processed further
            }
        }
    }

    if($VsoResult.Command) {
        # Input is a VSO command, process it
        switch($VsoResult.Command) {
            'task.complete' {
                Write-Debug "Task complete: $($VsoResult.Properties.Result) - $($VsoResult.Message)"
                $ScriptOutput.Value.Result.Result = $VsoResult.Properties.Result
                $ScriptOutput.Value.Result.Message = $VsoResult.Message
                return
            }
            'task.setvariable' {
                Write-Debug "Task set variable: $($VsoResult.Properties.Variable) = $($VsoResult.Properties.Value)"
                $ScriptOutput.Value.Variable += ,[pscustomobject]$VsoResult.Properties
                return
            }
            'task.setsecret' {
                Write-Debug "Task set secret: $($VsoResult.Properties.Value)"
                $ScriptOutput.Value.Secret += ,$VsoResult.Properties.Value
                return
            }
            'task.prependpath' {
                Write-Debug "Task prepend path: $($VsoResult.Properties.Value)"
                $ScriptOutput.Value.Path += ,$VsoResult.Properties.Value
                return
            }
            'task.uploadfile' {
                Write-Debug "Task upload file: $($VsoResult.Properties.Value)"
                $ScriptOutput.Value.Upload += ,$VsoResult.Properties.Value
                return
            }
            'task.logissue' {
                Write-Debug "Task log issue: $($VsoResult.Properties.Type) - $($VsoResult.Message)"
                $ScriptOutput.Value.Log += ,[pscustomobject]$VsoResult.Properties
                return
            }
            'task.setprogress' {
                Write-Debug "Task set progress: $($VsoResult.Properties.Value) - $($VsoResult.Message)"
                $PercentString = "$($VsoResult.Properties.Percent)".PadLeft(3,' ')
                Write-Host "$($VsoResult.Message) - $PercentString %" -ForegroundColor Green
                return
            }
            default {
                # Not implemented
                Write-Debug "Command [$($VsoResult.Command)] is not implemented"

                # Do not return! Output is processed further
            }
        }
    }
    
    # Replace any secrets in the output
    $ScriptOutput.Value.Secret | Foreach-Object {
        $InputString = $InputString -replace [Regex]::Escape($_), '***'
    }

    # Unknown input, output as is
    Write-Warning "Error processing input: $InputString"
    $InputString | Write-Output
}
}
Function ConvertFrom-VisualStudioOnlineString {
[CmdletBinding(DefaultParameterSetName='Default')]
[OutputType([hashtable])]
param(
    [Parameter(Mandatory, ValueFromPipeline)]
    [AllowEmptyString()]
    [AllowNull()]
    [string] $String
)
Begin {
    $VisualStudioOnlineExpressions = @(
        @{
            Name = 'Command'
            Expression = '^##vso\[(?<command>[\S]+)(?<properties>[^\]]*)\](?<line>.*)$'
        }
        @{
            Name = 'Format'
            Expression = '^##\[(?<format>group|endgroup|section|warning|error|debug|command)\](?<line>.*)$'
        }
    )
}
Process {
    # Check if the line is null or empty
    if ([string]::IsNullOrEmpty($String)) {
        return
    }
    
    # Evaluate each regular expression against the received String
    foreach($Expression in $VisualStudioOnlineExpressions) {
        $RxExpression = [regex]::new($Expression.Expression)
        $RxResult = $RxExpression.Match($String)
        if ($RxResult.Success) {
            $Vso = @{
                Type = $Expression.Name
                Expression = $Expression.Expression
                Matches = $RxResult
            }
            break
        }
    }
    if(-not $Vso.Type) {
        return
    }
    
    # Handle known commands, or return
    if($Vso.Type -eq 'Format') {
        return @{
            Format = $Vso.Matches.Groups['format'].value
            Message = $Vso.Matches.Groups['line'].value
        }
    } 
    if($Vso.Type -eq 'Command') {
        # "> Command: $String" | Write-Debug
        $Properties = @{}
        $Vso.Matches.Groups['properties'].Value.Trim() -split '\s*;\s*' | Where-Object { 
            -not ([string]::IsNullOrEmpty($_)) 
        } | ForEach-Object {
            $key, $value = $_.Trim() -split '\s*=\s*', 2
            # "{$key = $value}" | Write-Debug
            $Properties += @{"$key" = $value }
        }
        switch ($Vso.Matches.Groups['command']) {
            'task.complete' {
                # Requires properties to be in 'result'
                if ($Properties.Keys | Where-Object { $_ -notin @('result') }) {
                    return
                }
                # Requires property 'result'
                if (-not ($Properties.ContainsKey('result'))) {
                    return
                }
                # Requires property 'result' to be 'Succeeded', 'SucceededWithIssues', or 'Failed'
                if (-not ($Properties['result'] -in @('Succeeded', 'SucceededWithIssues', 'Failed'))) {
                    return
                }
                # Return the command
                switch ($Properties['result']) {
                    'Succeeded' { $Result = 'Succeeded' }
                    'SucceededWithIssues' { $Result = 'SucceededWithIssues' }
                    'Failed' { $Result = 'Failed' }
                    default {                   
                        return 
                    }
                }
                return @{
                    Command = 'task.complete'
                    Message = $Vso.Matches.Groups['line'].Value
                    Properties = @{ 
                        Result = $Result
                    }
                }
            }
            'task.setvariable' {
                # Requires properties to be in 'variable', 'isSecret', 'isOutput', and 'isReadOnly'
                if ($Properties.Keys | Where-Object { $_ -notin @('variable', 'isSecret', 'isOutput', 'isReadOnly') }) {
                    Write-Warning "Invalid properties"
                    return
                }
                # Requires property 'variable'
                if (-not ($Properties.ContainsKey('variable'))) {
                    Write-Warning "Missing name"
                    return
                }
                # Requires property 'variable' to be not empty
                if ([string]::IsNullOrEmpty($Properties['variable'])) {
                    Write-Warning "Null name"
                    return
                }
                # Requires property 'variable' to be a valid variable name
                try { & { Invoke-Expression "`$$($Properties['variable']) = `$null" } } catch {
                    Write-Warning "Invalid name"
                    return
                }
                return @{
                    Command = 'task.setvariable'
                    Message = $null
                    Properties = @{
                        Name       = $Properties['variable']
                        Value      = $Vso.Matches.Groups['line'].Value
                        IsSecret   = $Properties.ContainsKey('isSecret')
                        IsOutput   = $Properties.ContainsKey('isOutput')
                        IsReadOnly = $Properties.ContainsKey('isReadOnly')
                    }
                }
            }
            'task.setsecret' {
                # Requires no properties
                if ($Properties.Keys.Count -ne 0) {
                    return
                }
                # Requires message
                if (([string]::IsNullOrEmpty($Vso.Matches.Groups['line'].Value))) {
                    return
                }
                return @{
                    Command = 'task.setsecret'
                    Message = $null
                    Properties = @{
                        Value = $Vso.Matches.Groups['line'].Value
                    }
                }
            }
            'task.prependpath' {
                # Requires no properties
                if ($Properties.Keys.Count -ne 0) {
                    return
                }
                # Requires message
                if (([string]::IsNullOrEmpty($Vso.Matches.Groups['line'].Value))) {
                    return
                }
                return @{
                    Command = 'task.prependpath'
                    Message = $null
                    Properties = @{
                        Value = $Vso.Matches.Groups['line'].Value
                    }
                }
            }
            'task.uploadfile' {
                # Requires no properties
                if ($Properties.Keys.Count -ne 0) {
                    return
                }
                # Requires message
                if (([string]::IsNullOrEmpty($Vso.Matches.Groups['line'].Value))) {
                    return
                }
                return @{
                    Command = 'task.uploadfile'
                    Message = $null
                    Properties = @{
                        Value = $Vso.Matches.Groups['line'].Value
                    }
                }
                return $Return
            }
            'task.setprogress' {
                # Requires properties to be in 'value'
                if ($Properties.Keys | Where-Object { $_ -notin @('value') }) {
                    return
                }
                # Requires property 'value'
                if (-not ($Properties.ContainsKey('value'))) {
                    return
                }
                # Requires property 'value' to be an integer
                $percent = $null
                if (-not ([int]::TryParse($Properties['value'], [ref]$percent))) {
                    return
                }
                # Requires property 'value' to be between 0 and 100
                if ($percent -lt 0 -or $percent -gt 100) {
                    return
                }
                return @{
                    Command = 'task.setprogress'
                    Message = $Vso.Matches.Groups['line'].Value
                    Properties = @{
                        Value = $percent
                    }
                }
            }
            'task.logissue' {
                # Requires properties to be in 'value'
                if ($Properties.Keys | Where-Object { $_ -notin @('type','sourcepath','linenumber','colnumber','code') }) {
                    return
                }
                # Requires property 'type'
                if (-not ($Properties.ContainsKey('type'))) {
                    return
                }
                # Requires property 'type' to be 'warning' or 'error'
                if (-not ($Properties['type'] -in @('warning', 'error'))) {
                    return
                } else {
                    switch($Properties['type']) {
                        'warning' { $percent = 'warning' }
                        'error' { $percent = 'error' }
                    }
                }
                # Requires property 'linenumber' to an integer (if present)
                $tryparse = $null
                if ($Properties['linenumber'] -and -not ([int]::TryParse($Properties['linenumber'], [ref]$tryparse))) {
                    return
                } elseif($Properties['linenumber']) {
                    $LogLineNumber = $Properties['linenumber']
                } else {
                    $LogLineNumber = $null
                }
                # Requires property 'colnumber' to an integer (if present)
                $tryparse = $null
                if ($Properties['colnumber'] -and -not ([int]::TryParse($Properties['colnumber'], [ref]$tryparse))) {
                    return
                } elseif($Properties['colnumber']) {
                    $LogColNumber = $Properties['colnumber']
                } else {
                    $LogColNumber = $null
                }
                # Requires property 'code' to an integer (if present)
                $tryparse = $null
                if ($Properties['code'] -and -not ([int]::TryParse($Properties['code'], [ref]$tryparse))) {
                    return
                } elseif($Properties['code']) {
                    $LogCode = $Properties['code']
                } else {
                    $LogCode = $null
                }
                return @{
                    Command = 'task.logissue'
                    Message = $Vso.Matches.Groups['line'].Value
                    Properties = @{
                        Type = $Properties['type']
                        SourcePath = $Properties['sourcepath']
                        LineNumber = $LogLineNumber
                        ColNumber = $LogColNumber
                        Code = $LogCode
                    }
                }
            }
            'build.addbuildtag' {
                # Requires no properties
                if ($Properties.Keys.Count -ne 0) {
                    return
                }
                # Requires message
                if (([string]::IsNullOrEmpty($Vso.Matches.Groups['line'].Value))) {
                    return
                }
                return @{
                    Command = 'build.addbuildtag'
                    Message = $null
                    Properties = @{
                        Value = $Vso.Matches.Groups['line'].Value
                    }
                }
                return $Return
            }
        }
    }
    return
}
}
Function Invoke-Script {
<#
.SYNOPSIS
    Invoke a script, defined as an array of strings
 
.DESCRIPTION
    Invoke a script, defined as an array of strings
    (Purpose: Run a step from a pipeline)
 
.PARAMETER Commands
    The Commands to run in the step
    @("'Hello World' | Write-Output"), for example
 
.PARAMETER ScriptInput
    The input to the script (optional arguments received from the pipeline definition) (default: @{})
    @{args = @(); path = '.'}, for example
 
.PARAMETER ScriptEnvironment
    The environment ScriptVariable to set for the step (default: @{})
    They are added to the script's local environment
    @{WHOAMI = $env:USERNAME}, for example
 
.PARAMETER ScriptVariable
    The ScriptVariable to set for the step (default: @{})
    They are used to expand variables in the Commands. Format of the variable is $(VariableName)
    @{WHOAMI = 'Pascal Havelange'}, for example
 
.PARAMETER DisplayName
    The display name of the step (default: 'MetaNull.Invoke-Script')
 
.PARAMETER Enabled
    Is the step Enabled (default: $true)
 
.PARAMETER Condition
    The Condition to run the step (default: '$true')
 
.PARAMETER ContinueOnError
    Continue on error (default: $false)
    If set to true, in case of error, the result will indicate that the step has 'completed with issues' instead of 'failed'
 
.PARAMETER TimeoutInSeconds
    The timeout in seconds after which commands will be aborted (range: 1 to 86400 (1 day); default: 300 (15 minutes))
 
.PARAMETER MaxRetryOnFailure
    The number of retries on step failure (default: 0)
 
.PARAMETER ScriptOutput
    The output of the script will be stored in this variable and the function returns the commands' output
 
.EXAMPLE
    $ScriptOutput = $null
    $ScriptOutput = Invoke-Script -commands '"Hello World"|Write-Output'
 
.EXAMPLE
    $ScriptOutput = $null
    Invoke-Script -commands '"Hello World"|Write-Output' -ScriptOutput ([ref]$ScriptOutput)
#>

[CmdletBinding(DefaultParameterSetName='Default')]
[OutputType([PSCustomObject], [bool])]
param(
    [Parameter(Mandatory)]
    [string[]]$Commands,

    [Parameter(Mandatory = $false)]
    [string]$ScriptWorkingDirectory = '.',

    [Parameter(Mandatory=$false)]
    [hashtable]$ScriptInput = @{},

    [Parameter(Mandatory=$false)]
    [hashtable]$ScriptEnvironment = @{},
    
    [Parameter(Mandatory=$false)]
    [hashtable]$ScriptVariable = @{},

    [Parameter(Mandatory=$false)]
    [string]$DisplayName = 'MetaNull.Invoke-Script',

    [Parameter(Mandatory=$false)]
    [switch]$Enabled,
    
    [Parameter(Mandatory=$false)]
    [string]$Condition = '$true',

    [Parameter(Mandatory=$false)]
    [switch]$ContinueOnError,

    [Parameter(Mandatory=$false)]
    [ValidateRange(1,86400)]
    [int]$TimeoutInSeconds = 300,

    [Parameter(Mandatory=$false)]
    [int]$MaxRetryOnFailure = 0,
    
    [Parameter(Mandatory)]
    [ref]$ScriptOutput
)
Process {
    $BackupErrorActionPreference = $ErrorActionPreference
    try {
        $ErrorActionPreference = "Stop"

        # Create an empty Result object
        '' | Invoke-VisualStudioOnlineString -ScriptOutput $ScriptOutput

        # Check if the step should run (step is Enabled)
        if ($Enabled.IsPresent -and -not $Enabled) {
            Write-Debug "Script '$DisplayName' was skipped because it was disabled."
            Set-Result -ScriptOutput $ScriptOutput -Message 'Disabled'
            throw $true # Interrupts the flow, $true is interpreted as a success
        }

        # Add received input variables to the ScriptOutput
        foreach ($key in $ScriptVariable.Keys) {
            Add-Variable -ScriptOutput $ScriptOutput -Name $key -Value $ScriptVariable[$key]
        }

        # Add received input environment variables to the process' environment
        Add-Environment -ScriptOutput $ScriptOutput -Name 'METANULL_CURRENT_DIRECTORY' -Value ([System.Environment]::CurrentDirectory)
        Add-Environment -ScriptOutput $ScriptOutput -Name 'METANULL_CURRENT_LOCATION' -Value ((Get-Location).Path)
        Add-Environment -ScriptOutput $ScriptOutput -Name 'METANULL_SCRIPT_ROOT' -Value ($PSScriptRoot)
        Add-Environment -ScriptOutput $ScriptOutput -Name 'METANULL_WORKING_DIRECTORY' -Value ($ScriptWorkingDirectory | Expand-Variables -ScriptOutput $ScriptOutput)
        foreach ($key in $ScriptEnvironment.Keys) {
            Add-Environment -ScriptOutput $ScriptOutput -Name $key -Value $ScriptEnvironment[$key]
        }

        # Check if the step should run (Condition is true)
        $sb_condition = [scriptblock]::Create(($Condition | Expand-Variables -ScriptOutput $ScriptOutput))
        if (-not (& $sb_condition)) {
            Write-Debug "Script '$DisplayName' was skipped because the Condition was false."
            Set-Result -ScriptOutput $ScriptOutput -Message 'Skipped'
            throw $true # Interrupts the flow, $true is interpreted as a success
        }
    
        # Create the scriptblocks
        # - Init: set-location
        $sb_init = [scriptblock]::Create(
            @(
                '$ErrorActionPreference = "Stop"'
                '$DebugPreference = "SilentlyContinue"'
                '$VerbosePreference = "SilentlyContinue"'
                'Set-Location $env:METANULL_WORKING_DIRECTORY'
            ) -join "`n"
        )
        # - Step: run the Commands
        $sb_step = [scriptblock]::Create(($Commands | Expand-Variables -ScriptOutput $ScriptOutput) -join "`n")

        # Set a timer, after which the job will be interrupted, if not yet complete
        $timer = [System.Diagnostics.Stopwatch]::StartNew()
        
        # Run the step, optionally retrying on failure
        do {
            Write-Debug "Running script $DisplayName as a Job"
            
            #"INIT: $($sb_init.ToString())" | Write-Debug
            #"STEP: $($sb_step.ToString())" | Write-Debug

            $job = Start-Job -ScriptBlock $sb_step -ArgumentList @($ScriptInput.args) -InitializationScript $sb_init 
            try {
                # Wait for job to complete
                while ($job.State -eq 'Running') {
                    Start-Sleep -Milliseconds 250
                    # Collect and process job's (partial) output
                    try {
                        $Partial = Receive-Job -Job $job -Wait:$false
                        # $Partial |% {"Partial: $($_)" | Write-Debug}
                        $Partial | Invoke-VisualStudioOnlineString -ScriptOutput $ScriptOutput | Write-Output
                    } catch {
                        Add-Error -ScriptOutput $ScriptOutput -ErrorRecord $_
                    }
                    # Interrupt the job if it takes too long
                    if($timer.Elapsed.TotalSeconds -gt $TimeoutInSeconds) {
                        Set-Result -ScriptOutput $ScriptOutput -Failed -Message 'Job timed out.'
                        Stop-Job -Job $job | Out-Null
                    }
                }
                # Collect and process job's (remaining) output
                if($job.HasMoreData) {
                    try {
                        $Partial = Receive-Job -Job $job -Wait
                        # $Partial |% {"Partial: $($_)" | Write-Debug}
                        $Partial | Invoke-VisualStudioOnlineString -ScriptOutput $ScriptOutput | Write-Output
                    } catch {
                        Add-Error -ScriptOutput $ScriptOutput -ErrorRecord $_
                    }
                }

                # Process job result
                if($job.State -eq 'Completed') {
                    # Interrupt the retry loop, the job si complete
                    Set-Result -ScriptOutput $ScriptOutput -Message $job.State
                    break       
                } elseif($job.State -eq 'Failed' -and $MaxRetryOnFailure -gt 0) {
                    # Keep on with the retry loop, as the job has failed, and we didn't reach yet the maximum number of allowed retries
                    Write-Debug "Job failed, retrying up to $MaxRetryOnFailure time(s)"
                    $ScriptOutput.Value.Retried ++
                    continue    
                } else {
                    # Interrupt the retry loop: Unexpected job.state and/or out of allowed retries => we shouldn't permit retrying
                    if($ContinueOnError.IsPresent -and $ContinueOnError) {
                        Set-Result -ScriptOutput $ScriptOutput -SucceededWithIssues -Message $job.State
                    } else {
                        Set-Result -ScriptOutput $ScriptOutput -Failed -Message $job.State
                    }
                    break
                }
            } finally {
                Write-Debug "Script '$DisplayName' ran for $($timer.Elapsed.TotalSeconds) seconds. Final job's state: $($job.State)"
                Remove-Job -Job $job -Force | Out-Null
            }
        } while(($MaxRetryOnFailure --) -gt 0)

    } catch {
        if($_.TargetObject -is [bool] -and $_.TargetObject -eq $true) {
            # This is a voluntary interruption of the flow... Do nothing
        } else {
            # This is an actual exception... Handle it
            Add-Error -ScriptOutput $ScriptOutput -ErrorRecord $_
            if($ContinueOnError.IsPresent -and $ContinueOnError) {
                Set-Result -ScriptOutput $ScriptOutput -SucceededWithIssues -Message $_
            } else {
                Set-Result -ScriptOutput $ScriptOutput -Failed -Message $_
            }
        }
    } finally {
        $ErrorActionPreference = $BackupErrorActionPreference
    }
}
Begin {
    <#
        .SYNOPSIS
            Update the ScriptOutput object to indicate the Success or Failure of the operation
    #>

    Function Set-Result {
        [CmdletBinding(DefaultParameterSetName='Succeeded')]
        param(
            [Parameter(Mandatory)]
            [ref]$ScriptOutput,
            [Parameter(Mandatory = $false)]
            [string]$Message = 'Done',
            [Parameter(Mandatory,ParameterSetName='Failed')]
            [switch]$Failed,
            [Parameter(Mandatory,ParameterSetName='SucceededWithIssues')]
            [switch]$SucceededWithIssues
        )
        Process {
            $Result = [pscustomobject]@{
                Message = $Message
                Result = 'Succeeded'
            }
            if($Failed.IsPresent -and $Failed) {
                $Result.Result = 'Failed'
            }
            if($SucceededWithIssues.IsPresent -and $SucceededWithIssues) {
                $Result.Result = 'SucceededWithIssues'
            }
            $ScriptOutput.Value.Result = $Result
        }
    }
    <#
        .SYNOPSIS
            Check the Success of Failure status from the ScriptOutput object, return $true in case of Success, or $false otherwise.
    #>

    Function Test-Result {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ref]$ScriptOutput
        )
        Process {
            switch ($ScriptOutput.Value.Result.Result) {
                'Succeeded' {           return $true    }
                'SucceededWithIssues' { return $true    }
                'Failed' {              return $false   }
            }
            return $false
        }
    }
    <#
        .SYNOPSIS
            Add to the process' environment variables
    #>

    Function Add-Environment {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ref]$ScriptOutput,

            [Parameter(Mandatory)]
            [string]$Name,

            [Parameter(Mandatory)]
            [string]$Value
        )
        Process {
            Write-Debug "Adding to process' environment: $($Name.ToUpper() -replace '\W','_') = $($Value)"
            [System.Environment]::SetEnvironmentVariable(($Name.ToUpper() -replace '\W','_'), $Value, [System.EnvironmentVariableTarget]::Process)
        }
    }
    <#
        .SYNOPSIS
            Update the ScriptOutput object, adding some user defined variables (variables are later expanded when generating the Script's content)
    #>

    Function Add-Variable {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ref]$ScriptOutput,

            [Parameter(Mandatory)]
            [string]$Name,

            [Parameter(Mandatory)]
            [string]$Value
        )
        Process {
            Write-Debug "Adding to process' variables: $($Name) = $($Value)"
            $ScriptOutput.Value.Variable += ,[pscustomobject]@{
                Name=$Name
                Value=[System.Environment]::ExpandEnvironmentVariables($Value)
                IsSecret=$false
                IsOutput=$false
                IsReadOnly=$false
            }
        }
    }
    <#
        .SYNOPSIS
            Update the ScriptOutput object, adding some ErrorRecord to the Error array
    #>

    Function Add-Error {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ref]$ScriptOutput,

            [Parameter(Mandatory)]
            [object]$ErrorRecord
        )
        Process {
            Write-Debug "Adding an error to the result: $($ErrorRecord.ToString())"
            $ScriptOutput.Value.Error += ,$ErrorRecord
        }
    }
    <#
        .SYNOPSIS
            Detect and expand user defined variables in a string
    #>

    Function Expand-Variables {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ref]$ScriptOutput,

            [Parameter(Mandatory,ValueFromPipeline)]
            [string]$String
        )
        Process {
            $ExpandedString = $_
            # Expand the Variables found in the command
            $ScriptOutput.Value.Variable.GetEnumerator() | Foreach-Object {
                $ExpandedString = $ExpandedString -replace [Regex]::Escape("`$($($_.Name))"),"$($_.Value)"
            }
            # [System.Environment]::ExpandEnvironmentVariables($ExpandedString) | Write-Output
            $ExpandedString | Write-Output
        }
    }
}
}
Function Test-VisualStudioOnlineString {
<#
    .SYNOPSIS
        Test if a string is a valid VSO command
 
    .DESCRIPTION
        Test if a string is a valid VSO command
 
    .PARAMETER String
            The string to test
 
    .EXAMPLE
        # Test a string
        '##vso[task.complete result=Succeeded;]Task completed successfully' | Test-VisualStudioOnlineString
#>

[CmdletBinding(DefaultParameterSetName='Default')]
[OutputType([bool])]
param(
    [Parameter(Mandatory, ValueFromPipeline)]
    [AllowEmptyString()]
    [AllowNull()]
    [string] $String
)
Process {
    return -not -not ($String | ConvertFrom-VisualStudioOnlineString)
}
}
Function Write-VisualStudioOnlineString {
<#
.SYNOPSIS
    Write a string in the Visual Studio Online format
 
.DESCRIPTION
    Write a string in the Visual Studio Online format
 
.PARAMETER Message
    The message to write
 
.PARAMETER Format
    The format of the message
    'group', 'endgroup', 'section', 'warning', 'error', 'debug', 'command'
 
.PARAMETER CompleteTask
    Complete the task
    'Succeeded', 'SucceededWithIssues', 'Failed'
 
.PARAMETER SetTaskVariable
    Set a task variable
 
.PARAMETER SetTaskSecret
    Set a task secret
 
.PARAMETER PrependTaskPath
    Prepend a path to the task
 
.PARAMETER UploadTaskFile
    Upload a file to the task
 
.PARAMETER SetTaskProgress
    Set the task progress
 
.PARAMETER LogIssue
    Log an issue
    'warning', 'error'
 
.PARAMETER AddBuildTag
    Add a build tag
 
.PARAMETER Name
    The name of the variable
 
.PARAMETER IsSecret
    Is the variable a secret
 
.PARAMETER IsReadOnly
    Is the variable read-only
 
.PARAMETER IsOutput
    Is the variable an output
 
.PARAMETER Value
    The value of the variable
 
.PARAMETER Path
    The path to prepend or upload
 
.PARAMETER Progress
    The progress value
 
.PARAMETER Type
    The type of issue
 
.PARAMETER SourcePath
    The source path of the issue
 
.PARAMETER LineNumber
    The line number of the issue
 
.PARAMETER ColNumber
    The column number of the issue
 
.PARAMETER Code
    The code of the issue
 
.PARAMETER Tag
    The tag to add to the build
 
.EXAMPLE
    # Write a message
    'Task completed successfully' | Write-VisualStudioOnlineString -Format 'section'
 
.EXAMPLE
    # Complete a task
    Write-VisualStudioOnlineString -CompleteTask -Result 'Succeeded' -Message 'Task completed successfully'
 
.EXAMPLE
    # Set a task variable
    Write-VisualStudioOnlineString -SetTaskVariable -Name 'VariableName' -Value 'VariableValue'
 
.EXAMPLE
    # Set a task secret
    Write-VisualStudioOnlineString -SetTaskSecret -Value 'SecretValue'
 
.EXAMPLE
    # Prepend a path to the task
    Write-VisualStudioOnlineString -PrependTaskPath -Path 'C:\Path\To\Prepend'
 
.EXAMPLE
    # Upload a file to the task
    Write-VisualStudioOnlineString -UploadTaskFile -Path 'C:\Path\To\Upload'
 
.EXAMPLE
    # Set the task progress
    Write-VisualStudioOnlineString -SetTaskProgress -Progress 50 -Message 'Task is 50% complete'
 
.EXAMPLE
    # Log an issue
    Write-VisualStudioOnlineString -LogIssue -Type 'warning' -Message 'This is a warning'
 
.EXAMPLE
    # Add a build tag
    Write-VisualStudioOnlineString -AddBuildTag -Tag 'tag'
 
.OUTPUTS
    System.String
 
.LINK
    https://docs.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands?view=azure-devops&tabs=powershell
#>

[CmdletBinding(DefaultParameterSetName='Format')]
[OutputType([string])]
param(
    [Parameter(Mandatory, ValueFromPipeline, ParameterSetName='Format')]
    [Parameter(Mandatory = $false, ValueFromPipeline, ParameterSetName='Command-TaskComplete')]
    [Parameter(Mandatory = $false, ValueFromPipeline, ParameterSetName='Command-TaskSetProgress')]
    [Parameter(Mandatory = $false, ValueFromPipeline, ParameterSetName='Command-TaskLogIssue')]
    [AllowEmptyString()]
    [AllowNull()]
    [string] $Message,

    [Parameter(Mandatory = $false, ParameterSetName='Format')]
    [ValidateSet('group', 'endgroup', 'section', 'warning', 'error', 'debug', 'command')]
    [string] $Format,


    [Parameter(Mandatory, ParameterSetName='Command-TaskComplete')]
    [switch] $CompleteTask,

    [Parameter(Mandatory, ParameterSetName='Command-TaskSetVariable')]
    [switch] $SetTaskVariable,

    [Parameter(Mandatory, ParameterSetName='Command-TaskSetSecret')]
    [switch] $SetTaskSecret,

    [Parameter(Mandatory, ParameterSetName='Command-TaskPrependPath')]
    [switch] $PrependTaskPath,

    [Parameter(Mandatory, ParameterSetName='Command-TaskUploadFile')]
    [switch] $UploadTaskFile,

    [Parameter(Mandatory, ParameterSetName='Command-TaskSetProgress')]
    [switch] $SetTaskProgress,

    [Parameter(Mandatory, ParameterSetName='Command-TaskLogIssue')]
    [switch] $LogIssue,

    [Parameter(Mandatory, ParameterSetName='Command-BuildAddBuildTag')]
    [switch] $AddBuildTag,


    [Parameter(Mandatory, ParameterSetName='Command-TaskComplete')]
    [ValidateSet('Succeeded', 'SucceededWithIssues', 'Failed')]
    [string] $Result,


    [Parameter(Mandatory, ParameterSetName='Command-TaskSetVariable')]
    [string] $Name,

    [Parameter(Mandatory = $false, ParameterSetName='Command-TaskSetVariable')]
    [switch] $IsSecret,

    [Parameter(Mandatory = $false, ParameterSetName='Command-TaskSetVariable')]
    [switch] $IsReadOnly,

    [Parameter(Mandatory = $false, ParameterSetName='Command-TaskSetVariable')]
    [switch] $IsOutput,

    [Parameter(Mandatory = $false, ValueFromPipeline, ParameterSetName='Command-TaskSetVariable')]
    [Parameter(Mandatory = $false, ValueFromPipeline, ParameterSetName='Command-TaskSetSecret')]
    [AllowEmptyString()]
    [AllowNull()]
    [string] $Value,

    [Parameter(Mandatory, ValueFromPipeline, ParameterSetName='Command-TaskPrependPath')]
    [Parameter(Mandatory, ValueFromPipeline, ParameterSetName='Command-TaskUploadFile')]
    [string] $Path,

    [Parameter(Mandatory, ParameterSetName='Command-TaskSetProgress')]
    [ValidateRange(0, 100)]
    [int] $Progress,

    [Parameter(Mandatory, ParameterSetName='Command-TaskLogIssue')]
    [ValidateSet('warning', 'error')]
    [string] $Type,

    [Parameter(Mandatory = $false, ParameterSetName='Command-TaskLogIssue')]
    [string] $SourcePath,

    [Parameter(Mandatory = $false, ParameterSetName='Command-TaskLogIssue')]
    [int] $LineNumber,

    [Parameter(Mandatory = $false, ParameterSetName='Command-TaskLogIssue')]
    [int] $ColNumber,

    [Parameter(Mandatory = $false, ParameterSetName='Command-TaskLogIssue')]
    [int] $Code,

    [Parameter(Mandatory, ParameterSetName='Command-BuildAddBuildTag')]
    [ValidateScript({ $_ -match '^[a-z][\w\.\-]+$' })]
    [string] $Tag
)
Process {
    switch($PSCmdlet.ParameterSetName) {
        'Format' {
            return "##[$Format]$Message"
        }
        'Command-TaskComplete' {
            return "##vso[task.complete result=$Result]$Message"
        }
        'Command-TaskSetVariable' {
            $IsSecretString = "$("$($IsSecret.IsPresent -and $IsSecret)".ToLower())"
            $IsOutputString = "$("$($IsOutput.IsPresent -and $IsOutput)".ToLower())"
            $IsReadOnlyString = "$("$($IsReadOnly.IsPresent -and $IsReadOnly)".ToLower())"
            return "##vso[task.setvariable variable=$Name;isSecret=$IsSecretString;isOutput=$IsOutputString;isReadOnly=$IsReadOnlyString]$Value"
        }
        'Command-TaskSetSecret' {
            return "##vso[task.setsecret]$Value"
        }
        'Command-TaskPrependPath' {
            return "##vso[task.prependpath]$Path"
        }
        'Command-TaskUploadFile' {
            return "##vso[task.uploadfile]$Path"
        }
        'Command-TaskSetProgress' {
            return "##vso[task.setprogress value=$Progress;]$Message"
        }
        'Command-TaskLogIssue' {
            $Properties = @()
            if ($SourcePath) { $Properties += "sourcepath=$SourcePath" }
            if ($LineNumber) { $Properties += "linenumber=$LineNumber" }
            if ($ColNumber) { $Properties += "colnumber=$ColNumber" }
            if ($Code) { $Properties += "code=$Code" }
            return "##vso[task.logissue type=$Type;$($Properties -join ';')]$Message"
        }
        'Command-BuildAddBuildTag' {
            return "##vso[build.addbuildtag]$Tag"
        }
    }
    return
}
}