Files
WindowsServer/Resize-OSPartition/Resize-OSPartition.ps1
T

1701 lines
76 KiB
PowerShell

#Requires -Version 5.1
<#
.SYNOPSIS
Safely (re)creates the Recovery Partition (WinRE) at the end of the
system disk and, on request, expands C: into the remaining space.
.DESCRIPTION
Multi-phase, highly defensive script:
PHASE 1 - DISCOVERY (read-only)
PHASE 2 - VALIDATION (read-only)
PHASE 3 - USER CONFIRMATION
PHASE 4 - DESTRUCTIVE OPERATION (remove old Recovery, if present)
PHASE 5 - REBUILD (create new Recovery FIRST at the TRUE end
of the disk using an explicit -Offset,
then optionally expand C: into whatever
remains, THEN enable WinRE)
PHASE 6 - VALIDATION (final audit)
PHASE 7 - FINAL REPORT
v2.1.0 CHANGES (bugfix release):
- New-RecoveryPartition now computes an EXPLICIT end-of-disk -Offset
(disk.Size - buffer - RecoverySizeMB) instead of letting
New-Partition auto-place it. Auto-placement (no -Offset) puts a
new partition at the START of the first available free extent -
NOT the end. Since the free extent runs from the end of C: to the
end of the disk, this glued Recovery directly onto C: and pushed
ALL remaining free space past Recovery, to the true end of the
disk - the opposite of the intended design, and it also meant the
gap-to-expand-C-into was always computed as ~0 MB, so the script
never asked to expand C:.
- The interactive "expand C: into remaining space?" question is now
asked BEFORE reagentc /enable, not after. Recovery is created at
the true end of the disk (so there can be a gap between C: and
Recovery at that point); if adjacency between C: and Recovery
really is required for reagentc to accept the partition (per this
script's own prior testing notes), closing that gap has to happen
before WinRE is enabled, not after.
- The old "blocking partition directly after C:" hard-abort guard
(which assumed Recovery must always sit immediately after C:) was
relaxed to an informational check, since Recovery is no longer
required to be placed immediately after C: at creation time.
An existing Recovery Partition is OPTIONAL on input. If none is found,
the script treats this as a fresh-build scenario (e.g. after a manual
diskpart shrink) instead of failing.
The script NEVER assumes Disk 0, Partition 4, or any fixed size. Every
disk/partition reference is re-detected dynamically and re-validated
immediately before any destructive action.
.PARAMETER AuditOnly
Performs full discovery + validation, prints the plan, but makes NO
changes to the system.
.PARAMETER RecoverySizeMB
Target size (MB) of the new Recovery Partition. Default 2000. NOTE:
1024 MB is NOT reliably enough on modern Windows 11 - reagentc /enable
silently falls back to storing WinRE inside C: itself (instead of using
the Recovery partition) if the partition is too small for the current
Winre.wim plus its own required working-space buffer, even though the
partition otherwise looks perfectly valid. The script also checks the
actual current Winre.wim size at runtime and warns if this value is
too small for it.
.PARAMETER DiskEndBufferMB
Small safety buffer (MB) left truly unallocated at the very end of the
disk, to avoid colliding with the GPT backup header / alignment
rounding. Default 1.
.PARAMETER LogPath
Directory for the log file. Default C:\Temp.
.PARAMETER SkipBanner
Suppresses the ASCII banner.
.EXAMPLE
.\WinRE-Resize2.ps1 -AuditOnly
.EXAMPLE
.\WinRE-Resize2.ps1 -RecoverySizeMB 1024
.NOTES
Author : F.Š. | Windows Recovery Partition Tool
Version: 2.1.0
#>
[CmdletBinding()]
param(
[switch]$AuditOnly,
[int]$RecoverySizeMB = 2000,
[int]$DiskEndBufferMB = 1,
[string]$LogPath = "C:\Temp",
[switch]$SkipBanner
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ============================================================================
# GLOBAL STATE
# ============================================================================
$Script:ToolVersion = "2.1.0"
$Script:LogFile = $null
$Script:TranscriptFile = $null
$Script:CurrentStep = 0
$Script:TotalSteps = 14
$Script:RecoveryGptType = "de94bba4-06d1-4d40-a16a-bfd50179d6ac"
$Script:RecoveryMbrType = 27
$Script:RecoveryGptAttrs = "0x8000000000000001"
$Script:BitLockerSuspended = $false # true while WE hold BitLocker protection suspended
# ============================================================================
# FUNCTION: Write-Log
# ============================================================================
function Write-Log {
param(
[Parameter(Mandatory)][string]$Message,
[ValidateSet('INFO','WARNING','ERROR','SUCCESS','DEBUG')][string]$Level = 'INFO'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$hostName = $env:COMPUTERNAME
$line = "[$timestamp] [$Level] [$hostName] $Message"
if ($Script:LogFile) {
try { Add-Content -Path $Script:LogFile -Value $line -ErrorAction Stop }
catch { Write-Host "LOG WRITE FAILED: $_" -ForegroundColor Red }
}
switch ($Level) {
'WARNING' { Write-Host $line -ForegroundColor Yellow }
'ERROR' { Write-Host $line -ForegroundColor Red }
'SUCCESS' { Write-Host $line -ForegroundColor Green }
'DEBUG' { Write-Host $line -ForegroundColor DarkGray }
default { Write-Host $line }
}
}
# ============================================================================
# FUNCTION: Write-Banner
# ============================================================================
function Write-Banner {
if ($SkipBanner) { return }
$banner = @"
███████╗███████╗
██╔════╝██╔════╝
█████╗ ███████╗
██╔══╝ ╚════██║
██║ ███████║
╚═╝ ╚══════╝
F.Š. | Windows Recovery Partition Tool
"@
Write-Host $banner -ForegroundColor Cyan
Write-Host "Version : $Script:ToolVersion"
Write-Host "Computer : $env:COMPUTERNAME"
Write-Host "User : $env:USERDOMAIN\$env:USERNAME"
Write-Host "Started : $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')"
Write-Host "PS Ver : $($PSVersionTable.PSVersion.ToString())"
Write-Host "OS : $((Get-CimInstance Win32_OperatingSystem).Caption)"
if ($AuditOnly) {
Write-Host "MODE : AUDIT ONLY - no changes will be made" -ForegroundColor Yellow
}
Write-Host ("=" * 60)
Write-Host ""
}
# ============================================================================
# FUNCTION: Write-Step
# ============================================================================
function Write-Step {
param(
[Parameter(Mandatory)][string]$Description,
[ValidateSet('START','OK','FAIL','SKIP')][string]$Status = 'START'
)
if ($Status -eq 'START') {
$Script:CurrentStep++
$prefix = "[{0}/{1}]" -f $Script:CurrentStep, $Script:TotalSteps
Write-Host ("{0} {1}" -f $prefix, $Description.PadRight(45, '.')) -NoNewline
Write-Log -Message "STEP START: $Description" -Level 'INFO'
}
else {
$color = switch ($Status) { 'OK' {'Green'} 'FAIL' {'Red'} 'SKIP' {'Yellow'} }
Write-Host " [$Status]" -ForegroundColor $color
Write-Log -Message "STEP ${Status}: $Description" -Level $(if ($Status -eq 'FAIL') {'ERROR'} else {'INFO'})
}
}
# ============================================================================
# FUNCTION: Stop-Safely
# ============================================================================
function Stop-Safely {
param(
[string]$Reason = "User requested termination",
[int]$ExitCode = 1
)
if ($Script:BitLockerSuspended) {
Write-Log -Message "Aborting with BitLocker still suspended - resuming protection before exit." -Level 'WARNING'
Resume-BitLockerProtection -MountPoint 'C:'
$Script:BitLockerSuspended = $false
}
Write-Log -Message "SCRIPT TERMINATED: $Reason" -Level 'ERROR'
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "SCRIPT STOPPED" -ForegroundColor Red
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "Reason : $Reason" -ForegroundColor Red
if ($Script:LogFile) { Write-Host "Log : $Script:LogFile" -ForegroundColor Red }
Write-Host ("=" * 60) -ForegroundColor Red
try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {}
exit $ExitCode
}
# ============================================================================
# FUNCTION: Test-IsAdministrator
# ============================================================================
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# ============================================================================
# FUNCTION: Confirm-YesNo (generic Y/N/Q prompt helper)
# ============================================================================
function Confirm-YesNo {
param(
[Parameter(Mandatory)][string]$Prompt
)
while ($true) {
Write-Host ""
Write-Host $Prompt
Write-Host "[Y] Yes [N] No [Q] Quit" -ForegroundColor Cyan
$answer = Read-Host "Your choice"
switch ($answer.Trim().ToUpper()) {
'Y' { return $true }
'N' { return $false }
'Q' { Stop-Safely -Reason "User aborted at confirmation prompt" }
default { Write-Host "Invalid input, please type Y, N or Q." -ForegroundColor Yellow }
}
}
}
# ============================================================================
# FUNCTION: Invoke-NativeCommand
# Runs an external .exe and captures stdout+stderr WITHOUT letting
# $ErrorActionPreference = 'Stop' turn stderr lines into terminating
# errors.
# ============================================================================
function Invoke-NativeCommand {
param(
[Parameter(Mandatory)][string]$FilePath,
[string[]]$ArgumentList = @()
)
$previousEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$global:LASTEXITCODE = 0
try {
if ($ArgumentList.Count -gt 0) {
$rawOutput = & $FilePath @ArgumentList 2>&1
}
else {
$rawOutput = & $FilePath 2>&1
}
}
finally {
$ErrorActionPreference = $previousEAP
}
$lines = @($rawOutput | ForEach-Object { $_.ToString() })
return [PSCustomObject]@{
Output = $lines
ExitCode = $LASTEXITCODE
}
}
# ============================================================================
# FUNCTION: Get-SystemDisk
# ============================================================================
function Get-SystemDisk {
Write-Log -Message "Detecting system disk hosting C:\" -Level 'INFO'
$cPartition = Get-Partition -DriveLetter C -ErrorAction Stop
$disk = Get-Disk -Number $cPartition.DiskNumber -ErrorAction Stop
Write-Log -Message "System disk detected: Disk $($disk.Number) ($($disk.FriendlyName))" -Level 'SUCCESS'
Write-Host ""
Write-Host "SYSTEM DISK" -ForegroundColor Cyan
Write-Host "Disk Number : $($disk.Number)"
Write-Host "Model : $($disk.FriendlyName)"
Write-Host "Serial Number : $($disk.SerialNumber)"
Write-Host "Bus Type : $($disk.BusType)"
Write-Host "Partition Style : $($disk.PartitionStyle)"
Write-Host "Size : $([math]::Round($disk.Size/1GB,2)) GB"
Write-Host "Operational Status : $($disk.OperationalStatus)"
Write-Host "Health Status : $($disk.HealthStatus)"
return [PSCustomObject]@{
Disk = $disk
CPartition = $cPartition
}
}
# ============================================================================
# FUNCTION: Get-DiskLayout
# ============================================================================
function Get-DiskLayout {
param([Parameter(Mandatory)][int]$DiskNumber)
$partitions = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset
Write-Host ""
Write-Host "CURRENT PARTITION LAYOUT (Disk $DiskNumber)" -ForegroundColor Cyan
Write-Host ("{0,-6} {1,-12} {2,-10} {3,-16} {4,-8} {5}" -f "Part#","Type","SizeMB","Offset","Drive","GptType")
foreach ($p in $partitions) {
$gptType = $null
try { $gptType = $p.GptType } catch { $gptType = "n/a" }
Write-Host ("{0,-6} {1,-12} {2,-10} {3,-16} {4,-8} {5}" -f `
$p.PartitionNumber, $p.Type, [math]::Round($p.Size/1MB,0), $p.Offset, `
($(if ($p.DriveLetter) { $p.DriveLetter } else { '-' })), $gptType)
}
return $partitions
}
# ============================================================================
# FUNCTION: Get-TrailingFreeSpaceMB
# ============================================================================
function Get-TrailingFreeSpaceMB {
param([Parameter(Mandatory)][int]$DiskNumber)
$disk = Get-Disk -Number $DiskNumber
$freeBytes = $disk.Size - $disk.AllocatedSize
if ($freeBytes -lt 0) { $freeBytes = 0 }
return [math]::Floor($freeBytes / 1MB)
}
# ============================================================================
# FUNCTION: Get-WinREStatus
# ============================================================================
function Get-WinREStatus {
Write-Log -Message "Querying reagentc /info" -Level 'INFO'
$result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/info')
$raw = $result.Output
Write-Log -Message "reagentc /info output (exit $($result.ExitCode)): `n$($raw -join "`n")" -Level 'DEBUG'
$statusLine = $raw | Where-Object { $_ -match 'Windows RE status' }
$locationLine = $raw | Where-Object { $_ -match 'Windows RE location' }
$status = if ($statusLine -match ':\s*(.+)$') { $Matches[1].Trim() } else { 'Unknown' }
$location = if ($locationLine -match 'harddisk(\d+)\\partition(\d+)') {
[PSCustomObject]@{ DiskNumber = [int]$Matches[1]; PartitionNumber = [int]$Matches[2] }
} else { $null }
return [PSCustomObject]@{
Status = $status
Location = $location
RawOutput = $raw
}
}
# ============================================================================
# FUNCTION: Get-RecoveryPartition
# ============================================================================
function Get-RecoveryPartition {
param(
[Parameter(Mandatory)]$Disk,
[Parameter(Mandatory)]$Partitions,
[Parameter(Mandatory)]$CPartition,
[Parameter(Mandatory)]$WinREStatus
)
Write-Log -Message "Detecting Recovery Partition candidates" -Level 'INFO'
$candidates = @()
foreach ($p in $Partitions) {
if ($p.PartitionNumber -eq $CPartition.PartitionNumber) { continue }
$isCandidate = $false
$typeMatches = $false
$reasons = @()
if ($Disk.PartitionStyle -eq 'GPT') {
try {
if ($p.GptType -and ($p.GptType.Trim('{}').ToLower() -eq $Script:RecoveryGptType)) {
$typeMatches = $true
}
} catch {}
}
elseif ($Disk.PartitionStyle -eq 'MBR') {
try {
if ($p.MbrType -eq $Script:RecoveryMbrType) {
$typeMatches = $true
}
} catch {}
}
if ($typeMatches) {
$isCandidate = $true
$reasons += if ($Disk.PartitionStyle -eq 'GPT') { "GptType matches Recovery GUID" } else { "MbrType = 27" }
if ($p.DriveLetter) {
$reasons += "WARNING: has drive letter $($p.DriveLetter): - unusual for a genuine Recovery partition, verify manually"
} else {
$reasons += "No drive letter"
}
}
if ($p.Offset -gt $CPartition.Offset) { $reasons += "Located after C:" }
if ($WinREStatus.Location -and $WinREStatus.Location.PartitionNumber -eq $p.PartitionNumber) {
$isCandidate = $true
$reasons += "Matches reagentc /info WinRE location"
}
if (-not $isCandidate -and -not $p.DriveLetter -and $p.Offset -ge ($CPartition.Offset + $CPartition.Size) -and ($p.Offset - ($CPartition.Offset + $CPartition.Size)) -lt 1MB -and $p.Size -le 4096MB) {
$isCandidate = $true
$reasons += "UNRECOGNIZED TYPE but immediately after C: with no gap and no drive letter - likely an orphaned Recovery-ish partition"
}
if ($isCandidate) {
$candidates += [PSCustomObject]@{
Partition = $p
Reasons = $reasons
}
}
}
if ($candidates.Count -eq 0) {
Write-Log -Message "No existing Recovery Partition detected on this disk." -Level 'INFO'
return $null
}
if ($candidates.Count -gt 1) {
Write-Log -Message "Multiple Recovery Partition candidates found ($($candidates.Count)) - cannot auto-select" -Level 'ERROR'
Write-Host ""
Write-Host "AMBIGUOUS: multiple possible Recovery partitions detected:" -ForegroundColor Red
foreach ($c in $candidates) {
Write-Host " - Partition $($c.Partition.PartitionNumber): $($c.Reasons -join '; ')"
}
return $null
}
$winner = $candidates[0]
Write-Log -Message "Recovery Partition identified: Partition $($winner.Partition.PartitionNumber) [$($winner.Reasons -join '; ')]" -Level 'SUCCESS'
return $winner.Partition
}
# ============================================================================
# FUNCTION: Test-DiskLayout
# ============================================================================
function Test-DiskLayout {
param(
[Parameter(Mandatory)]$Partitions,
[Parameter(Mandatory)]$CPartition,
[Parameter(Mandatory)]$RecoveryPartition
)
$sorted = $Partitions | Sort-Object Offset
$cIndex = [array]::IndexOf($sorted.PartitionNumber, $CPartition.PartitionNumber)
$rIndex = [array]::IndexOf($sorted.PartitionNumber, $RecoveryPartition.PartitionNumber)
if ($rIndex -ne ($cIndex + 1)) {
Write-Log -Message "Recovery partition is NOT directly behind C: (other partitions in between)" -Level 'ERROR'
return $false
}
Write-Log -Message "Disk layout validated: Recovery partition is immediately behind C:" -Level 'SUCCESS'
return $true
}
# ============================================================================
# FUNCTION: Confirm-RecoveryPartition
# ============================================================================
function Confirm-RecoveryPartition {
param(
[Parameter(Mandatory)][int]$DiskNumber,
[Parameter(Mandatory)]$RecoveryPartition,
[Parameter(Mandatory)]$CPartition
)
$gptType = $null
try { $gptType = $RecoveryPartition.GptType } catch { $gptType = "n/a" }
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "RECOVERY PARTITION DETECTION" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "The script believes the following partition is WinRE:"
Write-Host ""
Write-Host "Disk Number : $DiskNumber"
Write-Host "Partition Number : $($RecoveryPartition.PartitionNumber)"
Write-Host "Size : $([math]::Round($RecoveryPartition.Size/1MB,0)) MB"
Write-Host "Offset : $($RecoveryPartition.Offset)"
Write-Host "Type : $($RecoveryPartition.Type)"
Write-Host "GPT Type : $gptType"
Write-Host "Drive Letter : $(if ($RecoveryPartition.DriveLetter) { $RecoveryPartition.DriveLetter } else { 'None' })"
Write-Host "Position : $(if ($RecoveryPartition.Offset -gt $CPartition.Offset) { 'After C:' } else { 'Before C:' })"
return Confirm-YesNo -Prompt "Is this the CORRECT Recovery Partition (to be deleted and recreated)?"
}
# ============================================================================
# FUNCTION: Confirm-Snapshot
# ============================================================================
function Confirm-Snapshot {
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Yellow
Write-Host "IMPORTANT - VM SNAPSHOT / CHECKPOINT" -ForegroundColor Yellow
Write-Host ("=" * 60) -ForegroundColor Yellow
Write-Host @"
Before continuing, verify that this VM does NOT have an
active snapshot (VMware) or checkpoint (Hyper-V).
This script cannot guarantee that VMware snapshots or
Hyper-V checkpoints are correctly detected from inside
Windows.
Recommended:
- Verify VMware snapshot / Hyper-V checkpoint state manually.
- Verify current backup exists and is valid.
"@
Write-Host ("=" * 60) -ForegroundColor Yellow
return Confirm-YesNo -Prompt "Have you verified that the VM has no active snapshot/checkpoint (VMware or Hyper-V)?"
}
# ============================================================================
# FUNCTION: Confirm-Backup
# ============================================================================
function Confirm-Backup {
return Confirm-YesNo -Prompt "Have you verified that a recent successful backup exists?"
}
# ============================================================================
# FUNCTION: Get-BitLockerInfo
# ============================================================================
function Get-BitLockerInfo {
param([string]$MountPoint = 'C:')
try {
return Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop
}
catch {
Write-Log -Message "Could not query BitLocker status for ${MountPoint}: $_" -Level 'DEBUG'
return $null
}
}
# ============================================================================
# FUNCTION: Suspend-BitLockerProtection
# ============================================================================
function Suspend-BitLockerProtection {
param([string]$MountPoint = 'C:')
$info = Get-BitLockerInfo -MountPoint $MountPoint
if (-not $info -or $info.VolumeStatus -eq 'FullyDecrypted') {
return $false
}
if ($info.ProtectionStatus -ne 'On') {
Write-Log -Message "BitLocker on $MountPoint is encrypted but protection is already Off - nothing to suspend." -Level 'INFO'
return $false
}
Write-Log -Message "Suspending BitLocker protection on $MountPoint for 1 restart (required before reagentc/diskpart touch the boot config)." -Level 'WARNING'
try {
Suspend-BitLocker -MountPoint $MountPoint -RebootCount 1 -ErrorAction Stop | Out-Null
}
catch {
Write-Log -Message "Failed to suspend BitLocker on ${MountPoint}: $_" -Level 'ERROR'
return $false
}
Write-Log -Message "BitLocker protection suspended on $MountPoint." -Level 'SUCCESS'
return $true
}
# ============================================================================
# FUNCTION: Resume-BitLockerProtection
# ============================================================================
function Resume-BitLockerProtection {
param([string]$MountPoint = 'C:')
Write-Log -Message "Resuming BitLocker protection on $MountPoint." -Level 'INFO'
try {
Resume-BitLocker -MountPoint $MountPoint -ErrorAction Stop | Out-Null
Write-Log -Message "BitLocker protection resumed on $MountPoint." -Level 'SUCCESS'
}
catch {
Write-Log -Message "FAILED to resume BitLocker protection on ${MountPoint}: $_. Resume it manually: Resume-BitLocker -MountPoint $MountPoint (or 'manage-bde -protectors -enable $MountPoint')." -Level 'ERROR'
}
}
# ============================================================================
# FUNCTION: Wait-BitLockerDecryption
# ============================================================================
function Wait-BitLockerDecryption {
param([string]$MountPoint = 'C:')
$bitlocker = $null
while ($true) {
try {
$bitlocker = Get-BitLockerVolume -MountPoint $MountPoint -ErrorAction Stop
}
catch {
Write-Log -Message "Could not query BitLocker status for ${MountPoint} while waiting for decryption: $_" -Level 'ERROR'
break
}
Clear-Host
Write-Host "BitLocker decryption progress" -ForegroundColor Cyan
Write-Host "--------------------------------"
Write-Host "Volume : $($bitlocker.MountPoint)"
Write-Host "Status : $($bitlocker.VolumeStatus)"
Write-Host "Progress : $($bitlocker.EncryptionPercentage)%"
Write-Host "Protection : $($bitlocker.ProtectionStatus)"
Write-Host ""
Write-Host "Last check : $(Get-Date)"
if ($bitlocker.VolumeStatus -ne 'DecryptionInProgress') {
Write-Host ""
Write-Host "Decryption is no longer in progress." -ForegroundColor Green
break
}
Start-Sleep -Seconds 5
}
return $bitlocker
}
# ============================================================================
# FUNCTION: Invoke-BitLockerDecryptWorkaround
# ============================================================================
function Invoke-BitLockerDecryptWorkaround {
param([string]$MountPoint = 'C:')
$before = Get-BitLockerInfo -MountPoint $MountPoint
if (-not $before -or $before.VolumeStatus -eq 'FullyDecrypted') {
return [PSCustomObject]@{ Success = $true; ProtectorTypes = @() }
}
$protectorTypes = @($before.KeyProtector | ForEach-Object { $_.KeyProtectorType })
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "BITLOCKER FULL DECRYPT WORKAROUND" -ForegroundColor Red
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host @"
reagentc.exe refuses to install WinRE while $MountPoint carries
ANY BitLocker encryption - confirmed via ReAgent.log: it falls
back to targeting the OS partition itself and then rejects that
purely because of BitLocker, even with protection suspended/off.
The only known workaround is to FULLY decrypt $MountPoint, enable
WinRE, then re-encrypt it again. The disk will be UNENCRYPTED for
the entire decrypt + re-encrypt duration.
Protectors currently on ${MountPoint}: $($protectorTypes -join ', ')
This script can only restore a plain TPM and/or recovery-password
protector afterwards - NOT any PIN/startup-key/AD-Entra escrow
setup you may have. You are responsible for reconfiguring that
and re-escrowing any new recovery key per your organization policy.
"@
Write-Host ("=" * 60) -ForegroundColor Red
if (-not (Confirm-YesNo -Prompt "Fully decrypt $MountPoint now so WinRE can be enabled, then re-encrypt it afterwards?")) {
return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes }
}
Write-Log -Message "Disabling BitLocker (full decrypt) on $MountPoint. Protectors before: $($protectorTypes -join ', ')" -Level 'WARNING'
try {
Disable-BitLocker -MountPoint $MountPoint -ErrorAction Stop | Out-Null
}
catch {
Write-Log -Message "Disable-BitLocker failed: $_" -Level 'ERROR'
return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes }
}
$final = Wait-BitLockerDecryption -MountPoint $MountPoint
if (-not $final -or $final.VolumeStatus -ne 'FullyDecrypted') {
$endStatus = if ($final) { $final.VolumeStatus } else { 'Unknown' }
Write-Log -Message "$MountPoint did not reach FullyDecrypted (ended at $endStatus) - aborting workaround." -Level 'ERROR'
return [PSCustomObject]@{ Success = $false; ProtectorTypes = $protectorTypes }
}
Write-Log -Message "$MountPoint is now fully decrypted." -Level 'SUCCESS'
return [PSCustomObject]@{ Success = $true; ProtectorTypes = $protectorTypes }
}
# ============================================================================
# FUNCTION: Invoke-BitLockerReencrypt
# ============================================================================
function Invoke-BitLockerReencrypt {
param(
[string]$MountPoint = 'C:',
[string[]]$OriginalProtectorTypes = @()
)
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Yellow
Write-Host "RE-ENCRYPTING $MountPoint" -ForegroundColor Yellow
Write-Host ("=" * 60) -ForegroundColor Yellow
try {
if ($OriginalProtectorTypes -contains 'Tpm') {
Write-Log -Message "Re-enabling BitLocker on $MountPoint with a TPM protector (matches what was there before)." -Level 'INFO'
Enable-BitLocker -MountPoint $MountPoint -TpmProtector -SkipHardwareTest -ErrorAction Stop | Out-Null
}
else {
Write-Log -Message "Re-enabling BitLocker on $MountPoint with a recovery-password protector (no TPM protector was present before)." -Level 'INFO'
Enable-BitLocker -MountPoint $MountPoint -RecoveryPasswordProtector -SkipHardwareTest -ErrorAction Stop | Out-Null
}
$info = Get-BitLockerInfo -MountPoint $MountPoint
if (-not ($info.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' })) {
Add-BitLockerKeyProtector -MountPoint $MountPoint -RecoveryPasswordProtector -ErrorAction Stop | Out-Null
$info = Get-BitLockerInfo -MountPoint $MountPoint
}
}
catch {
Write-Log -Message "Re-enabling BitLocker on $MountPoint failed: $_. You MUST re-encrypt $MountPoint manually." -Level 'ERROR'
return $false
}
$rp = $info.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1
if ($rp) {
Write-Host ""
Write-Host "!!! NEW BITLOCKER RECOVERY PASSWORD - RECORD THIS NOW !!!" -ForegroundColor Red
Write-Host "Key protector ID : $($rp.KeyProtectorId)" -ForegroundColor Red
Write-Host "Recovery password : $($rp.RecoveryPassword)" -ForegroundColor Red
Write-Log -Message "New BitLocker recovery password on ${MountPoint}: ID $($rp.KeyProtectorId) = $($rp.RecoveryPassword)" -Level 'WARNING'
}
Write-Host ""
Write-Host "Encryption has (re)started in the background on $MountPoint." -ForegroundColor Yellow
if ($OriginalProtectorTypes -and ($OriginalProtectorTypes | Where-Object { $_ -ne 'Tpm' -and $_ -ne 'RecoveryPassword' })) {
Write-Host "Original protectors also included: $($OriginalProtectorTypes -join ', ') - reconfigure any PIN/startup-key/AD-Entra escrow manually." -ForegroundColor Yellow
}
Write-Log -Message "Re-encryption of $MountPoint started (runs in the background)." -Level 'SUCCESS'
return $true
}
# ============================================================================
# FUNCTION: Show-PreflightChecklist
# ============================================================================
function Show-PreflightChecklist {
param(
[Parameter(Mandatory)][int]$DiskNumber,
[Parameter(Mandatory)][string]$WinREStatusText,
[Parameter(Mandatory)][bool]$HasExistingRecovery,
[Parameter(Mandatory)][int]$AvailableMB,
[Parameter(Mandatory)][int]$RecoverySizeMB,
[Parameter()]$BitLockerInfo
)
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "PRE-FLIGHT CHECKLIST (already verified automatically)" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "[OK] Running as Administrator, required cmdlets/tools present"
Write-Host "[OK] System disk detected: Disk $DiskNumber"
Write-Host "[OK] WinRE status queried: $WinREStatusText"
if ($HasExistingRecovery) {
Write-Host "[OK] Existing Recovery partition detected (identity confirmed next)"
} else {
Write-Host "[--] No existing Recovery partition found - fresh build"
}
Write-Host "[OK] Free space check: $AvailableMB MB available / $RecoverySizeMB MB required"
if ($BitLockerInfo) {
if ($BitLockerInfo.ProtectionStatus -eq 'On') {
Write-Host "[!!] BitLocker on C: is $($BitLockerInfo.VolumeStatus), protection ON - will be suspended automatically for the destructive steps and resumed afterwards" -ForegroundColor Yellow
} else {
Write-Host "[OK] BitLocker on C: is $($BitLockerInfo.VolumeStatus), protection $($BitLockerInfo.ProtectionStatus) - no suspend needed"
}
} else {
Write-Host "[--] BitLocker not present/not queryable on C:"
}
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host ""
Write-Host "Still needs YOUR manual confirmation:" -ForegroundColor Yellow
if ($HasExistingRecovery) {
Write-Host " - Identity of the detected Recovery partition"
}
Write-Host " - VM snapshot (VMware) / checkpoint (Hyper-V) state"
Write-Host " - Existence of a recent, successful backup"
Write-Host ""
}
# ============================================================================
# FUNCTION: Get-ReAgentLogTail
# ============================================================================
function Get-ReAgentLogTail {
param([int]$Lines = 40)
$logPath = Join-Path -Path $env:WINDIR -ChildPath 'Logs\ReAgent\ReAgent.log'
if (-not (Test-Path -Path $logPath)) { return $null }
try {
return Get-Content -Path $logPath -Tail $Lines -ErrorAction Stop
}
catch {
Write-Log -Message "Could not read ${logPath}: $_" -Level 'DEBUG'
return $null
}
}
# ============================================================================
# FUNCTION: Get-WinREWimSizeMB
# ============================================================================
function Get-WinREWimSizeMB {
$candidatePaths = @(
(Join-Path -Path $env:WINDIR -ChildPath 'System32\Recovery\Winre.wim')
)
foreach ($path in $candidatePaths) {
if (Test-Path -Path $path) {
try {
$bytes = (Get-Item -Path $path -Force -ErrorAction Stop).Length
return [math]::Ceiling($bytes / 1MB)
}
catch {
Write-Log -Message "Could not read size of ${path}: $_" -Level 'DEBUG'
}
}
}
return $null
}
# ============================================================================
# FUNCTION: Get-RecommendedRecoverySizeMB
# ============================================================================
function Get-RecommendedRecoverySizeMB {
param([int]$FallbackMB = 2000)
$wimSizeMB = Get-WinREWimSizeMB
if (-not $wimSizeMB) { return $FallbackMB }
$recommended = $wimSizeMB + 400 + 100
return [math]::Max($recommended, $FallbackMB)
}
# ============================================================================
# FUNCTION: Disable-WinRE
# ============================================================================
function Disable-WinRE {
Write-Log -Message "Disabling WinRE via reagentc /disable" -Level 'INFO'
$result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/disable')
Write-Log -Message "reagentc /disable output (exit $($result.ExitCode)): $($result.Output -join ' | ')" -Level 'DEBUG'
Start-Sleep -Seconds 2
$status = Get-WinREStatus
if ($status.Status -notmatch 'Disabled') {
Write-Log -Message "WinRE could not be disabled. Current status: $($status.Status)" -Level 'ERROR'
return $false
}
Write-Log -Message "WinRE successfully disabled" -Level 'SUCCESS'
return $true
}
# ============================================================================
# FUNCTION: Enable-WinRE
# ============================================================================
function Enable-WinRE {
Write-Log -Message "Enabling WinRE via reagentc /enable" -Level 'INFO'
$result = Invoke-NativeCommand -FilePath 'reagentc.exe' -ArgumentList @('/enable')
Write-Log -Message "reagentc /enable output (exit $($result.ExitCode)): $($result.Output -join ' | ')" -Level 'DEBUG'
Start-Sleep -Seconds 2
$status = Get-WinREStatus
if ($status.Status -notmatch 'Enabled') {
Write-Log -Message "WinRE could not be enabled. Current status: $($status.Status)" -Level 'ERROR'
Write-Log -Message "Full reagentc output: `n$($status.RawOutput -join "`n")" -Level 'ERROR'
$reagentLog = Get-ReAgentLogTail -Lines 40
if ($reagentLog) {
Write-Log -Message "Tail of $env:WINDIR\Logs\ReAgent\ReAgent.log (often shows the REAL cause behind a misleading console message):`n$($reagentLog -join "`n")" -Level 'ERROR'
}
if (($result.Output -join ' ') -match 'BitLocker') {
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "REAGENTC REFUSED - CITES BITLOCKER" -ForegroundColor Red
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host @"
reagentc.exe refused to enable WinRE citing BitLocker Drive
Encryption on this volume - even with BitLocker protection
suspended/off. This is a documented reagentc limitation on some
builds: it can block whenever the volume carries ANY BitLocker
encryption at all, regardless of protector/suspend state, so
suspending protectors (what this script already tried) is not
always enough.
Known workaround - NOT automated here on purpose, because it
means fully decrypting and later re-encrypting C:, and only you
know which protectors / recovery-key escrow policy (TPM, PIN,
AD/Entra/MBAM) need restoring afterwards:
1. manage-bde -off C: (full decrypt - can take a while)
2. reagentc /enable
3. Re-enable BitLocker with your normal protector setup.
IMPORTANT: this exact console message is also known to be
misleading - the REAL cause can instead be an undersized
Recovery partition for winre.wim, or the new partition's ID/
attributes not yet recognized by reagentc, OR (v2.1) a gap
between C: and the Recovery partition if adjacency turns out to
matter. Check the ReAgent.log excerpt above (also in the log
file) before assuming it really is BitLocker.
"@
Write-Host ("=" * 60) -ForegroundColor Red
}
return $false
}
Write-Log -Message "WinRE successfully enabled" -Level 'SUCCESS'
return $true
}
# ============================================================================
# FUNCTION: Invoke-DiskpartScript
# ============================================================================
function Invoke-DiskpartScript {
param([Parameter(Mandatory)][string[]]$Commands)
$tempScript = Join-Path -Path $env:TEMP -ChildPath "diskpart_$([guid]::NewGuid().ToString('N')).txt"
$Commands | Set-Content -Path $tempScript -Encoding ASCII
Write-Log -Message "Running diskpart script: `n$($Commands -join "`n")" -Level 'DEBUG'
$result = Invoke-NativeCommand -FilePath 'diskpart.exe' -ArgumentList @('/s', $tempScript)
Write-Log -Message "diskpart output (exit $($result.ExitCode)): `n$($result.Output -join "`n")" -Level 'DEBUG'
Remove-Item -Path $tempScript -Force -ErrorAction SilentlyContinue
return $result.Output
}
# ============================================================================
# FUNCTION: Remove-RecoveryPartition
# ============================================================================
function Remove-RecoveryPartition {
param(
[Parameter(Mandatory)][int]$DiskNumber,
[Parameter(Mandatory)][int]$PartitionNumber
)
Write-Log -Message "Removing Recovery Partition: Disk $DiskNumber / Partition $PartitionNumber" -Level 'WARNING'
$commands = @(
"select disk $DiskNumber"
"select partition $PartitionNumber"
"delete partition override"
"exit"
)
Invoke-DiskpartScript -Commands $commands | Out-Null
Start-Sleep -Seconds 2
$stillExists = Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue |
Where-Object { $_.PartitionNumber -eq $PartitionNumber }
if ($stillExists) {
Write-Log -Message "Recovery Partition removal FAILED - partition still present" -Level 'ERROR'
return $false
}
Write-Log -Message "Recovery Partition removed successfully" -Level 'SUCCESS'
return $true
}
# ============================================================================
# FUNCTION: New-RecoveryPartition
# v2.1 FIX: creates the Recovery partition at the TRUE END of the disk
# using an EXPLICIT -Offset computed from disk.Size, instead of letting
# New-Partition auto-place it. Auto-placement (no -Offset) puts a new
# partition at the START of the first available free extent - which,
# right after removing the old Recovery, is the space starting
# immediately after C: - NOT the end of the disk. That silently glued
# Recovery onto C: and pushed all remaining free space past it, to the
# true end of the disk, which is the opposite of this script's design
# intent (Recovery at the true end, C: expandable into the gap).
#
# No longer checks/enforces adjacency to C: at creation time - Recovery
# is intentionally allowed to have a gap in front of it here. Adjacency
# (if it matters to reagentc) is established afterwards, optionally, by
# expanding C: into that gap - see the "Expand C:?" step in MAIN, which
# now runs BEFORE Enable-WinRE.
# ============================================================================
function New-RecoveryPartition {
param(
[Parameter(Mandatory)][int]$DiskNumber,
[Parameter(Mandatory)][int]$RecoverySizeMB,
[Parameter(Mandatory)][string]$PartitionStyle,
[int]$EndBufferMB = 1
)
$sizeBytes = [uint64]$RecoverySizeMB * 1MB
$bufferBytes = [uint64]$EndBufferMB * 1MB
$disk = Get-Disk -Number $DiskNumber
$rawOffset = $disk.Size - $bufferBytes - $sizeBytes
if ($rawOffset -le 0) {
Write-Log -Message "Computed Recovery offset ($rawOffset) is not positive - disk is too small for a $RecoverySizeMB MB Recovery partition plus $EndBufferMB MB buffer." -Level 'ERROR'
return $null
}
$alignedOffset = [uint64]([math]::Floor($rawOffset / 1MB) * 1MB)
Write-Log -Message "Creating new Recovery Partition ($RecoverySizeMB MB) on Disk $DiskNumber at explicit end-of-disk offset $alignedOffset (disk size $($disk.Size) bytes, buffer $EndBufferMB MB)" -Level 'INFO'
try {
$newPartition = New-Partition -DiskNumber $DiskNumber -Size $sizeBytes -Offset $alignedOffset -AssignDriveLetter:$false -ErrorAction Stop
}
catch {
Write-Log -Message "New-Partition at offset $alignedOffset failed: $_" -Level 'ERROR'
Write-Log -Message "This usually means the trailing free space no longer matches what the free-space check saw - re-run the script from Step 1 so it re-reads the current disk layout instead of continuing from a stale state." -Level 'ERROR'
return $null
}
# Sanity check: it must actually have landed at (or very near, after
# alignment rounding) the true end of the disk.
$diskEndSlack = 2MB
$partitionEndOffset = $newPartition.Offset + $newPartition.Size
if (($disk.Size - $partitionEndOffset) -gt ($bufferBytes + $diskEndSlack)) {
Write-Log -Message "New Recovery partition ends at $partitionEndOffset, but disk size is $($disk.Size) bytes - it does not appear to be at the true end of the disk. Removing it and aborting rather than leaving a stray partition behind." -Level 'ERROR'
try {
Remove-Partition -DiskNumber $DiskNumber -PartitionNumber $newPartition.PartitionNumber -Confirm:$false -ErrorAction Stop
Write-Log -Message "Removed the misplaced partition (was Partition $($newPartition.PartitionNumber))." -Level 'INFO'
}
catch {
Write-Log -Message "Could not remove the misplaced partition automatically: $_. Remove Partition $($newPartition.PartitionNumber) on Disk $DiskNumber manually before re-running." -Level 'ERROR'
}
return $null
}
try {
Format-Volume -Partition $newPartition -FileSystem NTFS -NewFileSystemLabel "Recovery" -Confirm:$false -Force -ErrorAction Stop | Out-Null
}
catch {
Write-Log -Message "Format-Volume failed: $_" -Level 'ERROR'
return $null
}
Write-Log -Message "New Recovery Partition created: Partition $($newPartition.PartitionNumber) at offset $($newPartition.Offset)" -Level 'SUCCESS'
$allParts = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset
if ($allParts[-1].PartitionNumber -ne $newPartition.PartitionNumber) {
Write-Log -Message "WARNING: newly created Recovery partition is NOT the last partition on the disk. Layout may be unexpected." -Level 'WARNING'
}
$commands = @("select disk $DiskNumber", "select partition $($newPartition.PartitionNumber)")
if ($PartitionStyle -eq 'GPT') {
$commands += "set id=$Script:RecoveryGptType"
$commands += "gpt attributes=$Script:RecoveryGptAttrs"
}
else {
$commands += "set id=$Script:RecoveryMbrType"
}
$commands += "exit"
Invoke-DiskpartScript -Commands $commands | Out-Null
Start-Sleep -Seconds 2
$verifyPartition = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $newPartition.PartitionNumber
Write-Log -Message "Recovery Partition type configured (Style: $PartitionStyle)" -Level 'SUCCESS'
return $verifyPartition
}
# ============================================================================
# FUNCTION: Resize-SystemPartition
# Expands C: into whatever contiguous free space remains (the gap
# between C: and the already-created, true-end-of-disk Recovery
# partition). Get-PartitionSupportedSize naturally stops right before
# Recovery, so no manual reserve math is needed.
# ============================================================================
function Resize-SystemPartition {
param([Parameter(Mandatory)][int]$DiskNumber)
$before = Get-Partition -DiskNumber $DiskNumber | Where-Object { $_.DriveLetter -eq 'C' }
$beforeSizeGB = [math]::Round($before.Size / 1GB, 2)
$supported = Get-PartitionSupportedSize -DiskNumber $DiskNumber -PartitionNumber $before.PartitionNumber
$targetSize = $supported.SizeMax
if ($targetSize -le $before.Size) {
Write-Log -Message "C: is already at its maximum possible size - nothing to expand" -Level 'WARNING'
return $true
}
Write-Log -Message "Resizing C: from $([math]::Round($before.Size/1GB,2)) GB to $([math]::Round($targetSize/1GB,2)) GB" -Level 'INFO'
try {
Resize-Partition -DiskNumber $DiskNumber -PartitionNumber $before.PartitionNumber -Size $targetSize -ErrorAction Stop
}
catch {
Write-Log -Message "Resize-Partition failed: $_" -Level 'ERROR'
return $false
}
Start-Sleep -Seconds 2
$after = Get-Partition -DriveLetter C
$afterSizeGB = [math]::Round($after.Size / 1GB, 2)
Write-Host ""
Write-Host "C: before: $beforeSizeGB GB"
Write-Host "C: after : $afterSizeGB GB"
Write-Host "Increase : +$([math]::Round($afterSizeGB - $beforeSizeGB, 2)) GB"
if ($after.Size -le $before.Size) {
Write-Log -Message "C: partition size did not increase as expected" -Level 'ERROR'
return $false
}
Write-Log -Message "C: successfully expanded to $afterSizeGB GB" -Level 'SUCCESS'
return $true
}
# ============================================================================
# FUNCTION: Test-FinalState
# ============================================================================
function Test-FinalState {
param(
[Parameter(Mandatory)][int]$DiskNumber,
[Parameter(Mandatory)]$RecoveryPartition
)
$results = [ordered]@{}
$disk = Get-Disk -Number $DiskNumber
$results['Disk online/healthy'] = ($disk.OperationalStatus -eq 'Online' -and $disk.HealthStatus -eq 'Healthy')
$c = Get-Partition -DriveLetter C
$cVolume = Get-Volume -DriveLetter C
$results['C: exists and NTFS'] = ($cVolume.FileSystem -eq 'NTFS')
$recovery = Get-Partition -DiskNumber $DiskNumber -PartitionNumber $RecoveryPartition.PartitionNumber
$results['Recovery exists'] = [bool]$recovery
$results['Recovery has no drive letter'] = (-not $recovery.DriveLetter)
$allPartitions = Get-Partition -DiskNumber $DiskNumber | Sort-Object Offset
$results['Recovery is last partition'] = ($allPartitions[-1].PartitionNumber -eq $recovery.PartitionNumber)
$winre = Get-WinREStatus
$results['WinRE enabled'] = ($winre.Status -match 'Enabled')
$results['WinRE location matches new Recovery'] = (
$winre.Location -and
$winre.Location.DiskNumber -eq $DiskNumber -and
$winre.Location.PartitionNumber -eq $recovery.PartitionNumber
)
return $results
}
# ============================================================================
# FUNCTION: Show-FinalSummary
# ============================================================================
function Show-FinalSummary {
param(
[Parameter(Mandatory)][hashtable]$Results,
[Parameter(Mandatory)][double]$CSizeBeforeGB,
[Parameter(Mandatory)][double]$CSizeAfterGB,
[Parameter(Mandatory)][int]$RecoverySizeMB,
[Parameter(Mandatory)][bool]$CExpanded,
[Parameter(Mandatory)][int]$RemainingFreeMB
)
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "FINAL VALIDATION" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor Cyan
$allOk = $true
foreach ($key in $Results.Keys) {
$status = if ($Results[$key]) { "[OK]" } else { $allOk = $false; "[FAIL]" }
$color = if ($Results[$key]) { 'Green' } else { 'Red' }
Write-Host ("{0} {1}" -f $status, $key) -ForegroundColor $color
}
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "RESULT" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "C: BEFORE : $CSizeBeforeGB GB"
Write-Host "C: AFTER : $CSizeAfterGB GB"
if ($CExpanded) {
Write-Host "GAIN : +$([math]::Round($CSizeAfterGB - $CSizeBeforeGB, 2)) GB"
} else {
Write-Host "GAIN : C: was NOT expanded (skipped on request)"
Write-Host "REMAINING : ~$RemainingFreeMB MB free between C: and Recovery (unallocated)"
}
Write-Host ""
Write-Host "Recovery Partition:"
Write-Host "Size : $RecoverySizeMB MB"
Write-Host "Position : Last partition"
Write-Host ""
if ($allOk) {
Write-Host "STATUS: SUCCESS" -ForegroundColor Green
Write-Log -Message "FINAL STATUS: SUCCESS" -Level 'SUCCESS'
} else {
Write-Host "STATUS: FAILED" -ForegroundColor Red
Write-Log -Message "FINAL STATUS: FAILED" -Level 'ERROR'
}
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Yellow
Write-Host "POST-ACTION VM CHECK" -ForegroundColor Yellow
Write-Host ("=" * 60) -ForegroundColor Yellow
Write-Host @"
If you created a VMware snapshot or Hyper-V checkpoint before
this operation, do NOT keep it longer than necessary.
Verify:
- Windows boots normally
- C: has the expected size
- WinRE is Enabled
- Recovery partition is present
- Backup is healthy
Only after validation should the temporary snapshot
be removed according to your VMware/backup procedure.
"@
Write-Host ("=" * 60) -ForegroundColor Yellow
}
# ============================================================================
# MAIN EXECUTION
# ============================================================================
try {
if (-not (Test-Path -Path $LogPath)) {
New-Item -Path $LogPath -ItemType Directory -Force | Out-Null
}
$timestampForFile = Get-Date -Format 'yyyyMMdd-HHmmss'
$Script:LogFile = Join-Path -Path $LogPath -ChildPath "WinRE-Resize-$timestampForFile-$env:COMPUTERNAME.log"
$Script:TranscriptFile = Join-Path -Path $LogPath -ChildPath "WinRE-Resize-$timestampForFile-$env:COMPUTERNAME.transcript.log"
try { Start-Transcript -Path $Script:TranscriptFile -Force | Out-Null } catch {
Write-Host "WARNING: could not start transcript ($_)" -ForegroundColor Yellow
}
Write-Banner
Write-Log -Message "=== WinRE-Resize2.ps1 v$Script:ToolVersion started ===" -Level 'INFO'
Write-Log -Message "Parameters: AuditOnly=$AuditOnly RecoverySizeMB=$RecoverySizeMB DiskEndBufferMB=$DiskEndBufferMB LogPath=$LogPath" -Level 'INFO'
# ------------------------------------------------------------------
# PHASE 1 - DISCOVERY
# ------------------------------------------------------------------
Write-Step -Description "Checking administrator privileges" -Status 'START'
if (-not (Test-IsAdministrator)) {
Write-Step -Description "Checking administrator privileges" -Status 'FAIL'
Stop-Safely -Reason "Script must be run as Administrator. Please re-launch PowerShell elevated."
}
Write-Step -Description "Checking administrator privileges" -Status 'OK'
foreach ($cmd in @('Get-Disk','Get-Partition','Get-Volume','Resize-Partition','New-Partition','Format-Volume')) {
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
Stop-Safely -Reason "Required cmdlet '$cmd' is not available on this system."
}
}
foreach ($exe in @('reagentc.exe','diskpart.exe')) {
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) {
Stop-Safely -Reason "Required executable '$exe' was not found in PATH."
}
}
Write-Step -Description "Detecting system disk" -Status 'START'
$sysDiskInfo = Get-SystemDisk
$diskNumber = $sysDiskInfo.Disk.Number
$cPartition = $sysDiskInfo.CPartition
$cSizeBeforeGB = [math]::Round($cPartition.Size / 1GB, 2)
Write-Step -Description "Detecting system disk" -Status 'OK'
$partitions = Get-DiskLayout -DiskNumber $diskNumber
Write-Step -Description "Checking WinRE" -Status 'START'
$winREStatus = Get-WinREStatus
Write-Host ""
Write-Host "WinRE Status : $($winREStatus.Status)"
if ($winREStatus.Location) {
Write-Host "WinRE Location : Disk $($winREStatus.Location.DiskNumber) / Partition $($winREStatus.Location.PartitionNumber)"
}
Write-Step -Description "Checking WinRE" -Status 'OK'
Write-Step -Description "Detecting Recovery partition" -Status 'START'
$recoveryPartition = Get-RecoveryPartition -Disk $sysDiskInfo.Disk -Partitions $partitions -CPartition $cPartition -WinREStatus $winREStatus
$hasExistingRecovery = [bool]$recoveryPartition
if ($hasExistingRecovery) {
Write-Step -Description "Detecting Recovery partition" -Status 'OK'
} else {
Write-Step -Description "Detecting Recovery partition" -Status 'SKIP'
Write-Host "No existing Recovery partition found - a new one will be created from scratch." -ForegroundColor Yellow
}
Write-Step -Description "Checking BitLocker status" -Status 'START'
$bitlockerInfo = Get-BitLockerInfo -MountPoint 'C:'
if ($bitlockerInfo) {
Write-Host ""
Write-Host "BitLocker on C: : $($bitlockerInfo.VolumeStatus) / Protection $($bitlockerInfo.ProtectionStatus)"
if ($bitlockerInfo.ProtectionStatus -eq 'On') {
Write-Host "Protection will be suspended automatically around the WinRE/partition steps and resumed afterwards." -ForegroundColor Yellow
}
Write-Step -Description "Checking BitLocker status" -Status 'OK'
} else {
Write-Step -Description "Checking BitLocker status" -Status 'SKIP'
Write-Log -Message "BitLocker not present/not queryable on C: - nothing to suspend." -Level 'INFO'
}
Write-Step -Description "Checking Recovery partition sizing" -Status 'START'
$winreWimSizeMB = Get-WinREWimSizeMB
$recommendedRecoveryMB = Get-RecommendedRecoverySizeMB
if ($winreWimSizeMB) {
Write-Host ""
Write-Host "Current Winre.wim size : ~$winreWimSizeMB MB"
Write-Host "Recommended minimum : $recommendedRecoveryMB MB (includes reagentc's working-space buffer + headroom)"
}
if ($RecoverySizeMB -lt $recommendedRecoveryMB) {
Write-Step -Description "Checking Recovery partition sizing" -Status 'FAIL'
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Yellow
Write-Host "RECOVERY PARTITION SIZE WARNING" -ForegroundColor Yellow
Write-Host ("=" * 60) -ForegroundColor Yellow
Write-Host @"
reagentc /enable does NOT error out when the Recovery partition
is too small - it silently falls back to storing WinRE inside
C: itself instead, even though the partition otherwise looks
perfectly valid (correct type, no drive letter, adjacent to C:).
This has been observed directly on this system.
Requested Recovery partition size : $RecoverySizeMB MB
Recommended minimum for this system: $recommendedRecoveryMB MB
"@
Write-Host ("=" * 60) -ForegroundColor Yellow
if (-not (Confirm-YesNo -Prompt "Continue anyway with a $RecoverySizeMB MB Recovery partition (real risk of the same silent fallback)?")) {
Stop-Safely -Reason "Aborted by user - re-run with -RecoverySizeMB $recommendedRecoveryMB (or more)."
}
} else {
Write-Step -Description "Checking Recovery partition sizing" -Status 'OK'
}
# ------------------------------------------------------------------
# PHASE 2 - VALIDATION
# ------------------------------------------------------------------
Write-Step -Description "Checking free space" -Status 'START'
if ($hasExistingRecovery) {
if (-not (Test-DiskLayout -Partitions $partitions -CPartition $cPartition -RecoveryPartition $recoveryPartition)) {
Write-Step -Description "Checking free space" -Status 'FAIL'
Write-Log -Message "WARNING: Recovery partition is not directly behind C:. Automatic procedure cannot safely continue." -Level 'ERROR'
Stop-Safely -Reason "Recovery partition is not directly behind C:. Aborting for safety."
}
}
$trailingFreeMB = Get-TrailingFreeSpaceMB -DiskNumber $diskNumber
$existingRecoveryMB = if ($hasExistingRecovery) { [math]::Round($recoveryPartition.Size / 1MB, 0) } else { 0 }
$availableMB = $trailingFreeMB + $existingRecoveryMB
Write-Host ""
Write-Host "FREE SPACE CHECK" -ForegroundColor Cyan
Write-Host "Existing Recovery size : $existingRecoveryMB MB"
Write-Host "Trailing unallocated space: $trailingFreeMB MB"
Write-Host "Total available : $availableMB MB"
Write-Host "Required for new Recovery : $RecoverySizeMB MB"
if ($availableMB -lt $RecoverySizeMB) {
Write-Step -Description "Checking free space" -Status 'FAIL'
Stop-Safely -Reason "Insufficient space: only $availableMB MB available (existing Recovery + trailing free space), need at least $RecoverySizeMB MB. Free up space manually (e.g. shrink C: further) and re-run."
}
Write-Step -Description "Checking free space" -Status 'OK'
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "CURRENT SYSTEM STATE" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "Computer : $env:COMPUTERNAME"
Write-Host "System Disk : $diskNumber"
Write-Host "Partition Style : $($sysDiskInfo.Disk.PartitionStyle)"
Write-Host "C: Size : $cSizeBeforeGB GB"
Write-Host "WinRE Status : $($winREStatus.Status)"
if ($hasExistingRecovery) {
Write-Host "Recovery Part# : $($recoveryPartition.PartitionNumber)"
Write-Host "Recovery Size : $existingRecoveryMB MB"
} else {
Write-Host "Recovery Part# : (none - will be created fresh)"
}
if ($AuditOnly) {
Write-Host ""
Write-Host "AUDIT ONLY" -ForegroundColor Yellow
Write-Host ""
Write-Host "Planned actions (NOT executed):"
$n = 1
if ($hasExistingRecovery) {
Write-Host " $n. Disable WinRE"; $n++
Write-Host " $n. Delete old Recovery Partition $($recoveryPartition.PartitionNumber) on Disk $diskNumber"; $n++
}
Write-Host " $n. Create new Recovery Partition ($RecoverySizeMB MB) at the true end of the disk"; $n++
Write-Host " $n. Ask whether to expand C: into the remaining free space (interactive, optional)"; $n++
Write-Host " $n. Configure Recovery Partition type ($($sysDiskInfo.Disk.PartitionStyle)) and re-enable WinRE"
Write-Host ""
Write-Host "No changes have been made."
Write-Log -Message "Audit-only run completed. No changes made." -Level 'SUCCESS'
Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
exit 0
}
# ------------------------------------------------------------------
# PHASE 3 - USER CONFIRMATION
# ------------------------------------------------------------------
Show-PreflightChecklist -DiskNumber $diskNumber -WinREStatusText $winREStatus.Status `
-HasExistingRecovery $hasExistingRecovery -AvailableMB $availableMB -RecoverySizeMB $RecoverySizeMB `
-BitLockerInfo $bitlockerInfo
Write-Step -Description "User confirmation" -Status 'START'
if ($hasExistingRecovery) {
if (-not (Confirm-RecoveryPartition -DiskNumber $diskNumber -RecoveryPartition $recoveryPartition -CPartition $cPartition)) {
Write-Step -Description "User confirmation" -Status 'FAIL'
Stop-Safely -Reason "User did not confirm the detected Recovery partition."
}
}
if (-not (Confirm-Snapshot)) {
Write-Step -Description "User confirmation" -Status 'FAIL'
Stop-Safely -Reason "User did not confirm VM snapshot/checkpoint verification."
}
if (-not (Confirm-Backup)) {
Write-Step -Description "User confirmation" -Status 'FAIL'
Stop-Safely -Reason "User did not confirm existence of a recent successful backup."
}
Write-Step -Description "User confirmation" -Status 'OK'
# ------------------------------------------------------------------
# PHASE 4 - DESTRUCTIVE OPERATION (only if an old Recovery exists)
# ------------------------------------------------------------------
if ($bitlockerInfo -and $bitlockerInfo.ProtectionStatus -eq 'On') {
Write-Step -Description "Suspending BitLocker" -Status 'START'
if (Suspend-BitLockerProtection -MountPoint 'C:') {
$Script:BitLockerSuspended = $true
Write-Step -Description "Suspending BitLocker" -Status 'OK'
} else {
Write-Step -Description "Suspending BitLocker" -Status 'FAIL'
Stop-Safely -Reason "Could not suspend BitLocker protection on C:. Aborting before touching WinRE/partitions to avoid a BitLocker recovery prompt on next boot."
}
}
Write-Step -Description "Disabling WinRE" -Status 'START'
if ($winREStatus.Status -match 'Enabled') {
if (-not (Disable-WinRE)) {
Write-Step -Description "Disabling WinRE" -Status 'FAIL'
Stop-Safely -Reason "WinRE could not be disabled. Cannot safely proceed."
}
Write-Step -Description "Disabling WinRE" -Status 'OK'
} else {
Write-Step -Description "Disabling WinRE" -Status 'SKIP'
Write-Log -Message "WinRE already disabled - nothing to do." -Level 'INFO'
}
Write-Step -Description "Removing old Recovery partition" -Status 'START'
if ($hasExistingRecovery) {
$finalCheckPartitions = Get-Partition -DiskNumber $diskNumber
$finalRecovery = $finalCheckPartitions | Where-Object { $_.PartitionNumber -eq $recoveryPartition.PartitionNumber }
$finalC = $finalCheckPartitions | Where-Object { $_.DriveLetter -eq 'C' }
if (-not $finalRecovery -or -not $finalC) {
Write-Step -Description "Removing old Recovery partition" -Status 'FAIL'
Stop-Safely -Reason "Partition table changed unexpectedly before destructive step. Aborting."
}
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "FINAL DESTRUCTIVE CHECK" -ForegroundColor Red
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "System Disk : Disk $diskNumber ($($sysDiskInfo.Disk.FriendlyName))"
Write-Host "C: : Disk $diskNumber / Partition $($finalC.PartitionNumber)"
Write-Host "Recovery to DELETE: Disk $diskNumber / Partition $($finalRecovery.PartitionNumber)"
Write-Host "Size : $([math]::Round($finalRecovery.Size/1MB,0)) MB"
Write-Host ""
Write-Host "!!! DESTRUCTIVE OPERATION !!!" -ForegroundColor Red
Write-Host "This operation is irreversible without backup. The script will NOT delete C:."
Write-Host ""
$typed = Read-Host "Type exactly DELETE to continue"
if ($typed -cne 'DELETE') {
Write-Step -Description "Removing old Recovery partition" -Status 'FAIL'
Stop-Safely -Reason "User did not type DELETE exactly. Destructive operation aborted."
}
if (-not (Remove-RecoveryPartition -DiskNumber $diskNumber -PartitionNumber $finalRecovery.PartitionNumber)) {
Write-Step -Description "Removing old Recovery partition" -Status 'FAIL'
Stop-Safely -Reason "Recovery partition removal failed or could not be verified."
}
Write-Step -Description "Removing old Recovery partition" -Status 'OK'
} else {
Write-Step -Description "Removing old Recovery partition" -Status 'SKIP'
}
# ------------------------------------------------------------------
# PHASE 5 - REBUILD (Recovery at true end FIRST, then optional C:
# expansion, THEN enable WinRE)
# ------------------------------------------------------------------
$trailingFreeMB = Get-TrailingFreeSpaceMB -DiskNumber $diskNumber
if ($trailingFreeMB -lt ($RecoverySizeMB + $DiskEndBufferMB)) {
Stop-Safely -Reason "After removing the old Recovery partition, only $trailingFreeMB MB is free at the end of the disk - not enough for a $RecoverySizeMB MB Recovery partition plus the $DiskEndBufferMB MB safety buffer."
}
# Informational only (v2.1): with Recovery now placed at the true end
# of the disk via explicit offset, a partition sitting directly after
# C: no longer blocks Recovery creation - it just means there will be
# no gap for C: to expand into later. We warn instead of aborting.
$cPartitionNow = Get-Partition -DriveLetter C
$cEndOffsetNow = $cPartitionNow.Offset + $cPartitionNow.Size
$alignmentSlackNow = 1MB
$adjacentPartition = Get-Partition -DiskNumber $diskNumber | Where-Object {
$_.PartitionNumber -ne $cPartitionNow.PartitionNumber -and
$_.Offset -ge $cEndOffsetNow -and
($_.Offset - $cEndOffsetNow) -lt $alignmentSlackNow
}
if ($adjacentPartition) {
Write-Log -Message "NOTE: Partition $($adjacentPartition.PartitionNumber) ($([math]::Round($adjacentPartition.Size/1MB,0)) MB) already sits directly after C:. This will limit or eliminate the space available to later expand C: into." -Level 'WARNING'
}
Write-Step -Description "Creating Recovery partition" -Status 'START'
$newRecovery = New-RecoveryPartition -DiskNumber $diskNumber -RecoverySizeMB $RecoverySizeMB -PartitionStyle $sysDiskInfo.Disk.PartitionStyle -EndBufferMB $DiskEndBufferMB
if (-not $newRecovery) {
Write-Step -Description "Creating Recovery partition" -Status 'FAIL'
Stop-Safely -Reason "Failed to create the new Recovery partition. WinRE remains disabled - manual intervention required."
}
Write-Step -Description "Creating Recovery partition" -Status 'OK'
# --- Interactive: expand C: into whatever remains, BEFORE enabling WinRE ---
$cNow = Get-Partition -DriveLetter C
$recoveryNow = Get-Partition -DiskNumber $diskNumber -PartitionNumber $newRecovery.PartitionNumber
$gapBytes = $recoveryNow.Offset - ($cNow.Offset + $cNow.Size)
$gapMB = [math]::Max(0, [math]::Round($gapBytes / 1MB, 0))
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "EXPAND C: ?" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor Cyan
Write-Host "Free space currently sitting between C: and the new Recovery partition: ~$gapMB MB"
$cExpanded = $false
if ($gapMB -lt 1) {
Write-Host "No meaningful free space to expand into - skipping." -ForegroundColor Yellow
}
elseif (Confirm-YesNo -Prompt "Do you want to expand C: to fill this space now?") {
Write-Step -Description "Expanding C:" -Status 'START'
if (Resize-SystemPartition -DiskNumber $diskNumber) {
$cExpanded = $true
Write-Step -Description "Expanding C:" -Status 'OK'
} else {
Write-Step -Description "Expanding C:" -Status 'FAIL'
Write-Log -Message "C: expansion failed - Recovery partition is unaffected. You can retry expanding C: manually via Disk Management. Proceeding to enable WinRE with the gap still in place." -Level 'WARNING'
}
}
else {
Write-Log -Message "User chose not to expand C: now. Remaining free space: ~$gapMB MB. If reagentc requires Recovery to be strictly adjacent to C:, leaving this gap in place could cause WinRE enable to fail or silently fall back to storing WinRE on C: - watch the next step's result." -Level 'WARNING'
}
# Step: Configure / enable WinRE (runs AFTER the expand-C: decision,
# so that if the user chose to expand, C: and Recovery are adjacent
# by the time reagentc runs).
Write-Step -Description "Configuring WinRE" -Status 'START'
$winREEnabled = Enable-WinRE
$bitlockerDecryptedByUs = $false
$bitlockerProtectorTypesBeforeDecrypt = @()
if (-not $winREEnabled) {
$currentBitlockerInfo = Get-BitLockerInfo -MountPoint 'C:'
if ($currentBitlockerInfo -and $currentBitlockerInfo.VolumeStatus -ne 'FullyDecrypted') {
Write-Log -Message "reagentc /enable failed while BitLocker encryption is still present on C: - offering the full-decrypt workaround." -Level 'WARNING'
$workaround = Invoke-BitLockerDecryptWorkaround -MountPoint 'C:'
if ($workaround.Success) {
$bitlockerDecryptedByUs = $true
$bitlockerProtectorTypesBeforeDecrypt = $workaround.ProtectorTypes
$winREEnabled = Enable-WinRE
}
}
}
if (-not $winREEnabled) {
Write-Step -Description "Configuring WinRE" -Status 'FAIL'
if ($bitlockerDecryptedByUs) {
Write-Log -Message "WinRE still could not be enabled even after fully decrypting C: - re-encrypting before aborting." -Level 'ERROR'
Invoke-BitLockerReencrypt -MountPoint 'C:' -OriginalProtectorTypes $bitlockerProtectorTypesBeforeDecrypt | Out-Null
}
Stop-Safely -Reason "Failed to enable WinRE after creating the new Recovery partition. Manual intervention required."
}
Write-Step -Description "Configuring WinRE" -Status 'OK'
if ($bitlockerDecryptedByUs) {
Invoke-BitLockerReencrypt -MountPoint 'C:' -OriginalProtectorTypes $bitlockerProtectorTypesBeforeDecrypt | Out-Null
}
if ($Script:BitLockerSuspended) {
Write-Step -Description "Resuming BitLocker" -Status 'START'
Resume-BitLockerProtection -MountPoint 'C:'
$Script:BitLockerSuspended = $false
Write-Step -Description "Resuming BitLocker" -Status 'OK'
}
# ------------------------------------------------------------------
# PHASE 6 - FINAL VALIDATION
# ------------------------------------------------------------------
Write-Step -Description "Final validation" -Status 'START'
$results = Test-FinalState -DiskNumber $diskNumber -RecoveryPartition $newRecovery
$cSizeAfterGB = [math]::Round((Get-Partition -DriveLetter C).Size / 1GB, 2)
$cFinal = Get-Partition -DriveLetter C
$recoveryFinal = Get-Partition -DiskNumber $diskNumber -PartitionNumber $newRecovery.PartitionNumber
$remainingGapMB = [math]::Max(0, [math]::Round(($recoveryFinal.Offset - ($cFinal.Offset + $cFinal.Size)) / 1MB, 0))
$stepStatus = if ($results.Values -notcontains $false) { 'OK' } else { 'FAIL' }
Write-Step -Description "Final validation" -Status $stepStatus
if ($results['WinRE enabled'] -and -not $results['WinRE location matches new Recovery']) {
Write-Host ""
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "WINRE IS ENABLED BUT NOT USING THE RECOVERY PARTITION" -ForegroundColor Red
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host @"
reagentc reported success, but WinRE is actually stored inside
C: itself rather than on the dedicated Recovery partition. This
can happen when reagentc's own partition-size check silently
rejects the Recovery partition and falls back to C: without
raising an error, OR (v2.1) if adjacency to C: turns out to be
required and a gap was left between C: and Recovery.
Current Winre.wim size : $(if ($winreWimSizeMB) { "~$winreWimSizeMB MB" } else { "unknown" })
Recovery partition size: $RecoverySizeMB MB
Recommended minimum : $recommendedRecoveryMB MB
Gap between C: and Recovery at final check: ~$remainingGapMB MB
If the partition size looks adequate above, check the ReAgent.log
excerpt below (also written to the script log) for the real reason.
"@
$reagentLogNow = Get-ReAgentLogTail -Lines 40
if ($reagentLogNow) {
Write-Log -Message "Tail of $env:WINDIR\Logs\ReAgent\ReAgent.log at final validation (WinRE enabled but not using the Recovery partition):`n$($reagentLogNow -join "`n")" -Level 'ERROR'
Write-Host ($reagentLogNow -join "`n")
}
Write-Host ("=" * 60) -ForegroundColor Red
Write-Host "If a gap remains, re-run and choose to expand C: this time so C: and Recovery are adjacent before WinRE is enabled." -ForegroundColor Yellow
}
# ------------------------------------------------------------------
# PHASE 7 - FINAL REPORT
# ------------------------------------------------------------------
Show-FinalSummary -Results $results -CSizeBeforeGB $cSizeBeforeGB -CSizeAfterGB $cSizeAfterGB -RecoverySizeMB $RecoverySizeMB -CExpanded $cExpanded -RemainingFreeMB $remainingGapMB
Write-Log -Message "=== WinRE-Resize2.ps1 finished ===" -Level 'INFO'
Write-Host ""
Write-Host "Log file: $Script:LogFile"
try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {}
if ($results.Values -contains $false) { exit 1 } else { exit 0 }
}
catch {
if ($Script:BitLockerSuspended) {
Write-Log -Message "Unhandled exception while BitLocker was suspended - attempting to resume protection." -Level 'WARNING'
Resume-BitLockerProtection -MountPoint 'C:'
$Script:BitLockerSuspended = $false
}
Write-Log -Message "UNHANDLED EXCEPTION: $_" -Level 'ERROR'
Write-Log -Message "Stack trace: $($_.ScriptStackTrace)" -Level 'ERROR'
Write-Host ""
Write-Host "UNSAFE / AMBIGUOUS STATE" -ForegroundColor Red
Write-Host "An unexpected error occurred. No further changes will be made." -ForegroundColor Red
Write-Host "Error: $_" -ForegroundColor Red
if ($Script:LogFile) { Write-Host "Log: $Script:LogFile" -ForegroundColor Red }
try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch {}
exit 1
}