Skip to main content

🧪 Offline Boot Testing & Generalization of VHDX OS Images

1. 🛠️ Manual Prep: Enter Audit Mode First

Before proceeding with automation, you'll need to enter System Audit Mode:

  1. Open Terminal, PowerShell, or Command Prompt as Administrator.
  2. Run the following to clear or set the Administrator password: net user administrator *
  • Press Enter twice for a blank password, or set a complex one that meets Windows password requirements.
  • (Don’t worry—this will be automatically reset within a week in production.)
  1. Then launch Sysprep in Audit Mode: C:\Windows\System32\Sysprep\sysprep.exe
  • Choose:
    • System Audit Mode
    • Reboot
  • Click OK and wait for the system to reboot into Audit Mode.

2. ⚙️ Automated Preparation & Generalization

This section provides the comprehensive PowerShell script that performs all the final cleanup and generalization steps.

🚀 AUTOMATED OPTION: Complete PowerShell Script

https://portal.dtctoday.com/link/424#bkmrk-for-a-fully-automate

For a fully automated approach that handles all cleanup steps including the sysprep generalization loop with AppxPackage removal, you can use this comprehensive script.

2. ⚙️ Automated Preparation & Generalization

This section provides the comprehensive PowerShell script that performs all the final cleanup and generalization steps.

🚀 AUTOMATED OPTION: Complete PowerShell Script

For a fully automated approach that handles all cleanup steps including the sysprep generalization loop with AppxPackage removal, you can use this comprehensive script:

# Complete-WindowsImagePrep.ps1
# Comprehensive Windows Image Preparation Script
# Combines all cleanup, generalization, and AppxPackage removal steps
# Run as Administrator

param(
    [switch]$SkipUserCleanup,
    [switch]$SkipAgentCleanup,
    [switch]$SkipLogCleanup
)

function Write-StepHeader {
    param([string]$Title)
    Write-Host ""
    Write-Host "=" * 60 -ForegroundColor Cyan
    Write-Host $Title -ForegroundColor Cyan
    Write-Host "=" * 60 -ForegroundColor Cyan
}

function Write-Success {
    param([string]$Message)
    Write-Host "✓ $Message" -ForegroundColor Green
}

function Write-Error {
    param([string]$Message)
    Write-Host "✗ $Message" -ForegroundColor Red
}

function Write-Warning {
    param([string]$Message)
    Write-Host "⚠ $Message" -ForegroundColor Yellow
}

function Write-Info {
    param([string]$Message)
    Write-Host "ℹ $Message" -ForegroundColor Blue
}

function Confirm-Step {
    param([string]$Message)
    do {
        $response = Read-Host "$Message (y/N)"
        $response = $response.ToLower()
    } while ($response -notin @('y', 'yes', 'n', 'no', ''))
    
    return ($response -in @('y', 'yes'))
}

function Clear-SysprepLogs {
    Write-Info "Clearing Sysprep Panther logs for fresh run..."
    try {
        $pantherPath = "C:\Windows\System32\Sysprep\Panther"
        if (Test-Path $pantherPath) {
            Remove-Item -Path "$pantherPath\*" -Force -Recurse -ErrorAction Stop
            Write-Success "Cleared Sysprep Panther logs"
        } else {
            Write-Info "Sysprep Panther directory doesn't exist yet"
        }
    } catch {
        Write-Warning "Could not clear Sysprep logs: $($_.Exception.Message)"
    }
}

function Remove-UserAccountsAndProfiles {
    if ($SkipUserCleanup) {
        Write-Warning "Skipping user cleanup (parameter specified)"
        return
    }

    Write-StepHeader "STEP 1: Remove User Accounts and Profiles"
    Write-Info "This will remove all local user accounts and profiles except:"
    Write-Info "• System accounts (Administrator, Guest, etc.)"
    Write-Info "• Current user: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1])"
    
    if (-not (Confirm-Step "Continue with user cleanup?")) {
        Write-Warning "Skipped user cleanup"
        return
    }

    $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1]
    $systemAccounts = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount',$currentUser)
    $systemProfiles = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount','Public',$currentUser)
    
    # Remove user accounts
    Write-Info "Removing local user accounts..."
    $users = Get-LocalUser | Where-Object { $_.Name -notin $systemAccounts }
    if ($users.Count -eq 0) {
        Write-Success "No additional user accounts found"
    } else {
        foreach ($user in $users) {
            try {
                Remove-LocalUser -Name $user.Name -ErrorAction Stop
                Write-Success "Removed user: $($user.Name)"
            } catch {
                Write-Error "Failed to remove user: $($user.Name) - $($_.Exception.Message)"
            }
        }
    }
    
    # Remove user profiles
    Write-Info "Removing local user profiles..."
    $profileFolders = Get-ChildItem -Path 'C:\Users' -Directory | Where-Object {
        $_.Name -notin $systemProfiles
    }
    
    if ($profileFolders.Count -eq 0) {
        Write-Success "No additional user profiles found"
    } else {
        foreach ($folder in $profileFolders) {
            try {
                # Remove profile registry entry
                $profileSid = (Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -eq $folder.FullName }).SID
                if ($profileSid) {
                    Remove-CimInstance -InputObject (Get-CimInstance Win32_UserProfile | Where-Object { $_.SID -eq $profileSid }) -ErrorAction SilentlyContinue
                    Write-Info "Removed registry entry for: $($folder.Name)"
                }
                
                # Force remove profile folder
                Remove-Item -Path $folder.FullName -Recurse -Force -ErrorAction Stop
                Write-Success "Removed profile folder: $($folder.FullName)"
            } catch {
                Write-Error "Failed to remove profile: $($folder.FullName) - $($_.Exception.Message)"
            }
        }
    }
}

function Remove-ProblematicApplications {
    Write-StepHeader "STEP 2: Remove Problematic Applications"
    Write-Info "This will silently uninstall applications that interfere with sysprep:"
    Write-Info "• Veeam applications (all variants)"
    Write-Info "• SnapAgent (Blackpoint Cyber)"
    Write-Info "• These applications cause sysprep failures and must be removed"
    
    if (-not (Confirm-Step "Continue with application removal?")) {
        Write-Warning "Skipped application removal"
        return
    }

    # Function to silently uninstall applications by name pattern
    function Uninstall-ApplicationByName {
        param([string]$AppNamePattern, [string]$DisplayName)
        
        try {
            # Get applications from both 32-bit and 64-bit registry
            $apps = @()
            $regPaths = @(
                'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
                'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
            )
            
            foreach ($regPath in $regPaths) {
                try {
                    $apps += Get-ItemProperty $regPath -ErrorAction SilentlyContinue | 
                        Where-Object { $_.DisplayName -like "*$AppNamePattern*" -and $_.UninstallString }
                } catch { }
            }
            
            if ($apps.Count -eq 0) {
                Write-Info "${DisplayName}: No installations found"
                return
            }
            
            Write-Info "${DisplayName}: Found $($apps.Count) installation(s)"
            
            foreach ($app in $apps) {
                try {
                    Write-Info "  Uninstalling: $($app.DisplayName)"
                    
                    $uninstallString = $app.UninstallString
                    if ($uninstallString -like "*msiexec*") {
                        # MSI installation - extract product code and use quiet uninstall
                        if ($uninstallString -match '\{[A-F0-9\-]+\}') {
                            $productCode = $matches[0]
                            $process = Start-Process -FilePath "msiexec.exe" -ArgumentList "/x", $productCode, "/quiet", "/norestart" -Wait -PassThru -WindowStyle Hidden
                            if ($process.ExitCode -eq 0) {
                                Write-Success "    Successfully uninstalled: $($app.DisplayName)"
                            } else {
                                Write-Warning "    Uninstall completed with exit code: $($process.ExitCode)"
                            }
                        }
                    } elseif ($uninstallString -like "*uninstall*" -or $uninstallString -like "*setup*") {
                        # Try to add silent switches for common installers
                        $silentArgs = @()
                        if ($uninstallString -like "*uninstall.exe*") {
                            $silentArgs = @("/S", "/silent", "/quiet")
                        } elseif ($uninstallString -like "*setup.exe*") {
                            $silentArgs = @("/S", "/silent", "/quiet", "/uninstall")
                        }
                        
                        # Parse executable and arguments
                        if ($uninstallString -match '^"([^"]+)"(.*)$') {
                            $executable = $matches[1]
                            $existingArgs = $matches[2].Trim()
                        } else {
                            $parts = $uninstallString.Split(' ', 2)
                            $executable = $parts[0]
                            $existingArgs = if ($parts.Length -gt 1) { $parts[1] } else { "" }
                        }
                        
                        # Combine existing args with silent args
                        $allArgs = @($existingArgs.Split(' ') + $silentArgs) | Where-Object { $_ -ne "" }
                        
                        if (Test-Path $executable) {
                            $process = Start-Process -FilePath $executable -ArgumentList $allArgs -Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
                            if ($process.ExitCode -eq 0) {
                                Write-Success "    Successfully uninstalled: $($app.DisplayName)"
                            } else {
                                Write-Warning "    Uninstall completed with exit code: $($process.ExitCode)"
                            }
                        } else {
                            Write-Warning "    Uninstaller not found: $executable"
                        }
                    } else {
                        Write-Warning "    Unsupported uninstall string format: $uninstallString"
                    }
                } catch {
                    Write-Error "    Failed to uninstall $($app.DisplayName): $($_.Exception.Message)"
                }
            }
        } catch {
            Write-Error "Failed to process ${DisplayName} applications: $($_.Exception.Message)"
        }
    }
    
    # Remove Veeam applications
    Uninstall-ApplicationByName -AppNamePattern "Veeam" -DisplayName "Veeam"
    
    # Remove SnapAgent (Blackpoint Cyber)
    Uninstall-ApplicationByName -AppNamePattern "SnapAgent" -DisplayName "SnapAgent (Blackpoint)"
    Uninstall-ApplicationByName -AppNamePattern "Blackpoint" -DisplayName "Blackpoint Cyber"
}

