2

I would like to call external commands from PowerShell Core such as Git, Python or other proprietary executables, but I want the call to throw when the exit code is non-zero while respecting PowerShell's ErrorAction (set via $ErrorActionPreference or ErrorAction argument). This normally is not the case, e.g. the following doesn't throw, but I would like it to:

cmd /c "exit 1" git foo 

What is the best / recommended way of doing that considering at least the following criteria:

  • [exception support]: generates an exception when the command fails or returns a non-zero exit code
  • [consistency]: consistent syntax accross various use cases (see use cases below)
  • [simplicity]: call in a most straightforward way, i.e. one line with as few extra characters as possible compared to the original direct command
  • [output]: output of command can either be printed to console in real-time while the command is running or captured into a variable
  • [spaces support]: support for spaces in path of command or in arguments
  • [variables support]: support for commands and arguments being stored in variables
  • [universality]: support for arbitrary arguments of the command
  • [interactivity support]: interactive commands remain interactive (e.g. console menus controlled via arrow keys)
  • [security]: safe to use in cloud production environments (as Invoke-Expression is often criticized for this)
  • [no dependency]: no external dependencies / modules
  • [ps core]: use in PowerShell Core
  • [kiss-dry]: respects common software development principles such as: KISS, DRY
  • [explicit argument] (OPTIONAL): explicit argument names when using functions (as this is required by our style guide, an exception for this might be possible though)

I reviewed many other posts already, but neither of these did tick all my criteria. I hope I didn't oversee anything. My focus is on usability, so here are some use cases, I would like to have supported with optimal user experience.

Use cases

An ideal function for me would let me do arbitrary calls as for example:

$ErrorActionPreference = 'Stop' # USE CASE: simple command Invoke-Call -Command "git version" # USE CASE: unknown command should fail with an exception Invoke-Call -Command "not-a-command" # USE CASE: failing commands should fail with an exception Invoke-Call -Command "git foo" # USE CASE: store output in variable $output = Invoke-Call -Command "git version" Write-Host $output # USE CASE: command with spaces Invoke-Call -Command "'C:\Program Files\Git\cmd\git.exe' version" # USE CASE: command with real-time output and non-zero exit code should show all output in real-time, then fail with an exception Invoke-Call -Command "ping -n 2 localhost & exit 1" # USE CASE: complex command with argument including spaces Invoke-Call -Command "cmd /c 'ping -n 2 localhost & exit 1'" # USE CASE: arbitrary command in a variable including arguments $commandWithArguments = "'C:\Program Files\Git\cmd\git.exe' version --build-options" Invoke-Call -Command $commandWithArguments # USE CASE: explicit error action should override $ErrorActionPreference Invoke-Call -Command "not-a-command" -ErrorAction Ignore 

Attempt using Invoke-Expression

function Invoke-ExpressionWithErrorHandling { param ( [Parameter(Mandatory)] [string] $Command ) Invoke-Expression -Command $Command if ($LASTEXITCODE -ne 0) { Write-Error "Expression exited with exit code $LASTEXITCODE" } } 

Use case testing:

PS D:\> $ErrorActionPreference = 'Stop' PS D:\> PS D:\> # USE CASE: simple command PS D:\> Invoke-ExpressionWithErrorHandling -Command "git version" git version 2.46.0.windows.1 PS D:\> PS D:\> # USE CASE: unknown command should fail with an exception PS D:\> Invoke-ExpressionWithErrorHandling -Command "not-a-command" Invoke-Expression: Line | 8 | Invoke-Expression -Command $Command | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | The term 'not-a-command' is not recognized as a name of a cmdlet, function, script file, or executable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again. PS D:\> PS D:\> # USE CASE: failing commands should fail with an exception PS D:\> Invoke-ExpressionWithErrorHandling -Command "git foo" git: 'foo' is not a git command. See 'git --help'. The most similar commands are flow hook Invoke-ExpressionWithErrorHandling: Expression exited with exit code 1 PS D:\> PS D:\> # USE CASE: store output in variable PS D:\> $output = Invoke-ExpressionWithErrorHandling -Command "git version" PS D:\> Write-Host $output git version 2.46.0.windows.1 PS D:\> PS D:\> # USE CASE: command with spaces PS D:\> Invoke-ExpressionWithErrorHandling -Command "`"C:\Program Files\Git\cmd\git.exe`" version" Invoke-Expression: Line | 8 | Invoke-Expression -Command $Command | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | Unexpected token 'version' in expression or statement. 

