SCRIPT LIBRARY · POWERSHELL
Silently Uninstall Any App by Name with a ConfigMgr Package
One uninstall script for every retired app. Give it a display name and it finds the right uninstall command, MSI or not, and runs it quietly.
- What it does
- Finds an installed app by its display name in the 64-bit and 32-bit Uninstall registry keys and removes it silently, using msiexec for MSI installs and the app's quiet uninstall command for everything else. Returns a result per app and a ConfigMgr-friendly exit code.
- Requires
- Windows PowerShell 5.1 or PowerShell 7
- No modules
- Permissions
- Local administrator or SYSTEM on the target PC
- Runs on
- Windows 10/11, Windows Server 2016+
- Tested
- Parse-checked and dry-run with mocked registry entries and Start-Process in PowerShell 7.4
Part 2 of the thread Packaging with ConfigMgr
Retiring an application is rarely hard. It's just tedious. You look up the product code, write a one-line msiexec /x {GUID} package, deploy it, and then find out a third of the machines have a different version with a different product code. Repeat for the next app.
The original post was exactly that one-liner, hard-wired to one version of the Cherwell Service Management client. This version skips the lookup. You give it the name as it appears in Programs and Features, and it reads the uninstall entry each machine actually has. MSI installs get removed with msiexec /x and their own product code, whatever version it is. Non-MSI installs use the app's QuietUninstallString, or the plain UninstallString plus switches you supply.
Cherwell is still the example below because it's a good one: the kind of desktop client that lingers on machines long after the platform behind it has moved on. Swap in whatever you're retiring.
<#
.SYNOPSIS
Silently uninstalls an application by its display name, using whatever uninstall entry the app registered.
.DESCRIPTION
Looks through the 64-bit and 32-bit Uninstall keys in HKLM for entries whose DisplayName matches.
MSI-based entries are removed with msiexec /x and the product code; everything else uses the
QuietUninstallString, or the UninstallString plus arguments you supply. Each removal is logged, and
the script returns one result per entry plus an exit code ConfigMgr understands (0, 3010, or the failure).
If the name matches more than one entry, it stops unless you add -AllowMultiple.
.PARAMETER DisplayName
Name as shown in Programs and Features. Wildcards are allowed, e.g. 'Cherwell*'.
.PARAMETER Publisher
Optional publisher filter, also wildcard-friendly. Handy when the app name is generic.
.PARAMETER SilentArgs
Arguments to add to a non-MSI UninstallString when the app doesn't register a QuietUninstallString.
.PARAMETER AllowMultiple
Remove every match instead of stopping when more than one entry matches.
.PARAMETER LogPath
Folder for msiexec logs. Defaults to the Windows temp folder.
.EXAMPLE
.\Uninstall-ApplicationByName.ps1 -DisplayName 'Cherwell*' -WhatIf
.EXAMPLE
.\Uninstall-ApplicationByName.ps1 -DisplayName 'Some Agent' -SilentArgs '/S'
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$DisplayName,
[string]$Publisher = '*',
[string]$SilentArgs,
[switch]$AllowMultiple,
[string]$LogPath = (Join-Path $env:SystemRoot 'Temp')
)
function Get-UninstallEntry {
# Read both registry views directly, so a 32-bit PowerShell host still sees 64-bit apps.
$views = if ([Environment]::Is64BitOperatingSystem) { 'Registry64', 'Registry32' } else { 'Default' }
foreach ($view in $views) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', $view)
$root = $base.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
if (-not $root) { continue }
foreach ($keyName in $root.GetSubKeyNames()) {
$key = $root.OpenSubKey($keyName)
[pscustomobject]@{
KeyName = $keyName
View = $view
DisplayName = $key.GetValue('DisplayName')
DisplayVersion = $key.GetValue('DisplayVersion')
Publisher = $key.GetValue('Publisher')
WindowsInstaller = $key.GetValue('WindowsInstaller')
SystemComponent = $key.GetValue('SystemComponent')
UninstallString = $key.GetValue('UninstallString')
QuietUninstall = $key.GetValue('QuietUninstallString')
}
$key.Close()
}
$root.Close(); $base.Close()
}
}
function Split-CommandLine {
param([string]$CommandLine)
if ($CommandLine -match '^\s*"([^"]+)"\s*(.*)$') { return $Matches[1], $Matches[2] }
if ($CommandLine -match '^\s*(.+?\.exe)\s*(.*)$') { return $Matches[1], $Matches[2] }
return $CommandLine, ''
}
$okCodes = 0, 1605 # 1605 = already gone
$rebootCodes = 1641, 3010
$matched = @(Get-UninstallEntry | Where-Object {
$_.DisplayName -like $DisplayName -and "$($_.Publisher)" -like $Publisher -and $_.SystemComponent -ne 1 -and ($_.UninstallString -or $_.QuietUninstall)
})
if ($matched.Count -eq 0) {
Write-Verbose "Nothing matching '$DisplayName' is installed."
exit 0
}
if ($matched.Count -gt 1 -and -not $AllowMultiple) {
$list = ($matched | ForEach-Object { "$($_.DisplayName) $($_.DisplayVersion)" }) -join '; '
Write-Error "'$DisplayName' matches $($matched.Count) entries ($list). Tighten the name or add -AllowMultiple."
exit 1
}
$results = foreach ($entry in $matched) {
$productCode = if ($entry.KeyName -match '^\{[0-9A-Fa-f\-]{36}\}$') { $entry.KeyName }
elseif ("$($entry.UninstallString)" -match '\{[0-9A-Fa-f\-]{36}\}') { $Matches[0] }
if ($productCode -and ($entry.WindowsInstaller -eq 1 -or "$($entry.UninstallString)" -match 'msiexec')) {
$log = Join-Path $LogPath ("Uninstall_{0}.log" -f ($productCode -replace '[{}]'))
$file = Join-Path $env:SystemRoot 'System32\msiexec.exe'
$arguments = "/x $productCode /qn /norestart REBOOT=ReallySuppress /L*v `"$log`""
}
elseif ($entry.QuietUninstall) {
$file, $arguments = Split-CommandLine $entry.QuietUninstall
}
elseif ($SilentArgs) {
$file, $arguments = Split-CommandLine $entry.UninstallString
$arguments = "$arguments $SilentArgs".Trim()
}
else {
Write-Warning "$($entry.DisplayName) has no quiet uninstall. Rerun with -SilentArgs for its installer type."
[pscustomobject]@{ DisplayName = $entry.DisplayName; Version = $entry.DisplayVersion; ExitCode = 1; Status = 'NoSilentOption' }
continue
}
if (-not $PSCmdlet.ShouldProcess("$($entry.DisplayName) $($entry.DisplayVersion)", "Run: $file $arguments")) { continue }
try {
$proc = Start-Process -FilePath $file -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
$code = $proc.ExitCode
}
catch {
Write-Warning "Couldn't start the uninstaller for $($entry.DisplayName): $($_.Exception.Message)"
$code = 1
}
$status = if ($okCodes -contains $code) { 'Removed' } elseif ($rebootCodes -contains $code) { 'RebootRequired' } else { 'Failed' }
[pscustomobject]@{ DisplayName = $entry.DisplayName; Version = $entry.DisplayVersion; ExitCode = $code; Status = $status }
}
$results
$failures = @($results | Where-Object Status -in 'Failed', 'NoSilentOption')
if ($failures) { exit $failures[0].ExitCode }
if ($results | Where-Object Status -eq 'RebootRequired') { exit 3010 }
exit 0
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-DisplayName | string | — | Required. The name from Programs and Features. Wildcards work, e.g. 'Cherwell*'. |
-Publisher | string | * | Narrow the match by publisher, handy when the app name is generic. |
-SilentArgs | string | — | Switches to add to a non-MSI UninstallString when the app doesn't provide a quiet one, e.g. /S for NSIS or /VERYSILENT for Inno Setup. |
-AllowMultiple | switch | — | Remove every matching entry. Without it, the script stops if more than one app matches. |
-LogPath | string | %SystemRoot%\Temp | Where msiexec writes its verbose logs, one per product code. |
-WhatIf | switch | — | Lists what matched and the exact command it would run. |
Run it
See what would be removed before you deploy anything.
.\Uninstall-ApplicationByName.ps1 -DisplayName 'Cherwell*' -WhatIfAs a ConfigMgr package program.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Uninstall-ApplicationByName.ps1 -DisplayName "Cherwell*"An EXE-installed app with no quiet uninstall string (NSIS installer).
.\Uninstall-ApplicationByName.ps1 -DisplayName 'Some Agent' -SilentArgs '/S'Every Fabrikam product, on purpose.
.\Uninstall-ApplicationByName.ps1 -DisplayName '*' -Publisher 'Fabrikam*' -AllowMultipleWhat you'll see
DisplayName Version ExitCode Status
----------- ------- -------- ------
Cherwell Service Management 10.2.1 3010 RebootRequired
How it works
- Read both registry views directly. It opens the 64-bit and 32-bit
Uninstallkeys throughRegistryKey.OpenBaseKey, so it sees everything even if ConfigMgr launches a 32-bit PowerShell. - Filter. Display name and publisher match your wildcards, hidden system components are skipped, and the entry has to have an uninstall command.
- Refuse surprises. No match means exit 0 (nothing to remove). More than one match without
-AllowMultiplemeans exit 1 and a list of what matched. - Pick the right command for each entry. If it's an MSI with a product code, it runs
msiexec.exe /x {code} /qn /norestartwith a verbose log. That includes entries that point atmsiexec /I, which would otherwise start a repair. If not, it usesQuietUninstallString, and failing that,UninstallStringplus-SilentArgs. - Run, wait, and report. Each uninstall gets a result object. Exit codes 0 and 1605 (already gone) count as removed, 1641 and 3010 as reboot needed.
- Hand ConfigMgr one exit code. The first failure if there was one, otherwise 3010 if anything wants a reboot, otherwise 0.
Packaging it
$SiteCode = 'ABC'
$SourceShare = '\\sccm01\Sources\Scripts'
$DPGroup = 'All DPs'
$Name = 'Uninstall - Cherwell Service Management'
$source = Join-Path $SourceShare 'Uninstall-ApplicationByName'
New-Item -ItemType Directory -Path $source -Force | Out-Null
Copy-Item .\Uninstall-ApplicationByName.ps1 -Destination $source
Import-Module (Join-Path $env:SMS_ADMIN_UI_PATH '..\ConfigurationManager.psd1')
Push-Location "$($SiteCode):\"
New-CMPackage -Name $Name -Path $source | Out-Null
New-CMProgram -PackageName $Name -StandardProgramName 'Uninstall' -CommandLine 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Uninstall-ApplicationByName.ps1 -DisplayName "Cherwell*"' -ProgramRunType WhetherOrNotUserIsLoggedOn -RunMode RunWithAdministrativeRights -RunType Hidden | Out-Null
Start-CMContentDistribution -PackageName $Name -DistributionPointGroupName $DPGroup
Pop-Location
The nice part is that the package content never changes. Next time you retire something, add another program to the same package with a different -DisplayName.
Take it further
- Use an Application if you want tracking. A package is fire-and-forget. Wrap the same script as the uninstall command of a ConfigMgr Application with a registry detection method, and you get proper compliance reporting and a clean "not installed" state.
- Find the stragglers first. Query hardware inventory (Add/Remove Programs) for the display name to build your target collection, so you're only deploying where the app actually is.
- Keep the logs where you'll look. Point
-LogPathatC:\Windows\CCM\Logsand the msiexec logs get picked up along with the client logs when you collect them from the console.
Things that'll trip you up
- Wildcards cut both ways. '*Client*' will happily match a dozen things you didn't mean. That's why the script refuses to go on when more than one entry matches, unless you add -AllowMultiple. Always run it with -WhatIf on a test machine first.
- Some uninstallers return before they're done. NSIS and a few others copy themselves to a temp folder, relaunch, and exit straight away, so the script sees exit code 0 while the real removal is still running. For NSIS, adding _?=<install folder> to SilentArgs keeps it in the foreground.
- Per-user installs are invisible to it. Apps installed into a user profile register under HKCU, not HKLM, and a package running as SYSTEM can't see them. Those need a user-context deployment instead.
- 3010 means reboot, not failure. The script passes 3010 back to ConfigMgr when an uninstall wants a restart. Set the program's "After running" option to decide whether ConfigMgr restarts the machine or leaves that to your normal maintenance window.