function Clear-AgentIdentityData {
    if ($SkipAgentCleanup) {
        Write-Warning "Skipping agent cleanup (parameter specified)"
        return
    }

    Write-StepHeader "STEP 3: Clear Agent Identity Data"
    Write-Info "This will clear identity data for:"
    Write-Info "• NinjaRMM (NodeId registry value and data folder)"
    Write-Info "• Veeam (registry keys and data folder)"
    Write-Info "• Services will be preserved"
    
    if (-not (Confirm-Step "Continue with agent cleanup?")) {
        Write-Warning "Skipped agent cleanup"
        return
    }

    # Remove NinjaRMM NodeId
    try {
        Remove-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\NinjaRMM LLC\NinjaRMMAgent\Agent' -Name 'NodeId' -Force -ErrorAction Stop
        Write-Success "Removed NinjaRMM NodeId registry value"
    } catch {
        Write-Info "NinjaRMM NodeId registry value not found or already removed"
    }
    
    # Remove Veeam registry key
    try {
        Remove-Item -Path 'HKLM:\SOFTWARE\Veeam' -Recurse -Force -ErrorAction Stop
        Write-Success "Removed Veeam registry key"
    } catch {
        Write-Info "Veeam registry key not found or already removed"
    }
    
    # Remove NinjaRMM folder
    try {
        Remove-Item -Path 'C:\ProgramData\NinjaRMMAgent' -Recurse -Force -ErrorAction Stop
        Write-Success "Removed NinjaRMM data folder"
    } catch {
        Write-Info "NinjaRMM data folder not found or already removed"
    }
    
    # Remove Veeam folder
    try {
        Remove-Item -Path 'C:\ProgramData\Veeam' -Recurse -Force -ErrorAction Stop
        Write-Success "Removed Veeam data folder"
    } catch {
        Write-Info "Veeam data folder not found or already removed"
    }
}

function Disable-BitLockerVolumes {
    Write-StepHeader "STEP 4: Disable BitLocker"
    Write-Info "This will disable BitLocker on all encrypted volumes"
    Write-Warning "BitLocker decryption may take time depending on drive size"
    
    if (-not (Confirm-Step "Continue with BitLocker disable?")) {
        Write-Warning "Skipped BitLocker disable"
        return
    }

    try {
        # Get all BitLocker volumes
        $bitlockerVolumes = Get-BitLockerVolume -ErrorAction SilentlyContinue
        
        if (-not $bitlockerVolumes) {
            Write-Success "No BitLocker volumes found"
            return
        }
        
        $encryptedVolumes = $bitlockerVolumes | Where-Object { 
            $_.VolumeStatus -eq 'FullyEncrypted' -or 
            $_.VolumeStatus -eq 'EncryptionInProgress' -or
            $_.VolumeStatus -eq 'DecryptionInProgress'
        }
        
        if ($encryptedVolumes.Count -eq 0) {
            Write-Success "No encrypted BitLocker volumes found"
            return
        }
        
        Write-Info "Found $($encryptedVolumes.Count) encrypted BitLocker volume(s):"
        foreach ($volume in $encryptedVolumes) {
            Write-Info "  • $($volume.MountPoint) - Status: $($volume.VolumeStatus)"
        }
        
        foreach ($volume in $encryptedVolumes) {
            try {
                Write-Info "Disabling BitLocker on volume: $($volume.MountPoint)"
                Disable-BitLocker -MountPoint $volume.MountPoint -ErrorAction Stop
                Write-Success "BitLocker disable initiated for: $($volume.MountPoint)"
            } catch {
                Write-Error "Failed to disable BitLocker on $($volume.MountPoint): $($_.Exception.Message)"
            }
        }
        
        # Check if any volumes are still decrypting
        $decryptingVolumes = Get-BitLockerVolume | Where-Object { 
            $_.VolumeStatus -eq 'DecryptionInProgress' 
        }
        
        if ($decryptingVolumes.Count -gt 0) {
            Write-Warning "BitLocker decryption is in progress on $($decryptingVolumes.Count) volume(s)"
            Write-Info "Decryption will continue in the background"
            Write-Info "You can check status with: Get-BitLockerVolume"
        }
        
    } catch {
        Write-Error "Failed to process BitLocker volumes: $($_.Exception.Message)"
        Write-Info "BitLocker management may not be available on this system"
    }
}

function Clear-WindowsLogs {
    if ($SkipLogCleanup) {
        Write-Warning "Skipping log cleanup (parameter specified)"
        return
    }

    Write-StepHeader "STEP 5: Clear Windows Logs"
    Write-Info "This will remove:"
    Write-Info "• Major Windows Event Logs (Application, System, Security)"
    Write-Info "• Panther setup logs"
    
    if (-not (Confirm-Step "Continue with log cleanup?")) {
        Write-Warning "Skipped log cleanup"
        return
    }

    # Clear Windows event logs using wevtutil
    Write-Info "Clearing Windows Event Logs..."
    $logsToClear = @('Application', 'Security', 'Setup', 'System')
    foreach ($log in $logsToClear) {
        try {
            Write-Info "  Clearing $log log..."
            wevtutil.exe cl $log /q:true
            Write-Success "    Cleared $log log"
        } catch {
            Write-Warning "  Could not clear $log log: $($_.Exception.Message)"
        }
    }
    
    # Remove Panther logs recursively
    Write-Info "Clearing Panther logs..."
    try {
        $pantherPath = "$env:SystemRoot\Panther"
        if (Test-Path $pantherPath) {
            Remove-Item -Path "$pantherPath\*" -Recurse -Force -ErrorAction Stop
            Write-Success "Removed Panther logs"
        } else {
            Write-Info "Panther directory does not exist."
        }
    } catch {
        Write-Error "Failed to remove Panther logs: $($_.Exception.Message)"
    }
}