As you can see this fails when passing commands with full paths containing spaces. I could not find any variant of quotes to make this work.

Missed criteria:

  • [spaces support]

More elaboration required for criteria:

  • [security]
3
  • Being that your executable path has a space, the path needs to be encapsulated in single quotes for this to work. You'd get same results from a console window as well. Invoke-ExpressionWithErrorHandling -Command "'C:\Program Files\Git\cmd\git.exe' version" Commented Aug 27, 2024 at 16:22
  • I would just run it and check $lastexitcode. Commented Aug 28, 2024 at 2:02
  • @Grok42 did you actually test that? If I do that, I get an error: Unexpected token 'version' in expression or statement. Commented Aug 28, 2024 at 9:08

3 Answers 3

3

I don't see the reason why you should use Invoke-Expression in this case, as was mentioned by Grok42 in a comment, the reason why the attempt failed is because you need to quote your path and, in addition to that, you'll need to add the call operator & to invoke that path:

Invoke-ExpressionWithErrorHandling "& 'C:\Program Files\Git\cmd\git.exe' version" 

However you can simply invoke the command passed to the -Command argument using the call operator and you can add a second parameter for the arguments that takes ValueFromRemainingArguments:

function Invoke-WithErrorHandling { param ( [Parameter(Mandatory)] [string] $Command, [Parameter(ValueFromRemainingArguments)] [string[]] $Arguments ) & $Command @Arguments if ($LASTEXITCODE -ne 0) { Write-Error "'$Command' exited with exit code '$LASTEXITCODE'" } } Invoke-WithErrorHandling cmd /c 'ping -n 3 google.com & exit 2' # this way would also work but requires escaping of & with a backtick (`) in pwsh 7+ # cause otherwise its interpreted as a job and as a reserved char in pwsh 5.1 :( Invoke-WithErrorHandling cmd /c ping -n 3 google.com `& exit 2 Invoke-WithErrorHandling 'C:\Program Files\Git\cmd\git.exe' version 
Sign up to request clarification or add additional context in comments.

4 Comments

The first suggestion to include single quotes doesn't work for me. I get the error: Unexpected token 'version' in expression or statement.
Thank you for the suggestion using the call operator. Looks quiet close to my desires already. Would that be possible somehow using a single string only to reduce the extra characters and make it easier to port commands to the script?
@boernsen missed the call operator in the first example, see the update
How would I call a command with arguments stored in a variable?
1
  • Santiago's helpful answer is promising:

    • It allows you to (mostly) retain regular invocation syntax, with the executable name / path and the arguments for it specified individually.

      • In other words: you only need to prepend Invoke-WithErrorHandling to how you would call your external program directly (albeit without the situational need for & - see this answer).
    • However, there's a caveat:

      • Because Invoke-WithErrorHandling is implemented as an advanced function (by virtue of using a [Parameter()] attribute), it invariably supports common parameters, which take precedence over any pass-through arguments.

      • E.g., Invoke-WithErrorHandling foo.exe -o would result in an error, because the -o matches two common parameters, -OutVariable and -OutBuffer, causing PowerShell to complain about ambiguity.

        • To work around this, such pass-through arguments need to be quoted, e.g., Invoke-WithErrorHandling foo.exe '-o' - but that is both cumbersome and requires you to be aware of which --prefixed pass-through arguments happen to collide with the names of common parameters.
  • Your own answer:

    • Requires you to pass an entire command line as a single string, to a different shell, cmd.exe

    • This requires you to obey the syntax of this other shell, in the context of obeying PowerShell's syntax rules first, in order to pass a string with embedded quoting.

    • The syntax rules of the target shell, cmd.exe, differ from PowerShell's, complicating the solution.

    • Additionally, there's the overhead of having to create an additional child process (for cmd.exe), though that may not matter much in practice.


