Skip to content

Instantly share code, notes, and snippets.

@mevanlc
Last active September 26, 2024 19:12
Show Gist options
  • Save mevanlc/1df165d846c4b6735c16734c0454e503 to your computer and use it in GitHub Desktop.
Save mevanlc/1df165d846c4b6735c16734c0454e503 to your computer and use it in GitHub Desktop.
PowerShell: Avoiding double-confirmations in Cmdlets that use SupportsShouldProcess

(The content of this article is licensed under "CC-BY" which allows you to share, adapt, remix, and republish this work. All that's required is attribution like this: "© CC-BY <link to where you obtained this article>"). More info at: https://creativecommons.org/licenses/by/4.0/

PowerShell: Avoiding double-confirm prompts in Cmdlets

A PowerShell Design Pattern: Handling Confirmation Prompts in Cmdlets


When writing custom PowerShell Cmdlets, a common scenario arises when your Cmdlet has the SupportsShouldProcess = $true attribute set to leverage PowerShell’s -Confirm infrastructure. This attribute enables built-in confirmation prompts that ask the user to verify potentially impactful actions before proceeding.

However, you might also find that your Cmdlet invokes other subcommands which also set SupportsShouldProcess = $true. PowerShell is designed such that the -Confirm state is inherited by those subcommands. Consequently, this can lead to an undesired interactive experience where the user is prompted multiple times: once for the main Cmdlet, and once (or more) for each subcommand.

The Problem with Multiple Prompts

Let’s say you have a Cmdlet that, amongst other things, deletes a directory, and it uses Remove-Item, which itself supports ShouldProcess. If the user runs your Cmdlet with -Confirm, they might face two prompts:

  1. One for the $PSCmdlet.ShouldProcess() that wraps one or more commands, including the Remove-Item.
  2. One for the subcommand Remove-Item.

The double prompting is at best annoying and, at worst, confusing enough to potentially confuse someone into making a mistake.

The Traditional Approach to Avoiding the Double Prompt

A common solution is to pass -Confirm:$false to the subcommands, preventing their prompts from appearing:

Remove-Item -Confirm:$false $Path

While this works, it may not always be the best choice. Sometimes the subcommand's prompts are phrased in a more meaningful way, or providing additional context or descriptive data that's not available in your Cmdlet. Disabling these prompts could prevent the user from making informed decisions. The subcommand may also want to take "High" impact actions which the user might like to confirm separately.

A Better Pattern: Checking Return Values

Instead of disabling the subcommand’s prompts, you can let them prompt as intended and use the return value of the command to see if the user declined a confirmation. Here's a motivating example:

if (-not (Remove-Item $item)) { return } # return for fail-fast

This approach leverages the fact that when a user declines a confirmation prompt, the return value of the subcommand is false. If the subcommand runs successfully, its return value is true. This allows you to detect if the user declined the action and handle it accordingly.

Handling Errors

There is, however, a caveat: if Remove-Item encounters an error (e.g., due to permissions), it will fail silently, and the associated if/return will cause your script to exit silently. This is "not ideal" for troubleshooting. To surface errors to the user while still handling declined confirmations properly, we could use -ErrorAction Stop:

if (-not (Remove-Item -ErrorAction Stop $item)) { return }

This pattern is not immediately intuitive to an uninformed reader: it looks like you've set -ErrorAction Stop which should make your Cmdlet fail-fast should this subcommand encounter an error. Why then leave the if/return in the code. Well, it's there handle the case of a declined prompt from the subcommand, as they do not trigger the -ErrorAction Stop. The if/return is there to prevent running downstream code if the user declines to proceed.

Combining Patterns: Fail Fast

In a "fail-fast is the default behavior" style Cmdlet, if a command fails or the user declines to proceed, you want your script to stop immediately. This is similar to the behavior in bash scripts using set -e. In PowerShell, you can achieve this by setting:

$ErrorActionPreference = 'Stop'

at the top of your function. This makes sure that any command failure will throw an exception, stopping the execution of the script.

Example: A Real Cmdlet

Here’s an example of a Cmdlet that recreates a directory:

function RecreateDir {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [string]$Path,
        [switch]$Force
    )
    if (Test-Path $Path) {
        if (Test-Path -PathType Leaf $Path) {
            if (-not $Force) {
                Write-Error "Error: $Path is a file, not a directory. Use -Force to override."
                return
            } else {
                if (-not (Remove-Item -Force -ErrorAction Stop $Path)) {
                    return
                }
            }
        } else {
            if(-not (Remove-Item -Recurse -Force -ErrorAction Stop $Path)) {
                return
            }
        }
    }
    New-Item -ItemType Directory -Path $Path -Confirm:$false
}

Explanation

  • if (-not (Some-Command -ErrorAction Stop)) { return }: This is the key pattern, used to handle declined confirmations from the subcommand while also stopping on errors.
  • The if/return does not interfere with -ErrorAction Stop's behavior. If Remove-Item encounters a real error, an exception is thrown, halting the script.
  • This pattern balances catching declined prompts and handling real errors gracefully, providing a "fail fast" approach that can be useful in many scenarios.

Summary

To summarize, when you have a Cmdlet using SupportsShouldProcess = $true, and you invoke subcommands that also support confirmation, consider the pattern:

  1. Use -ErrorAction Stop to surface and handle actual errors.
  2. Use if (-not (Subcommand)) { return } to detect and handle user-declined confirmation prompts.
  3. Optionally set $ErrorActionPreference = 'Stop' for a consistent error-handling experience throughout your function.

This design pattern ensures that your script is both user-friendly and robust, handling both user intentions and errors effectively.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment