ExternalCommand.psm1
<# .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 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. # .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\apopovich\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 #> function Invoke-ExternalCommand { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = 1)][string]$Command, [string[]]$Arguments = @(), [int[]]$HideArguments = @(), [string]$OutVarStdout = $null, [string]$OutVarStderr = $null, [string]$OutVarCode = $null, [switch]$Return, [switch]$IgnoreExitCode ) # 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 = @() for ($i = 0; $i -lt $Arguments.length; $i++) { # replacing sensitive arguments with "<sensitive>" if ($HideArguments -contains $i) { $ArgsToShow += "<sensitive>" } else { $ArgsToShow += $Arguments[$i] } } if ($ArgsToShow.Length -gt 0) { $ArgsToShow = (EscapeArguments($ArgsToShow)) } # Displaying what will be executed Write-Host -Object "Running command [", $CommandObject.Source, "] with arguments:", $ArgsToShow -ForegroundColor Yellow $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 # adding arguments if needed if ($Arguments.Length -gt 0) { $pinfo.Arguments = (EscapeArguments($Arguments)) } # 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 } # 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" Write-Host $EventData -ForegroundColor Gray } } # STDERR 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) { 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 # 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 } <# .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 #> function EscapeArguments { param ( [string[]]$Arguments ) $EscapedArguments = @() foreach ($EscapedArg in $Arguments) { # 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 # escaped all non escaped slashed followed by quotes (no more org before quote, so quote is never end/begin arg char) $EscapedArg = $EscapedArg -replace '(\\*)\"', '$1$1\"' $EscapedArg = "`"$EscapedArg`"" $EscapedArguments += $EscapedArg Write-Verbose "Escaped Arguments: $EscapedArg" } return $EscapedArguments -join " " } |