The following avoids the common-parameter problem that affects Santiago's solution, by defining the function as a simple (non-advanced) one that passes all arguments through:

Note:

  • As Santiago's solution, the function below is meant to be used with individual arguments that mimic direct-invocation syntax, e.g., to make the function execute git foo, invoke it as
    Invoke-WithErrorHandling git foo
# To create an *alias* for this function that conforms to PowerShell's naming # standard, use, e.g.: # Set-Alias iwe Invoke-WithErrorHandling function Invoke-WithErrorHandling { # Parse the arguments into the executable and the pass-through arguments # for it. $exe, $argsForExe = $Args # Workaround for v7.2-: # Prevents 2> redirections applied to calls to this function # from accidentally triggering a terminating error in PS v7.2- # See bug report at https://github.com/PowerShell/PowerShell/issues/4002 $ErrorActionPreference = 'Continue' # In v7.4+, prevent PowerShell itself from emitting an error in response # to a nonzero exit code reported from a program. $PSNativeCommandUseErrorActionPreference = $false try { if ($MyInvocation.ExpectingInput) { # Relay pipeline input. Note: The input is collected first, in full, # up front, before it is relayed to the target executable. # Also, the v7.4+ ability to pipe raw bytes between two external # executables is unavailable due to using this wrapper function. $input | & $exe $argsForExe } else { & $exe $argsForExe } } catch { throw } # catch is triggered ONLY if $exe can't be found, never for errors reported by $exe itself if ($LASTEXITCODE) { throw "`"$exe`" indicated failure (exit code $LASTEXITCODE)." } } 

Note:

  • As requested, the above creates the equivalent of a .NET exception, which is a fatal-by-default script-terminating error, via the throw keyword.

  • The function also supports relaying pipeline input to the target executable, albeit - of necessity - such input is collected in memory first, in full.

    • The only way to avoid this would be to use an advanced function, which is not an option, because it would re-introduce the common-parameters problem.

    • Also, the use of a PowerShell wrapper function invariably precludes the PowerShell 7.4+ ability to pipe raw binary data between to external programs (see the bottom section of this answer for details).

  • There is still one remaining difference to direct invocation of external programs that cannot be avoided, whether or not the function is advanced.

    • Unquoted arguments containing , (e.g., foo,bar) must be quoted (e.g, 'foo,bar') in order to be passed through correctly; without quoting, the wrapper function receives the ,-separated tokens as separate arguments.
  • The above functionality is also available via the Native module's iee function (authored by me):

    • iee additionally compensates for PowerShell's long-standing bug with respect to passing arguments with embedded " characters in Windows PowerShell (whose latest and last version is 5.1.x), which was finally fixed - with selective exceptions - in PowerShell (Core) 7 v7.3+ - see this answer for details.

Alternatively, if you want to pass the command line to execute as a single argument, do not use a string, but a script block (type [scriptblock], literal form { ... }):

Note:

  • The following function therefore expects a single, mandatory script-block argument containing the command to execute (and optionally also pipeline input), so that in order to make it execute git foo, you'd call it as
    Invoke-WithErrorHandling { git foo }
