Detection Script Best Practices
Overview
Detection scripts are a critical component of the Unified App Management (UAM) deployment system. They determine whether an application is installed on a target host, and their return value drives the entire install/uninstall/upgrade decision flow. A poorly written detection script can cause silent deployment failures, incorrect app state reporting, or catastrophic process termination.
This guide covers both Shell App detection scripts and Winget App custom detection scripts.
How Detection Scripts Are Executed
Execution Engine
Detection scripts are wrapped in a function and executed via Invoke-Expression:
# Shell Apps
$commandToExecute = "function __NMM__ {$__script__} __NMM__ @__ScriptArgs__"
$res = Invoke-Expression -Command $commandToExecute -ErrorAction Stop
# Winget Apps (custom detection)
$commandToExecute = "function __NMM__ {$__script__} __NMM__"
$res = Invoke-Expression -Command $commandToExecute -ErrorAction StopThe script is embedded inside a wrapper function __NMM__ and then invoked. This means the script runs in the same PowerShell process as the entire UAM orchestrator.
Execution Context
| Aspect | Shell Apps | Winget Apps (Custom Detection) |
|---|---|---|
| Context object |
$context (ShellContext) |
$context (WingetDetectionContext) |
| Available properties |
TargetVersion, DetectedVersion, Versions
|
TargetVersion |
| Available methods |
Log(), GetAttachedBinary()
|
Log() |
| Parameters | Passed via @__ScriptArgs__ from DetectParams
|
None |
| Runs in session | No (local execution) | No (local execution) |
Expected Return Values
| Return Value | Interpretation |
|---|---|
[string] |
App is installed; the string is the detected version name |
[bool] $true |
App is installed (version unknown) |
[bool] $false |
App is NOT installed |
$null |
App is NOT installed |
| Any other type | ERROR — throws exception: "unexpected type" |
Critical Failure Scenarios
1. Using exit or exit() — CATASTROPHIC
Risk Level: CRITICAL
# BAD — This kills the entire UAM process
if (-not (Test-Path "C:\Program Files\MyApp")) {
exit 1
}What happens:
-
exitterminates the entire PowerShell host process immediately - The UAM orchestrator is running in the same process
- The
Send-Responsecall inAppManagement-Manager.ps1is never reached - No results are uploaded to Azure Blob Storage
- The backend receives no response and the deployment is marked as failed/timed out
- Cleanup (PSSession removal, WinRM reset, local admin disable) is never executed
- The UAM local admin account remains active on the machine
- WinRM may remain in a modified state
Why it happens:
The detection script runs inside a function wrapper (__NMM__), but exit doesn't just exit the function — it terminates the PowerShell process entirely.
Correct approach:
# GOOD — Return the appropriate value
if (-not (Test-Path "C:\Program Files\MyApp")) {
return $null # App not installed
}
return $true # App installed2. Using exit with Code 0 — SILENT CATASTROPHIC
Risk Level: CRITICAL
# BAD — Even exit 0 kills the process
$app = Get-ItemProperty "HKLM:\SOFTWARE\MyApp" -ErrorAction SilentlyContinue
if ($app) {
exit 0 # "Success" that destroys everything
}
exit 1What happens:
Same as above — process termination. Even exit 0 kills the host. There is no "successful exit" from a detection script because the script is not a standalone process.
3. Unhandled Exceptions — Process Crash
Risk Level: HIGH
# BAD — Uncontrolled exception propagation
$version = (Get-WmiObject Win32_Product | Where-Object { $_.Name -eq "MyApp" }).Version
return $versionWhat happens:
- If the exception escapes the
try/catchinInvoke-ShellScriptExecutor/Invoke-WingetDetectionScriptExecutor, it's caught and re-thrown as a wrapped exception - The action is marked as failed (not skipped)
- For Shell Apps:
Invoke-ShellDetectionpropagates the exception up toInvoke-InstallShellAction, which catches it inInvoke-ConfigProcessingand marksSuccess = false - The deployment continues to the next action, but this action reports failure
- Logs may be incomplete since the exception interrupted log collection
Correct approach:
# GOOD — Handle errors gracefully
try {
$regPath = "HKLM:\SOFTWARE\MyApp"
if (Test-Path $regPath) {
$version = (Get-ItemProperty $regPath).Version
return $version
}
return $null
}
catch {
$context.Log("Detection failed: $($_.Exception.Message)")
return $null # Treat detection failure as "not installed"
}4. Infinite Loops / Long-Running Operations — Timeout Stall
Risk Level: HIGH
# BAD — Waiting indefinitely for a service
while ((Get-Service "MyAppService").Status -ne "Running") {
Start-Sleep -Seconds 5
}
return $trueWhat happens:
- Detection scripts do NOT have an independent timeout mechanism (unlike winget commands which have a 30-minute timeout via
Start-Job) - The entire UAM process stalls indefinitely
- For Azure VM Extension deployments: the Custom Script Extension itself has a timeout (typically 90 minutes), after which Azure marks it as failed
- No results are uploaded until the extension times out
- All subsequent actions in the policy are never processed
Correct approach:
# GOOD — Use bounded checks
$maxAttempts = 3
$attempt = 0
while ($attempt -lt $maxAttempts) {
$service = Get-Service "MyAppService" -ErrorAction SilentlyContinue
if ($service -and $service.Status -eq "Running") {
return $true
}
$attempt++
Start-Sleep -Seconds 2
}
return $null5. Writing Output to Pipeline Instead of Returning — Corrupted Return Value
Risk Level: HIGH
# BAD — Write-Output pollutes the return value
Write-Output "Checking for MyApp..."
$version = (Get-ItemProperty "HKLM:\SOFTWARE\MyApp" -ErrorAction SilentlyContinue).Version
if ($version) {
Write-Output "Found version: $version"
return $version
}
Write-Output "Not found"
return $nullWhat happens:
-
Write-Outputsends objects to the pipeline - When the function returns, PowerShell collects ALL pipeline output as the return value
- The result becomes an array like
@("Checking for MyApp...", "Found version: 1.0", "1.0") - An array is neither
[string], nor[bool], nor$null - The detection engine throws: "Detected script resulted in [...], that is of unexpected type [System.Object[]]"
- The action is marked as failed
Correct approach:
# GOOD — Use $context.Log() for messages, only return the final value
$context.Log("Checking for MyApp...")
$version = (Get-ItemProperty "HKLM:\SOFTWARE\MyApp" -ErrorAction SilentlyContinue).Version
if ($version) {
$context.Log("Found version: $version")
return $version
}
$context.Log("Not found")
return $null6. Returning Unexpected Types — Type Mismatch
Risk Level: MEDIUM
# BAD — Returns an object instead of a string
return Get-ItemProperty "HKLM:\SOFTWARE\MyApp"# BAD — Returns an integer
return 1# BAD — Returns an array
return @("1.0.0", "2.0.0")What happens:
- The detection engine only accepts
[string],[bool], or$null - Any other type throws an exception: "unexpected type"
- The action fails
Correct approach:
# GOOD — Always return a string version, boolean, or $null
$app = Get-ItemProperty "HKLM:\SOFTWARE\MyApp" -ErrorAction SilentlyContinue
if ($app) {
return [string]$app.Version # Explicit cast to string
}
return $null7. Modifying Global State — Side Effects
Risk Level: MEDIUM
# BAD — Changing working directory
Set-Location "C:\Program Files\MyApp"
if (Test-Path ".\config.xml") {
return $true
}
return $falseWhat happens:
- The UAM engine does save and restore
Get-Locationafter detection script execution - However, modifying environment variables, registry values, or global PowerShell preferences can affect subsequent actions
- Shell apps share the same process context for all actions in the deployment
Other dangerous side effects:
# BAD — Modifying environment variables
[Environment]::SetEnvironmentVariable("PATH", $newPath, "Machine")
# BAD — Killing processes
Stop-Process -Name "winget" -Force
# BAD — Modifying PowerShell preferences
$ErrorActionPreference = "SilentlyContinue"
$ProgressPreference = "Continue" # Can cause performance issuesCorrect approach:
# GOOD — Use scoped operations
$configPath = Join-Path "C:\Program Files\MyApp" "config.xml"
if (Test-Path $configPath) {
return $true
}
return $false8. Returning Version Not in Versions List — Shell App Skip Failure
Risk Level: MEDIUM (Shell Apps only)
# BAD — Returns "1.0.0.1234" but versions are defined as "1.0.0"
$regVersion = (Get-ItemProperty "HKLM:\SOFTWARE\MyApp").DisplayVersion
return $regVersion # Returns "1.0.0.1234"What happens:
- For Shell Apps,
Invoke-ShellDetectionchecks if the returned version matches a known version in$AllVersions - If not found: "Version X was not found among known versions" is logged as a warning
- The version comparison (
Confirm-VersionInstalled) still runs with the returned string - The comparison may produce unexpected results (skip when upgrade is needed, or upgrade when skip is appropriate)
Correct approach:
# GOOD — Return the exact version name as defined in the shell app
# Check what versions are available via $context.Versions
$installedBuild = (Get-ItemProperty "HKLM:\SOFTWARE\MyApp").DisplayVersion
# Map to known version
$knownVersion = $context.Versions | Where-Object { $installedBuild -like "$($_.Name)*" } | Select-Object -First 1
if ($knownVersion) {
return $knownVersion.Name
}
return $null9. Network-Dependent Detection — Unreliable Results
Risk Level: MEDIUM
# BAD — Detection depends on network availability
$response = Invoke-RestMethod "https://api.myapp.com/version"
if ($response.installed) {
return $response.version
}
return $nullWhat happens:
- Network may be unavailable during deployment (especially on freshly provisioned VMs)
- DNS resolution may fail
- API timeouts can stall the detection script
- Transient network errors cause false "not installed" results, triggering unnecessary reinstallation
Correct approach:
# GOOD — Use local detection methods
# Check registry, file system, or WMI — never depend on network
$regPath = "HKLM:\SOFTWARE\MyApp"
if (Test-Path $regPath) {
return (Get-ItemProperty $regPath).Version
}
return $null10. Using throw for Flow Control — Incorrect Behavior
Risk Level: MEDIUM
# BAD — Using throw to indicate "not installed"
$app = Get-Package -Name "MyApp" -ErrorAction Stop
if (-not $app) {
throw "Application not found"
}
return $app.VersionWhat happens:
-
throwis caught by the executor'stry/catchand stored as$ex - The exception is then re-thrown by the calling function
- The action is marked as failed (not "not installed")
- This is semantically wrong — "not installed" should be
$nullor$false, not an error
Correct approach:
# GOOD — Return $null for "not found"
$app = Get-Package -Name "MyApp" -ErrorAction SilentlyContinue
if ($app) {
return $app.Version
}
return $null11. ErrorActionPreference and -ErrorAction Stop Interactions
Risk Level: MEDIUM
# BAD — Get-ItemProperty throws on missing key with ErrorAction Stop
$version = (Get-ItemProperty "HKLM:\SOFTWARE\NonExistent\MyApp" -ErrorAction Stop).Version
return $versionWhat happens:
- The executor uses
-ErrorAction StoponInvoke-Expressionitself - Additionally, individual cmdlets with
-ErrorAction Stopwill throw terminating errors - These are caught by the executor's
try/catch, but result in the action failing
Key insight: The outer Invoke-Expression -ErrorAction Stop means that any uncaught terminating error inside the detection script bubbles up as a detection failure.
Correct approach:
# GOOD — Use SilentlyContinue for probing operations
$regPath = "HKLM:\SOFTWARE\MyApp"
$app = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
if ($app -and $app.Version) {
return [string]$app.Version
}
return $null12. Using Write-Host — Invisible but Harmless
Risk Level: LOW
# Not ideal but won't break anything
Write-Host "Detecting MyApp..."
return $trueWhat happens:
-
Write-Hostwrites directly to the console (host), not to the pipeline - It doesn't corrupt the return value
- However, the output is invisible in UAM logs (not captured anywhere)
- Use
$context.Log()instead for visibility
13. Large Output Volume — Memory and Performance
Risk Level: LOW
# BAD — Scanning entire disk
$files = Get-ChildItem "C:\" -Recurse -Filter "myapp.exe"
if ($files) { return $true }
return $falseWhat happens:
- Detection scripts should be fast (ideally less than 5 seconds)
- Recursive file searches, full WMI queries, or large registry enumerations can take minutes
- While not immediately fatal, they slow down the entire deployment batch
- All actions are sequential — one slow detection delays everything
Decision Matrix: What Happens to the Deployment
| Detection Script Behavior | Impact on Current Action | Impact on Other Actions | Results Uploaded? |
|---|---|---|---|
| exit / exit 0 / exit 1 | All fail | All fail | NO |
| Unhandled exception (throw) | Fails | Continue normally | Yes |
| Returns unexpected type | Fails | Continue normally | Yes |
| Pipeline pollution (array) | Fails | Continue normally | Yes |
| Infinite loop | Stalls indefinitely | Never reached | NO (until extension timeout) |
| Returns wrong version string | May trigger unnecessary upgrade/skip | Continue normally | Yes |
| Returns $null correctly | Triggers install | Continue normally | Yes |
| Returns version string correctly | May skip (if correct version) | Continue normally | Yes |
Quick Reference: Do's and Don'ts
DO
- Always use return — Never use exit, exit 0, or exit 1
- Return only [string], [bool], or $null — Cast explicitly if unsure
- Use $context.Log() for diagnostic output — Never Write-Output or Write-Host
- Handle errors with try/catch — Use -ErrorAction SilentlyContinue for probing
- Detect locally — Use registry, file system, or services (never network calls)
- Keep detection fast — Target less than 5 seconds execution time
- Return the exact version name that matches your shell app version definitions
- Test for $null explicitly — Don't rely on truthiness for important checks
- Use bounded operations — Add timeouts or attempt limits for any loop
DON'T
- Never use exit — This is the #1 cause of catastrophic deployment failures
- Never use Write-Output — It corrupts the return value
- Never throw to indicate "not installed" — Use return $null or return $false
- Never modify global state — No env vars, no registry writes, no process kills
- Never depend on network — APIs can be unavailable during deployment
- Never use infinite loops — Always have a bounded exit condition
- Never return complex objects — Only scalar values
- Never call Stop-Process on system processes — Especially not winget or powershell
- Never use [System.Environment]::Exit() — Same as exit, kills the host process
Template: Recommended Detection Script Structure
Shell App Detection Script
# Shell App Detection Script Template
# Available: $context.TargetVersion, $context.DetectedVersion, $context.Versions
# Available: $context.Log(), $context.GetAttachedBinary()
# Must return: [string] version name | [bool] | $null
try {
$context.Log("Starting detection for MyApp")
# Method 1: Registry-based detection (preferred)
$regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MyApp"
$app = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
if ($app -and $app.DisplayVersion) {
$detectedVersion = [string]$app.DisplayVersion
$context.Log("Found MyApp version: $detectedVersion")
return $detectedVersion
}
# Method 2: File-based detection (fallback)
$exePath = "C:\Program Files\MyApp\myapp.exe"
if (Test-Path $exePath) {
$fileVersion = (Get-Item $exePath).VersionInfo.ProductVersion
$context.Log("Found MyApp binary, version: $fileVersion")
return [string]$fileVersion
}
$context.Log("MyApp not found")
return $null
}
catch {
$context.Log("Detection error: $($_.Exception.Message)")
return $null
}Winget App Custom Detection Script
# Winget App Custom Detection Script Template
# Available: $context.TargetVersion, $context.Log()
# Must return: [string] version | [bool] | $null
try {
$context.Log("Detecting app installation")
$regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{APP-GUID}"
$app = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
if ($app -and $app.DisplayVersion) {
$version = [string]$app.DisplayVersion
$context.Log("Detected version: $version (target: $($context.TargetVersion))")
return $version
}
# Check 64-bit registry path as well
$regPath64 = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{APP-GUID}"
$app = Get-ItemProperty $regPath64 -ErrorAction SilentlyContinue
if ($app -and $app.DisplayVersion) {
$version = [string]$app.DisplayVersion
$context.Log("Detected version (64-bit path): $version")
return $version
}
$context.Log("Application not detected")
return $null
}
catch {
$context.Log("Detection failed: $($_.Exception.Message)")
return $null
}Debugging Detection Scripts
Reading Logs
Detection script logs are available in two places:
- Action-level log: The Log field in the per-action result JSON
- Global log file: $TEMP\NMMLogs\AppsManager.log on the target VM
Common Log Messages to Look For
| Log Message | Meaning |
|---|---|
| "Detection script returned version name [X]" | Script returned a string — app detected |
| "Detection script returned boolean [True/False]" | Script returned bool — version unknown |
| "Detection script returned $null" | Script returned null — not installed |
| "Detected script resulted in [...], that is of unexpected type" | Return type error |
| "Shell script threw an exception" | Unhandled error in script |
| "Version X was not found among known versions" | Returned version doesn't match definitions |
Testing Detection Scripts Locally
# Simulate the execution environment
$result = & {
function __NMM__ {
# Paste your detection script here
}
__NMM__
}
# Verify return type
Write-Host "Type: $($result.GetType().Name)"
Write-Host "Value: $result"
# Check: should be [string], [bool], or $null
if ($result -is [string]) { Write-Host "OK: Version string" }
elseif ($result -is [bool]) { Write-Host "OK: Boolean" }
elseif ($null -eq $result) { Write-Host "OK: Null (not installed)" }
else { Write-Host "ERROR: Unexpected type!" }Summary of Impact Severity
| Issue | Severity | Scope of Damage |
|---|---|---|
| exit / [System.Environment]::Exit() | CRITICAL | Entire deployment lost, no results, no cleanup |
| Infinite loop | HIGH | Entire deployment stalls until external timeout |
| Unhandled exception | HIGH | Current action fails, others continue |
| Pipeline output pollution | HIGH | Current action fails with type error |
| Wrong return type | MEDIUM | Current action fails with type error |
| Wrong version string | MEDIUM | Incorrect install/skip decision |
| Network dependency | MEDIUM | Unreliable detection results |
| Slow execution | LOW | Delays entire deployment batch |
| Write-Host usage | LOW | Invisible output (no harm, no help) |
Need help?
Raise a support ticket for this item.
Comments (0 comments)