function Create-UnattendXml {
    Write-StepHeader "STEP 6: Create unattend.xml"
    $unattendPath = 'C:\Windows\System32\Sysprep\unattend.xml'
    
    Write-Info "Creating a new, validated unattend.xml at: $unattendPath"
    
    if (-not (Confirm-Step "Continue with unattend.xml creation?")) {
        Write-Warning "Skipped unattend.xml creation"
        return $false
    }

    $unattendContent = @'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
    xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <settings pass="specialize">
        <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
            <UserAccounts>
                <LocalAccounts>
                    <LocalAccount wcm:action="add">
                        <Name>installadmin</Name>
                        <Group>Administrators</Group>
                        <Password>
                            <Value>DTC@dental2025</Value>
                            <PlainText>true</PlainText>
                        </Password>
                    </LocalAccount>
                </LocalAccounts>
            </UserAccounts>
            <AutoLogon>
                <Enabled>true</Enabled>
                <Username>installadmin</Username>
                <Password>
                    <Value>DTC@dental2025</Value>
                    <PlainText>true</PlainText>
                </Password>
            </AutoLogon>
        </component>
    </settings>
    <settings pass="oobeSystem">
        <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
            <OOBE>
                <HideEULAPage>true</HideEULAPage>
                <HideLocalAccountScreen>true</HideLocalAccountScreen>
                <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
                <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
                <ProtectYourPC>1</ProtectYourPC>
                <SkipMachineOOBE>true</SkipMachineOOBE>
            </OOBE>
        </component>
    </settings>
</unattend>
'@

    try {
        $unattendContent | Set-Content -Path $unattendPath -Encoding UTF8 -ErrorAction Stop
        Write-Success "unattend.xml created successfully"
        return $true
    } catch {
        Write-Error "Failed to create unattend.xml: $($_.Exception.Message)"
        return $false
    }
}

function Get-AppxBlockers {
    $logPath = 'C:\Windows\System32\Sysprep\Panther\setuperr.log'
    
    if (!(Test-Path $logPath)) {
        return @()
    }
    
    try {
        $content = Get-Content $logPath -Raw -ErrorAction Stop
        
        # Match the actual sysprep error format for AppxPackages
        # Example: "SYSPRP Package Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe was installed for a user, but not provisioned for all users"
        $matches = [regex]::Matches($content, 'SYSPRP Package ([^\s]+) was installed for a user, but not provisioned for all users')
        
        $blockers = @()
        foreach ($match in $matches) {
            $packageFullName = $match.Groups[1].Value
            
            # Store both the full name and the base name for better removal chances
            $blockers += $packageFullName
            
            # Also try to extract just the base package name for broader matching
            # Example: Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe -> Microsoft.WidgetsPlatformRuntime
            if ($packageFullName -match '^([^_]+)') {
                $baseName = $Matches[1]
                if ($baseName -ne $packageFullName) {
                    $blockers += $baseName
                }
            }
        }
        
        # Also check for the older error format as fallback
        $legacyMatches = [regex]::Matches($content, 'Package (.*?) cannot be removed and is preventing Sysprep')
        foreach ($match in $legacyMatches) {
            $blockers += $match.Groups[1].Value
        }
        
        return ($blockers | Select-Object -Unique)
    } catch {
        Write-Warning "Could not read or parse Sysprep error log: $($_.Exception.Message)"
        return @()
    }
}

function Remove-AppxBlockers {
    param([string[]]$Blockers)
    
    Write-Info "Attempting to remove $($Blockers.Count) blocking AppxPackages..."
    
    $totalRemoved = 0
    foreach ($blocker in $Blockers) {
        $removedInLoop = $false
        try {
            # Try removing the provisioned package(s) first
            $provisionedPackages = Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like "*$blocker*" }
            if ($provisionedPackages) {
                foreach ($package in $provisionedPackages) {
                    Write-Info "  Removing provisioned package: $($package.DisplayName)"
                    Remove-AppxProvisionedPackage -Online -PackageName $package.PackageName -ErrorAction Stop
                    Write-Success "    Removed provisioned package: $($package.DisplayName)"
                    $removedInLoop = $true
                }
            }
            
            # Try removing the user-specific package(s)
            $appPackages = Get-AppxPackage -AllUsers | Where-Object { $_.Name -like "*$blocker*" -or $_.PackageFullName -like "*$blocker*" }
            if ($appPackages) {
                foreach ($package in $appPackages) {
                    Write-Info "  Removing AppxPackage: $($package.Name)"
                    Remove-AppxPackage -Package $package.PackageFullName -AllUsers -ErrorAction Stop
                    Write-Success "    Removed AppxPackage: $($package.Name)"
                    $removedInLoop = $true
                }
            }
            
            if ($removedInLoop) {
                $totalRemoved++
            } else {
                Write-Warning "  No installed or provisioned package found for blocker: '$blocker'"
            }
        } catch {
            Write-Error "  Failed during removal for blocker '$blocker': $($_.Exception.Message)"
        }
    }
    
    Write-Info "Total packages removed in this pass: $totalRemoved"
    return $totalRemoved
}