# To create an *alias* for this function that conforms to PowerShell's naming # conventions, use, e.g.: # Set-Alias iwe Invoke-WithErrorHandling function Invoke-WithErrorHandling { param( [Parameter(Mandatory, Position=0)] [scriptblock] $ScriptBlock, [Parameter(ValueFromPipeline)] # Optional pipeline input $InputObject ) begin { # Set up a steppable pipeline. $steppablePipeline = $ScriptBlock.GetSteppablePipeline($myInvocation.CommandOrigin) $steppablePipeline.Begin($PSCmdlet) # Workaround for v7.2-: # Prevents 2> redirections applied to calls to this function # from accidentally triggering a terminating error in PS v7.2- # See bug report at https://github.com/PowerShell/PowerShell/issues/4002 $ErrorActionPreference = 'Continue' # In v7.4+, prevent PowerShell itself from emitting an error in response # to a nonzero exit code reported from a program. $PSNativeCommandUseErrorActionPreference = $false } process { # Pass the current pipeline input through. try { $steppablePipeline.Process($_) } catch { throw } } end { $steppablePipeline.End() if ($LASTEXITCODE) { throw "`"$ScriptBlock`" indicated failure (exit code $LASTEXITCODE)." } } } 

Note:

  • Unlike the earlier function, this one is an advanced one, which is unproblematic, because the one and only argument specifying the command line to execute cannot collide with common parameters anymore. However, common parameters such as -OutVariable and -ErrorVariable do not function properly, due to the design limitation discussed in GitHub issue #5758

  • The notes on the earlier function apply to this one too, except that pipeline input is supported in a streaming fashion by this function (no need to collect all input up front first), by virtue of it being implemented as a proxy (wrapper) function - see this answer for additional information.

12 Comments

Isn't it necessary anyway to obey the cmd shell's syntax when calling commands, which have been designed for cmd? (such as git.exe)
@boernsen, no command-line utility should be designed for a specific shell, and git.exe isn't either - you can call it equally from PowerShell and cmd.exe, in which case different syntax rules apply, respectively.
I see, thanks for clarifying. I guess then it is just my subjective impression that PowerShell's syntax for calling external command-line utilities is unintuitive or badly designed (compared to cmd.exe), which makes me think those command-line utilities are not designed for PowerShell :)
Can you give an example on how you would call your suggestion of Invoke-WithErrorHandling with commands being composed from variables? E.g. $myCommand = "git version"; Invoke-WithErrorHandling $myCommand. I couldn't get it to work.
@boernsen: I missed that you were looking to pass command lines as a single argument to the function. For that, it's better to use a script block ({ ... }) rather than a string. Please see my update.
|
0

I am attempting to answer my own question as I found a potential solution using the call operator & in combination with cmd /c.

I won't mark it as answer for now though as I would like to give opportunity to point out drawbacks I haven't seen and to collect more elaboration on the security aspect.

I added support for single quoted parts (e.g. an executable path or an argument) by explicitely replacing single quotes with escaped double quotes, making it easier to call such commands.

This is the function:

