ExternalCommand.psm1

function Invoke-ExternalCommand
{
    <#
    .SYNOPSIS
    This is a helper function that runs a external executable binary and checks
    exit code for success to see if an error occurred. If an non 0 exit code is
    detected then an exception is thrown by default. It also properly creates
    and escapes arguments supplied via Arguments array parameter. Supports UTF8
    and whitespace in the argument values.
    .PARAMETER Command
    Executable name/command to run. Must be available at PATH env var or you can specify full path to the binary.
    .PARAMETER Arguments
    Arguments string array to path to Command as arguments
    .PARAMETER HideArguments
    List indexes (starting with 0) of arguments you would like to obscure in the message that command and its
    arguments that being executed.
    .PARAMETER DontEscapeArguments
    List indexes (starting with 0) of arguments you would like to skip escape logic on.
    When used, unless is a simple argument it's on you to escape it correctly
    .PARAMETER OutVarStdout
    Provide variable name that will be used to save STDOUT output of the command execution.
    .PARAMETER OutVarStderr
    Provide variable name that will be used to save STDERR output of the command execution.
    .PARAMETER OutVarCode
    Provide variable name that will be used to save process exit code.
    .PARAMETER Return
    By default this function returns $null, if specified you will get this object:
    @{Stdout="Contains Stdout",Stderr="Contains Stderr",All="Stdout and Stderr output as it was generated",Code="Int from process exit code"}
    Stdout output sequence order is guaranteed, while Stderr lines sequence might be out of order (eventing nature?).
    .PARAMETER IgnoreExitCode
    Specify if you expect non 0 exit code from the Command and would like to avoid non 0 exit code exception.
    .PARAMETER HideStdout
    Specify if don't want STDOUT to be written to the host
    .PARAMETER HideStderr
    Specify if don't want STDERR to be written to the host
    .PARAMETER HideCommand
    Specify if don't want `Running command` informational message to be written to the host STDERR
    .EXAMPLE
    > Invoke-ExternalCommand -Command git -Arguments version
    Running command [ C:\Program Files\Git\cmd\git.exe ] with arguments: "version"
    git version 2.20.1.windows.1

    > Invoke-ExternalCommand -Command helm -Arguments version,--client
    Running command [ C:\ProgramData\chocolatey\bin\helm.exe ] with arguments: "version" "--client"
    Client: &version.Version{SemVer:"v2.12.2", GitCommit:"7d2b0c73d734f6586ed222a567c5d103fed435be", GitTreeState:"clean"}

    > Invoke-ExternalCommand -Command helm -Arguments versiondd,--client
    Running command [ C:\ProgramData\chocolatey\bin\helm.exe ] with arguments: "versiondd" "--client"
    Error: unknown command "versiondd" for "helm"
    Did you mean this?


        version
    Run 'helm --help' for usage.

    Command returned non zero exit code of '1'. Command: helm
    At C:\Users\user\dev\ps-modules\Igloo.Powershell.ExternalCommand\Igloo.Powershell.ExternalCommand.psm1:237 char:9
    + throw ([string]::Format("Command returned non zero exit code ...
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : OperationStopped: (Command returne.... Command: helm:String) [], RuntimeException
    + FullyQualifiedErrorId : Command returned non zero exit code of '1'. Command: helm
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param (
        [Parameter(Mandatory = 1)][string]$Command,
        [string[]]$Arguments = @(),
        [int[]]$HideArguments = @(),
        [int[]]$DontEscapeArguments = @(),
        [string]$OutVarStdout = $null,
        [string]$OutVarStderr = $null,
        [string]$OutVarCode = $null,
        [switch]$Return,
        [switch]$IgnoreExitCode,
        [switch]$HideStdout,
        [switch]$HideStderr,
        [switch]$HideCommand
    )
    # setting desired $ErrorActionPreference, backup old one
    $OldErrorActionPreference = $ErrorActionPreference
    $ErrorActionPreference = "Stop"

    $CommandObject = (Get-Command $Command -CommandType Application -ErrorAction Ignore | Select-Object -First 1)
    if (!$CommandObject)
    {
        throw "Invalid application name\path provided (Cmdlets not supported): $Command"
    }

    # for debug info hiding args
    # looping through all arguments
    $ArgsToShow = New-Object System.Collections.Generic.List[String]

    for ($i = 0; $i -lt $Arguments.length; $i++)
    {
        # replacing sensitive arguments with "<sensitive>"
        if ($HideArguments -contains $i)
        {
            $ArgsToShow.Add("<sensitive>")
        }
        else
        {
            $ArgsToShow.Add($Arguments[$i])
        }
    }
    if ($ArgsToShow.Length -gt 0)
    {
        $ArgsToShow = (EscapeArguments -Arguments $ArgsToShow -DontEscapeArguments $DontEscapeArguments)
    }
    # Displaying what will be executed
    if (!$HideCommand)
    {
        # output to STDERR not to corrupt STDOUT
        [Console]::Error.WriteLine([string]::Format("Running command [{0}] with arguments: {1}", $CommandObject.Source, $ArgsToShow -join " "))
    }

    $pinfo = New-Object System.Diagnostics.ProcessStartInfo
    # Full pth to the binary
    $pinfo.FileName = $CommandObject.Source
    # enable UTF-8 Support and redirecting STDERR and STDOUT
    $pinfo.StandardOutputEncoding = New-Object System.Text.UTF8Encoding $false
    $pinfo.StandardErrorEncoding = New-Object System.Text.UTF8Encoding $false
    $pinfo.RedirectStandardError = $true
    $pinfo.RedirectStandardOutput = $true

    # please no shell
    $pinfo.UseShellExecute = $false

    # set process working directory
    $pinfo.WorkingDirectory = (Get-Location).Path

    # adding arguments if needed
    if ($Arguments.Length -gt 0)
    {
        $pinfo.Arguments = (EscapeArguments -Arguments $Arguments -DontEscapeArguments $DontEscapeArguments)
    }

    # Creating process
    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $pinfo

    # https://xiaalex.wordpress.com/2013/05/03/use-asynchronous-read-on-standard-output-of-a-process/

    # object in which we will capture STDOUT and STDERR
    $Results = @{
        Stderr     = ""
        Stdout     = ""
        All        = ""
        Code       = 0
        HideStderr = $HideStderr
    }

    # Event handler for STDERR lines
    # Additional output handler to print STDERR into console realtime and capture it into var
    $ErrAction = {
        $EventData = $Event.SourceEventArgs.Data
        if (![String]::IsNullOrWhiteSpace($EventData))
        {
            $Event.MessageData.Stderr += "$EventData`n"
            $Event.MessageData.All += "$EventData`n"
            if (!$Event.MessageData.HideStderr)
            {
                [Console]::Error.WriteLine($EventData)
            }
        }
    }

    # STDOUT might get captured out of sequence
    $ErrEvent = Register-ObjectEvent -InputObject $p -EventName ErrorDataReceived -Action $ErrAction -MessageData $Results
    # Start command process
    $p.Start() | Out-Null

    # Start triggering events for STDERR
    $p.BeginErrorReadLine();

    # running until process have exited and ErrorDataReceived events are done and no more data in StandardOutput stream
    do
    {
        # capturing STDOUT this way guaranties sequence of lines (important)
        $StdoutLine = $p.StandardOutput.ReadLine()
        if ($null -ne $StdoutLine)
        {
            if (!$HideStdout)
            {
                Write-Host $StdoutLine -ForegroundColor Green
            }
            $Results.Stdout += $StdoutLine
            $Results.All += $StdoutLine
            if (!$p.StandardOutput.EndOfStream)
            {
                # dont add new line at the end
                $Results.Stdout += "`n"
                $Results.All += "`n"
            }
        }
    } while (!$p.HasExited -or !$ErrEvent.HasMoreData -or !$p.StandardOutput.EndOfStream)
    Unregister-Event -SourceIdentifier $ErrEvent.Name

    # Cleanup flag we used in the error event handler
    $Results.Remove('HideStderr')

    # Some Trimming
    $Results.Stderr = $Results.Stderr.TrimEnd("`n")
    $Results.All = $Results.All.TrimEnd("`n")

    # Saving exit code
    $Results.Code = $p.ExitCode

    # Exit code check if not asked to ignore it
    if ( ($Results.Code -ne 0) -and !$IgnoreExitCode)
    {
        throw ([string]::Format("Command returned non zero exit code of '{0}'. Command: {1}`nSTDERR: {2}", $p.ExitCode, $Command, $Results.Stderr))
    }

    # Setting OutVarCode variable if requested
    if ($OutVarCode)
    {
        $PSCmdlet.SessionState.PSVariable.Set($OutVarCode, $Results.Code)
    }

    # Setting OutVarStderr variable if requested
    if ($OutVarStderr)
    {
        $PSCmdlet.SessionState.PSVariable.Set($OutVarStderr, $Results.Stderr)
    }

    # Setting OutVarStdout variable if requested
    if ($OutVarStdout)
    {
        $PSCmdlet.SessionState.PSVariable.Set($OutVarStdout, $Results.Stdout)
    }

    # Restoring $ErrorActionPreference on success
    $ErrorActionPreference = $OldErrorActionPreference

    # Returning if requested
    if ($Return)
    {
        return $Results
    }
    return $null
}


function EscapeArguments
{
    <#
    .SYNOPSIS
    Utility function to convert list of arguments from array of string to properly escaped string of arguments
    .PARAMETER Arguments
    Array of stings with arguments values
    .PARAMETER DontEscapeArguments
    List indexes (starting with 0) of arguments you would like to skip escape logic on.
    #>

    param (
        [string[]]$Arguments,
        [int[]]$DontEscapeArguments = @()
    )
    $EscapedArguments = New-Object System.Collections.Generic.List[String]
    $ArgPosition = -1
    foreach ($EscapedArg in $Arguments)
    {
        $ArgPosition++
        if ($DontEscapeArguments.Contains($ArgPosition))
        {
            $EscapedArguments.Add($EscapedArg)
            Write-Host "Unescaped Arguments: $EscapedArg"    
            continue
        }
        # https://docs.microsoft.com/en-gb/windows/desktop/api/shellapi/nf-shellapi-commandlinetoargvw
        # https://stackoverflow.com/questions/21314633/how-do-i-pass-a-literal-double-quote-from-powershell-to-a-native-command

        # stdlib Rules:
        # 2N backslashes + " ==> N backslashes and begin/end quote
        # 2N+1 backslashes + " ==> N backslashes + literal "
        # N backslashes ==> N backslashes

        # escaping double quotes as we use them to indicate start/end of argument
        $EscapedArg = $EscapedArg.Replace('"', '\"')

        # double all the slashes followed by \" (no more org before quote, so quote is never end/begin arg char)
        $EscapedArg = $EscapedArg -replace '(\\+)\\\"', '$1$1\"'

        # double all the slashes at the end of the argument
        $EscapedArg = $EscapedArg -replace '(\\+)$', '$1$1'

        # wrapping argument
        $EscapedArg = "`"$EscapedArg`""

        # appending to result
        $EscapedArguments.Add($EscapedArg)
        Write-Verbose "Escaped Arguments: $EscapedArg"

    }
    return $EscapedArguments -join " "
}