function Run-SysprepLoop {
    Write-StepHeader "STEP 7: Run Sysprep Generalization"
    Write-Info "This will run sysprep and handle AppxPackage errors in a loop."
    
    if (-not (Test-Path 'C:\Windows\System32\Sysprep\unattend.xml')) {
        Write-Warning "unattend.xml not found. Sysprep will run without it."
        if (-not (Confirm-Step "Continue without unattend.xml?")) {
            Write-Error "Sysprep aborted."
            return
        }
    }
    
    $sysprepPath = "C:\Windows\System32\Sysprep\sysprep.exe"
    $maxAttempts = 10
    $attempt = 1
    
    while ($attempt -le $maxAttempts) {
        Write-Info "---"
        Write-Info "Sysprep Attempt #$attempt"
        Write-Info "---"
        
        Clear-SysprepLogs
        
        $arguments = @(
            "/generalize",
            "/oobe",
            "/shutdown",
            "/unattend:C:\Windows\System32\Sysprep\unattend.xml"
        )
        
        try {
            # Sysprep should shut down the machine on success. If the process exits and
            # the script continues, it means it failed.
            Write-Info "Executing Sysprep... The system should shut down on success."
            Start-Process -FilePath $sysprepPath -ArgumentList $arguments -Wait -PassThru -ErrorAction Stop
            
            # If we get here, Sysprep failed because a successful run would have shut down the PC.
            Write-Warning "Sysprep process completed without shutting down, indicating a failure."
            # Give logs a moment to be written
            Start-Sleep -Seconds 5

        } catch {
            Write-Error "Failed to execute Sysprep: $($_.Exception.Message)"
        }

        # If we are here, sysprep likely failed. Check for blockers.
        $blockers = Get-AppxBlockers
        
        if ($blockers.Count -eq 0) {
            Write-Error "Sysprep failed, but no AppxPackage blockers were found in the logs."
            Write-Info "Please check the logs manually for other errors: C:\Windows\System32\Sysprep\Panther\setuperr.log"
            break
        }
        
        Write-Warning "Sysprep failed. Found $($blockers.Count) potential blockers."
        
        $removedCount = Remove-AppxBlockers -Blockers $blockers
        if ($removedCount -eq 0) {
            Write-Error "Could not remove any of the blocking packages. Manual intervention required."
            break
        }
        
        $attempt++
        if ($attempt -gt $maxAttempts) {
            Write-Error "Reached max attempts. Sysprep failed."
        } else {
            Write-Info "Packages removed. Preparing to retry."
            if (-not (Confirm-Step "Ready to retry sysprep?")) {
                Write-Error "Sysprep retry aborted by user."
                break
            }
            Write-Info "Retrying sysprep..."
        }
    }
}

# Main execution
function Main {
    Write-StepHeader "Windows Image Preparation Script"
    Write-Info "This script will prepare a Windows image for generalization"
    Write-Info "Run as Administrator in the VM you want to generalize"
    Write-Warning "Create a VM checkpoint/snapshot before running!"
    
    if (-not (Confirm-Step "Continue with image preparation?")) {
        Write-Warning "Image preparation cancelled"
        exit 0
    }
    
    # Check if running as administrator
    $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
    if (-not $isAdmin) {
        Write-Error "This script must be run as Administrator"
        exit 1
    }
    
    try {
        # Execute all steps
        Remove-UserAccountsAndProfiles
        Remove-ProblematicApplications
        Clear-AgentIdentityData
        Disable-BitLockerVolumes
        Clear-WindowsLogs
        
        if (Create-UnattendXml) {
            Run-SysprepLoop
        } else {
            Write-Error "Cannot proceed without unattend.xml"
            exit 1
        }
        
    } catch {
        Write-Error "Script execution failed: $($_.Exception.Message)"
        exit 1
    }
}

# Run main function
Main 

7. ✅ Final Checklist

  • Booted offline on hypervisor
  • Converted to GPT with valid partitions
  • Optional: user profiles backed up and deleted
  • VM Checkpoint Created
  • Agent identities reset (binaries remain)
  • Problematic packages removed based on setuperr.log
  • unattend.xml disables OOBE + telemetry
  • Sysprep executed successfully and VM is shut down.