function Invoke-Cmd { [CmdletBinding()] param ( # main command, may contain arguments [Parameter(Mandatory)] [string] $Command, # Optional: skip automatic replacement of single quotes by double quotes [Parameter()] [switch] $SkipSingleQuotesReplacement, # Optional: separate arguments (this supports unquoted commands with arguments) [Parameter(ValueFromRemainingArguments)] [string[]] $SeparateArguments ) $finalCommand = $Command if ($null -ne $SeparateArguments) { $finalCommand += " $SeparateArguments" } # Single quotes are easier to use and read for commands with spaces, # so they are supported by replacing them by escaped double quotes # automatically if not skipped explicitely if (-not $SkipSingleQuotesReplacement) { $finalCommand = $finalCommand -replace "'","`"" } & cmd "/c `"$finalCommand`"" if ($LASTEXITCODE -ne 0) { Write-Error "The following command exited with exit code '$LASTEXITCODE': $finalCommand" } } 

Use cases tested (sorry for German locale, I couldn't figure out how to change it):

PS D:\> $ErrorActionPreference = 'Stop' PS D:\> # USE CASE: simple command PS D:\> Invoke-Cmd -Command "git version" git version 2.46.0.windows.1 PS D:\> # USE CASE: unknown command should fail with an exception PS D:\> Invoke-Cmd -Command "not-a-command" Der Befehl "not-a-command"" ist entweder falsch geschrieben oder konnte nicht gefunden werden. Invoke-Cmd: The following command exited with exit code '1': not-a-command PS D:\> # USE CASE: failing commands should fail with an exception PS D:\> Invoke-Cmd -Command "git foo" git: 'foo' is not a git command. See 'git --help'. The most similar commands are flow hook Invoke-Cmd: The following command exited with exit code '1': git foo PS D:\> # USE CASE: store output in variable PS D:\> $output = Invoke-Cmd -Command "git version" PS D:\> Write-Host $output git version 2.46.0.windows.1 PS D:\> # USE CASE: command with spaces PS D:\> Invoke-Cmd -Command "'C:\Program Files\Git\cmd\git.exe' version" git version 2.46.0.windows.1 PS D:\> # USE CASE: command with real-time output and non-zero exit code should show all output in real-time, then fail with an exception PS D:\> Invoke-Cmd -Command "ping -n 2 localhost & exit 1" Ping wird ausgeführt für MDXN01047043.bku.db.de [::1] mit 32 Bytes Daten: Antwort von ::1: Zeit<1ms Antwort von ::1: Zeit<1ms Ping-Statistik für ::1: Pakete: Gesendet = 2, Empfangen = 2, Verloren = 0 (0% Verlust), Ca. Zeitangaben in Millisek.: Minimum = 0ms, Maximum = 0ms, Mittelwert = 0ms Invoke-Cmd: The following command exited with exit code '1': ping -n 2 localhost & exit 1 PS D:\> # USE CASE: complex command with argument including spaces PS D:\> Invoke-Cmd -Command "cmd /c 'ping -n 2 localhost & exit 1'" Ping wird ausgeführt für MDXN01047043.bku.db.de [::1] mit 32 Bytes Daten: Antwort von ::1: Zeit<1ms Antwort von ::1: Zeit<1ms Ping-Statistik für ::1: Pakete: Gesendet = 2, Empfangen = 2, Verloren = 0 (0% Verlust), Ca. Zeitangaben in Millisek.: Minimum = 0ms, Maximum = 0ms, Mittelwert = 0ms Invoke-Cmd: The following command exited with exit code '1': cmd /c "ping -n 2 localhost & exit 1" PS D:\> # USE CASE: arbitrary command in a variable including arguments PS D:\> $commandWithArguments = "'C:\Program Files\Git\cmd\git.exe' version --build-options" PS D:\> Invoke-Cmd -Command $commandWithArguments git version 2.46.0.windows.1 cpu: x86_64 built from commit: 2e6a859ffc0471f60f79c1256f766042b0d5d17d sizeof-long: 4 sizeof-size_t: 8 shell-path: D:/git-sdk-64-build-installers/usr/bin/sh feature: fsmonitor--daemon libcurl: 8.9.0 OpenSSL: OpenSSL 3.2.2 4 Jun 2024 zlib: 1.3.1 PS D:\> # USE CASE: explicit error action should override $ErrorActionPreference PS D:\> Invoke-Cmd -Command "not-a-command" -ErrorAction Ignore Der Befehl "not-a-command"" ist entweder falsch geschrieben oder konnte nicht gefunden werden. 

Advantage:

  • ticks all requirements / use cases

Potential drawbacks (not for me though):

  • child process for cmd.exe (I don't mind as overhead is minimal in practice and usability is good.)
  • requires to obey syntax of cmd.exe (I personally prefer this as command's documentations and examples are often designed as such.)

Unclear:

  • Is it safe to use in a cloud production environment? Are there any better alternatives?

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.