Detection Script Best Practices

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 Stop

The 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:

  • exit terminates the entire PowerShell host process immediately
  • The UAM orchestrator is running in the same process
  • The Send-Response call in AppManagement-Manager.ps1 is 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 installed

2. 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 1

What 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 $version

What happens:

  • If the exception escapes the try/catch in Invoke-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-ShellDetection propagates the exception up to Invoke-InstallShellAction, which catches it in Invoke-ConfigProcessing and marks Success = 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 $true

What 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 $null

5. 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 $null

What happens:

  • Write-Output sends 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 $null

6. 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 $null

7. 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 $false

What happens:

  • The UAM engine does save and restore Get-Location after 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 issues

Correct approach:

# GOOD — Use scoped operations
$configPath = Join-Path "C:\Program Files\MyApp" "config.xml"
if (Test-Path $configPath) {
    return $true
}
return $false

8. 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-ShellDetection checks 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 $null

9. 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 $null

What 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 $null

10. 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.Version

What happens:

  • throw is caught by the executor's try/catch and 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 $null or $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 $null

11. 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 $version

What happens:

  • The executor uses -ErrorAction Stop on Invoke-Expression itself
  • Additionally, individual cmdlets with -ErrorAction Stop will 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 $null

12. Using Write-Host — Invisible but Harmless

Risk Level: LOW

# Not ideal but won't break anything
Write-Host "Detecting MyApp..."
return $true

What happens:

  • Write-Host writes 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 $false

What 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

  1. Always use return — Never use exit, exit 0, or exit 1
  2. Return only [string], [bool], or $null — Cast explicitly if unsure
  3. Use $context.Log() for diagnostic output — Never Write-Output or Write-Host
  4. Handle errors with try/catch — Use -ErrorAction SilentlyContinue for probing
  5. Detect locally — Use registry, file system, or services (never network calls)
  6. Keep detection fast — Target less than 5 seconds execution time
  7. Return the exact version name that matches your shell app version definitions
  8. Test for $null explicitly — Don't rely on truthiness for important checks
  9. Use bounded operations — Add timeouts or attempt limits for any loop

DON'T

  1. Never use exit — This is the #1 cause of catastrophic deployment failures
  2. Never use Write-Output — It corrupts the return value
  3. Never throw to indicate "not installed" — Use return $null or return $false
  4. Never modify global state — No env vars, no registry writes, no process kills
  5. Never depend on network — APIs can be unavailable during deployment
  6. Never use infinite loops — Always have a bounded exit condition
  7. Never return complex objects — Only scalar values
  8. Never call Stop-Process on system processes — Especially not winget or powershell
  9. 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.

Was this article helpful?

0 out of 0 found this helpful
Have more questions? Submit a request

Comments (0 comments)

Please sign in to leave a comment.