(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/
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.
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:
- One for the
$PSCmdlet.ShouldProcess()
that wraps one or more commands, including theRemove-Item
. - 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.
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.
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.
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.
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.
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
}
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. IfRemove-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.
To summarize, when you have a Cmdlet using SupportsShouldProcess = $true
, and you invoke subcommands that also support confirmation, consider the pattern:
- Use
-ErrorAction Stop
to surface and handle actual errors. - Use
if (-not (Subcommand)) { return }
to detect and handle user-declined confirmation prompts. - 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.