Files
local-ci-cd-system/Setup-TemplateVM.ps1
T

660 lines
30 KiB
PowerShell

#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
Provisions a Windows VM as a CI build template (run INSIDE the VM once).
.DESCRIPTION
This script is run ONE TIME inside the template VM before taking the
"BaseClean" snapshot. After the snapshot is taken, never modify the VM.
Installs and configures:
- Windows Updates (all available, via COM API — no external module needed)
- WinRM with basic authentication (for PSSession from host)
- A dedicated local build user account
- Visual Studio Build Tools 2026 (MSBuild, MSVC, .NET desktop targets)
- .NET SDK 10.x
- Firewall rules for WinRM
NOTE: Git is NOT installed. The host clones the repository and copies
the source tree into the VM via WinRM (Option B isolation model).
╔══════════════════════════════════════════════════════════════════════╗
║ NETWORK REQUIREMENT DURING SETUP ║
║ The VM must have internet access while this script runs so it can ║
║ download Windows Updates and tool installers. Configure the VM NIC ║
║ to VMnet8 (NAT) before running setup, then switch back to VMnet11 ║
║ (Host-Only) BEFORE taking the BaseClean snapshot.
╚══════════════════════════════════════════════════════════════════════╝
After this script completes:
1. Switch VM NIC from VMnet8 (NAT) → VMnet11 (Host-Only) in VMware
Workstation: VM → Settings → Network Adapter → Custom: VMnet11
2. Shut down the VM (Start → Shut down)
3. In VMware Workstation: VM → Snapshot → Take Snapshot
Name it exactly: BaseClean
4. Power off the VM and leave it powered off permanently
.PARAMETER SkipWindowsUpdate
Skip the Windows Update step (useful if updates were already applied).
.NOTES
Invoke via Prepare-TemplateSetup.ps1 from the HOST (recommended), or
run manually from an elevated PowerShell session INSIDE the VM:
Set-ExecutionPolicy Bypass -Scope Process -Force
.\Setup-TemplateVM.ps1
#>
[CmdletBinding()]
param(
# Local username for the CI build account inside the VM
[string] $BuildUsername = 'ci_build',
# Password for the build account.
# IMPORTANT: Change this to a strong password and store it in
# Windows Credential Manager on the HOST as target "BuildVMGuest".
[string] $BuildPassword = 'CIBuild!ChangeMe2026',
# .NET SDK channel to install (should match the host SDK version)
[string] $DotNetSdkVersion = '10.0',
# Python version to install (x.y.z — must match an exact release on python.org)
[string] $PythonVersion = '3.13.3',
# Skip Windows Update (useful when updates already applied or no internet)
[switch] $SkipWindowsUpdate
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Step {
param([string]$Message)
Write-Host "`n=== $Message ===" -ForegroundColor Cyan
}
# ── Step 0: Windows Update ────────────────────────────────────────────────────
# NOTE: The Windows Update COM API (Microsoft.Update.Session) raises E_ACCESSDENIED
# when called from a WinRM/network-logon session. We work around this by running
# the actual download+install inside a Scheduled Task (SYSTEM account, full token).
if ($SkipWindowsUpdate) {
Write-Host "[Setup] Skipping Windows Update (SkipWindowsUpdate switch set)."
}
else {
Write-Step "Running Windows Update via Scheduled Task (avoids WinRM token restrictions)"
# --- worker script that runs as SYSTEM inside the scheduled task ---
$wuWorkerPath = 'C:\CI\wu_worker.ps1'
$wuResultPath = 'C:\CI\wu_result.txt'
$wuRebootPath = 'C:\CI\wu_reboot.txt'
$wuLogPath = 'C:\CI\wu_log.txt'
$wuWorker = @'
$ErrorActionPreference = 'Stop'
try {
$s = New-Object -ComObject Microsoft.Update.Session
$q = $s.CreateUpdateSearcher()
$found = $q.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
$count = $found.Updates.Count
if ($count -eq 0) {
"0" | Set-Content C:\CI\wu_result.txt
"False" | Set-Content C:\CI\wu_reboot.txt
"No updates needed." | Set-Content C:\CI\wu_log.txt
} else {
"Downloading $count update(s)..." | Set-Content C:\CI\wu_log.txt
$dl = $s.CreateUpdateDownloader()
$dl.Updates = $found.Updates
$dl.Download() | Out-Null
"Installing $count update(s)..." | Add-Content C:\CI\wu_log.txt
$inst = $s.CreateUpdateInstaller()
$inst.Updates = $found.Updates
$r = $inst.Install()
[string]$r.ResultCode | Set-Content C:\CI\wu_result.txt
[string]$r.RebootRequired | Set-Content C:\CI\wu_reboot.txt
"Done. ResultCode=$($r.ResultCode) RebootRequired=$($r.RebootRequired)" | Add-Content C:\CI\wu_log.txt
}
} catch {
"ERROR: $_" | Set-Content C:\CI\wu_log.txt
"99" | Set-Content C:\CI\wu_result.txt
"False" | Set-Content C:\CI\wu_reboot.txt
}
'@
$wuWorker | Set-Content -Path $wuWorkerPath -Encoding UTF8
# Remove any leftover result files from a previous run
Remove-Item $wuResultPath, $wuRebootPath -ErrorAction SilentlyContinue
# Register scheduled task running as SYSTEM
$taskName = 'CI-WindowsUpdate'
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$wuWorkerPath`""
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
Write-Host "Scheduled task registered. Starting Windows Update..."
Start-ScheduledTask -TaskName $taskName
# Poll until the task finishes (result file written = task complete)
$timeout = [datetime]::UtcNow.AddMinutes(60)
$dotCount = 0
while (-not (Test-Path $wuResultPath)) {
if ([datetime]::UtcNow -gt $timeout) {
throw "Windows Update timed out after 60 minutes."
}
Start-Sleep -Seconds 15
$dotCount++
if ($dotCount % 4 -eq 0) { Write-Host " ... still running ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" }
}
# Read results
$resultCode = [int](Get-Content $wuResultPath -Raw).Trim()
$rebootNeeded = (Get-Content $wuRebootPath -Raw).Trim() -eq 'True'
$log = Get-Content $wuLogPath -Raw -ErrorAction SilentlyContinue
Write-Host "Windows Update log:"
Write-Host $log
# Clean up
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item $wuWorkerPath, $wuResultPath, $wuRebootPath, $wuLogPath -ErrorAction SilentlyContinue
if ($resultCode -eq 99) {
throw "Windows Update worker script failed. See log above."
}
if ($resultCode -notin @(0, 2, 3)) {
Write-Warning "Windows Update returned unexpected result code: $resultCode"
}
else {
Write-Host "Windows Update completed (ResultCode=$resultCode)."
}
if ($rebootNeeded) {
Write-Warning "*** REBOOT REQUIRED to finish installing updates. ***"
Write-Warning " Restart the VM, then re-run this script with: -SkipWindowsUpdate"
exit 3010
}
}
# ── Step 1: WinRM ─────────────────────────────────────────────────────────────
Write-Step "Configuring WinRM"
Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null
# Allow unencrypted (HTTP/5985) — acceptable for an isolated lab network.
# Migrate to HTTPS/5986 with a self-signed cert for hardened environments.
winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
# Increase shell memory and timeout limits for long builds
winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="2048"}' | Out-Null
winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null
Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>$null
Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>$null # 2 hours
# Ensure WinRM starts automatically
Set-Service -Name WinRM -StartupType Automatic 3>$null
Start-Service WinRM 3>$null
Write-Host "WinRM configured."
# ── Step 2: Firewall ──────────────────────────────────────────────────────────
Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM"
# Disable all firewall profiles entirely. This VM lives on a Host-Only network
# (VMnet11) with no external exposure, so full disable is acceptable and avoids
# any rule-order or profile-classification issues that would block WinRM/ICMP.
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
Write-Host "Windows Firewall disabled on all profiles."
# ── Step 3: Build user account ────────────────────────────────────────────────
Write-Step "Creating build user account: $BuildUsername"
$existingUser = Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue
if ($existingUser) {
Write-Host "User '$BuildUsername' already exists — skipping creation."
}
else {
# Use net user instead of New-LocalUser: the LocalAccounts cmdlets have a known
# bug where SecureString deserialized over WinRM causes InvalidPasswordException.
$netResult = & net user $BuildUsername $BuildPassword /add /y 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to create user '$BuildUsername': $netResult"
}
Write-Host "User '$BuildUsername' created."
}
# Always ensure password policy and Administrators membership (idempotent)
& net user $BuildUsername /passwordchg:no | Out-Null
Set-LocalUser -Name $BuildUsername -PasswordNeverExpires $true
$isMember = & net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername
if (-not $isMember) {
& net localgroup Administrators $BuildUsername /add | Out-Null
Write-Host "User '$BuildUsername' added to Administrators."
}
else {
Write-Host "User '$BuildUsername' already in Administrators."
}
# ── Step 3b: Disable UAC ──────────────────────────────────────────────────────
Write-Step "Disabling UAC (isolated lab VM)"
# EnableLUA=0 disables UAC entirely — admin processes get full token without prompt.
# LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also
# get an elevated token (avoids the filtered-token issue with runProgramInGuest).
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' `
-Name EnableLUA -Value 0 -Type DWord -Force
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' `
-Name LocalAccountTokenFilterPolicy -Value 1 -Type DWord -Force
Write-Host "UAC disabled."
# ── Step 4: CI working directories ───────────────────────────────────────────
Write-Step "Creating CI working directories"
foreach ($dir in @('C:\CI\build', 'C:\CI\output', 'C:\CI\scripts')) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
Write-Host "Created: $dir"
}
}
# ── Step 4b: Explorer settings ───────────────────────────────────────────────
Write-Step "Configuring Explorer defaults"
# Open Explorer to 'This PC' (Devices and Drives) instead of Quick Access.
# LaunchTo=1 = This PC, LaunchTo=2 = Quick Access (default)
$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced'
Set-ItemProperty -Path $explorerAdvKey -Name 'LaunchTo' -Value 1 -Type DWord -Force
# Also apply to the Default User profile so ci_build and any new user inherits it.
$defaultHive = 'C:\Users\Default\NTUSER.DAT'
$mountName = 'TempDefaultUser'
if (Test-Path $defaultHive) {
reg load "HKU\$mountName" $defaultHive | Out-Null
$defKey = "Registry::HKU\$mountName\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced"
if (-not (Test-Path $defKey)) { New-Item -Path $defKey -Force | Out-Null }
Set-ItemProperty -Path $defKey -Name 'LaunchTo' -Value 1 -Type DWord -Force
[gc]::Collect()
reg unload "HKU\$mountName" | Out-Null
Write-Host "Explorer: 'This PC' set for current user and default profile."
}
else {
Write-Host "Explorer: 'This PC' set for current user (default hive not found)."
}
# ── Step 5: Disable Windows Defender ────────────────────────────────────────
Write-Step "Disabling Windows Defender real-time protection"
# Defender scanning significantly slows compilation and NuGet restore.
# On an isolated CI template VM this trade-off is acceptable.
$defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue
if ($defSvc) {
Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null
Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null
Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null
Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null
# Exclude CI build paths from any remaining scanning
Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null
Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null
Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null
Write-Host "Windows Defender real-time protection disabled."
}
else {
Write-Host "Windows Defender service not found — skipping."
}
# Disable via Group Policy registry key — survives Defender auto-updates and reboots.
# This is the same key that SCCM/GPO uses in enterprise environments.
$defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender'
if (-not (Test-Path $defenderKey)) {
New-Item -Path $defenderKey -Force | Out-Null
}
Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force
# Also disable via the Windows Security Center policy subkey
$rtKey = "$defenderKey\Real-Time Protection"
if (-not (Test-Path $rtKey)) {
New-Item -Path $rtKey -Force | Out-Null
}
Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force
Write-Host "Defender GPO registry keys written (persistent across updates)."
# ── Step 6: Install .NET SDK ─────────────────────────────────────────────────
Write-Step "Installing .NET SDK $DotNetSdkVersion"
$dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue
if ($dotnetInstalled) {
$installedVersion = dotnet --version
Write-Host ".NET SDK already installed: $installedVersion"
}
else {
Write-Host "Downloading dotnet-install.ps1..."
$dotnetInstallScript = "$env:TEMP\dotnet-install.ps1"
Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' `
-OutFile $dotnetInstallScript -UseBasicParsing
Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..."
& $dotnetInstallScript `
-Channel $DotNetSdkVersion `
-InstallDir 'C:\dotnet' `
-NoPath
# Add to system PATH
$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($currentPath -notlike '*C:\dotnet*') {
[System.Environment]::SetEnvironmentVariable(
'PATH',
"$currentPath;C:\dotnet",
'Machine'
)
}
$env:PATH = $env:PATH + ';C:\dotnet'
Write-Host ".NET SDK installed: $(dotnet --version)"
}
# ── Step 7: Install Python ───────────────────────────────────────────────────
Write-Step "Installing Python $PythonVersion"
$pythonExe = 'C:\Python\python.exe'
if (Test-Path $pythonExe) {
Write-Host "Python already installed: $(& $pythonExe --version 2>&1)"
}
else {
$pyInstallerUrl = "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe"
$pyInstallerPath = 'C:\CI\python_installer.exe'
Write-Host "Downloading Python $PythonVersion installer..."
Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing
Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..."
$pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @(
'/quiet',
'InstallAllUsers=1',
'PrependPath=1',
'Include_test=0',
'Include_doc=0',
'TargetDir=C:\Python'
) -Wait -PassThru
Remove-Item $pyInstallerPath -ErrorAction SilentlyContinue
if ($pyProc -and $pyProc.ExitCode -ne 0) {
throw "Python installation failed (exit $($pyProc.ExitCode))."
}
# Refresh PATH for subsequent steps
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Write-Host "Python installed: $(python --version 2>&1)"
}
# ── Step 8: Install Visual Studio Build Tools 2022 ───────────────────────────
Write-Step "Installing Visual Studio Build Tools 2026"
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
if (Test-Path $msbuildPath) {
Write-Host "VS Build Tools 2026 already installed."
}
else {
$vsInstallerUrl = 'https://aka.ms/vs/stable/vs_buildtools.exe'
$vsInstallerPath = 'C:\CI\vs_BuildTools.exe'
$vsResultPath = 'C:\CI\vs_result.txt'
$vsLogPath = 'C:\CI\vs_log.txt'
Write-Host "Downloading VS Build Tools installer..."
Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy
Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing
# Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet).
# SYSTEM account cannot execute them without this unblock step.
Unblock-File -Path $vsInstallerPath -ErrorAction SilentlyContinue
# Verify the download produced a valid Windows PE file (MZ header)
$mzBytes = [System.IO.File]::ReadAllBytes($vsInstallerPath) | Select-Object -First 2
if (-not ($mzBytes[0] -eq 0x4D -and $mzBytes[1] -eq 0x5A)) {
$fileSize = (Get-Item $vsInstallerPath).Length
throw "VS installer download appears invalid (size=$fileSize, not an EXE). Check the URL: $vsInstallerUrl"
}
Write-Host "Installer verified OK ($((Get-Item $vsInstallerPath).Length) bytes)."
# VS installer fails under WinRM (network logon token). Run via Scheduled Task as SYSTEM.
$vsWorkerPath = 'C:\CI\vs_worker.ps1'
$vsWorker = @"
# SYSTEM account may have no TEMP set use C:\Windows\Temp
`$env:TEMP = 'C:\Windows\Temp'
`$env:TMP = 'C:\Windows\Temp'
# Clean any corrupt VS installer cache left by previous failed attempts
Remove-Item 'C:\ProgramData\Microsoft\VisualStudio\Packages' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\Windows\Temp\vs*' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\Windows\Temp\dd_*' -Force -ErrorAction SilentlyContinue
`$result = try {
`$proc = Start-Process -FilePath '$vsInstallerPath' -ArgumentList @(
'--quiet','--wait','--norestart','--nocache',
'--installPath "C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools"',
'--add','Microsoft.VisualStudio.Workload.VCTools',
'--includeRecommended',
'--add','Microsoft.VisualStudio.Workload.MSBuildTools',
'--add','Microsoft.VisualStudio.Workload.ManagedDesktopBuildTools',
'--add','Microsoft.VisualStudio.Component.NuGet.BuildTools',
'--add','Microsoft.Net.Component.4.8.SDK',
'--add','Microsoft.Net.Component.4.7.2.TargetingPack'
) -Wait -PassThru
if (`$proc) { [string]`$proc.ExitCode } else { '99' }
} catch {
"99: `$_"
}
if (-not `$result) { `$result = '99' }
`$result | Set-Content '$vsResultPath' -Encoding UTF8
"@
$vsWorker | Set-Content -Path $vsWorkerPath -Encoding UTF8
Remove-Item $vsResultPath -ErrorAction SilentlyContinue
$taskName = 'CI-VSBuildTools'
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$vsWorkerPath`""
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
Write-Host "Installing VS Build Tools via Scheduled Task (this may take 15-30 minutes)..."
Start-ScheduledTask -TaskName $taskName
$timeout = [datetime]::UtcNow.AddMinutes(60)
$dots = 0
while (-not (Test-Path $vsResultPath)) {
if ([datetime]::UtcNow -gt $timeout) { throw "VS Build Tools installation timed out." }
# If task already finished without writing the result file, the worker itself crashed
$taskInfo = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($taskInfo -and $taskInfo.State -eq 'Ready') {
$lastResult = (Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue).LastTaskResult
throw "VS worker task exited prematurely (LastTaskResult=0x$('{0:X}' -f $lastResult)). Check C:\CI\vs_worker.ps1 for syntax errors."
}
Start-Sleep -Seconds 20
$dots++
if ($dots % 3 -eq 0) { Write-Host " ... still installing ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" }
}
$rawResult = Get-Content $vsResultPath -Raw -ErrorAction SilentlyContinue
$rawResult = if ($rawResult) { $rawResult.Trim() } else { '99' }
# Result file may contain "0", "3010", or "99: <error message>"
$vsExit = if ($rawResult -match '^\d+$') { [int]$rawResult } else { 99 }
$vsMsg = if ($vsExit -eq 99) { $rawResult } else { '' }
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item $vsWorkerPath, $vsResultPath, $vsInstallerPath -ErrorAction SilentlyContinue
if ($vsExit -notin @(0, 3010)) {
throw "VS Build Tools installation failed (exit $vsExit). $vsMsg"
}
Write-Host "VS Build Tools 2026 installed (exit code $vsExit)."
}
# Add MSBuild to system PATH
$msbuildDir = Split-Path $msbuildPath -Parent
$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($currentPath -notlike "*$msbuildDir*") {
[System.Environment]::SetEnvironmentVariable(
'PATH',
"$currentPath;$msbuildDir",
'Machine'
)
Write-Host "MSBuild added to system PATH."
}
# ── Step 9: Verify toolchain ───────────────────────────────────────────────
Write-Step "Verifying toolchain"
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
$allOk = $true
# Helper: resolve a binary — tries hardcoded path first, then Get-Command (PATH)
function Resolve-Tool {
param([string]$HardcodedPath, [string]$CommandName)
if ($HardcodedPath -and (Test-Path $HardcodedPath -PathType Leaf)) {
return $HardcodedPath
}
$found = Get-Command $CommandName -ErrorAction SilentlyContinue
if ($found) { return $found.Source }
return $null
}
# dotnet
$dotnetBin = Resolve-Tool '' 'dotnet'
if ($dotnetBin) {
$v = & $dotnetBin --version 2>&1 | Select-Object -First 1
Write-Host " [OK] dotnet: $v ($dotnetBin)"
} else {
Write-Warning " [FAIL] dotnet not found"
$allOk = $false
}
# python
$pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python'
if ($pythonBin) {
$v = & $pythonBin --version 2>&1 | Select-Object -First 1
Write-Host " [OK] python: $v ($pythonBin)"
} else {
Write-Warning " [FAIL] python not found"
$allOk = $false
}
# msbuild
$msbuildBin = Resolve-Tool $msbuildPath 'msbuild'
if ($msbuildBin) {
$v = & $msbuildBin -version 2>&1 | Select-Object -First 1
Write-Host " [OK] msbuild: $v ($msbuildBin)"
} else {
Write-Warning " [FAIL] msbuild not found at '$msbuildPath' and not in PATH"
$allOk = $false
}
if (-not $allOk) {
Write-Warning "Some tools failed verification. Review above and rerun if needed."
}
# ── Cleanup ───────────────────────────────────────────────────────────────────
Write-Step "Cleaning up disk"
# 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags
# First register the flags via /sageset:1 in the registry (silent, no UI)
$cleanKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches'
$sageset = 1
$cleanCategories = @(
'Active Setup Temp Folders',
'BranchCache',
'Downloaded Program Files',
'Internet Cache Files',
'Memory Dump Files',
'Old ChkDsk Files',
'Previous Installations',
'Recycle Bin',
'Service Pack Cleanup',
'Setup Log Files',
'System error memory dump files',
'System error minidump files',
'Temporary Files',
'Temporary Setup Files',
'Thumbnail Cache',
'Update Cleanup',
'Upgrade Discarded Files',
'Windows Defender',
'Windows Error Reporting Archive Files',
'Windows Error Reporting Files',
'Windows Error Reporting Queue Files',
'Windows Error Reporting Temp Files',
'Windows ESD installation files',
'Windows Upgrade Log Files'
)
foreach ($cat in $cleanCategories) {
$keyPath = "$cleanKey\$cat"
if (Test-Path $keyPath) {
Set-ItemProperty -Path $keyPath -Name "StateFlags$('{0:D4}' -f $sageset)" -Value 2 -Type DWord -Force -ErrorAction SilentlyContinue
}
}
Write-Host "Running cleanmgr /sagerun:$sageset (may take a few minutes)..."
$proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" -Wait -PassThru
Write-Host "cleanmgr exited (code $($proc.ExitCode))."
# 2. Clear Windows Update cache
Write-Host "Stopping Windows Update service..."
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\Windows\SoftwareDistribution\Download\*' -Recurse -Force -ErrorAction SilentlyContinue
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
Write-Host "Windows Update download cache cleared."
# 3. Clear CBS / component store logs
Remove-Item 'C:\Windows\Logs\CBS\*' -Force -ErrorAction SilentlyContinue
# 4. Clear TEMP folders
Remove-Item 'C:\Windows\Temp\*' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
# 5. Clear Prefetch
Remove-Item 'C:\Windows\Prefetch\*' -Force -ErrorAction SilentlyContinue
# 6. Run DISM component store cleanup (removes superseded update components)
Write-Host "Running DISM component store cleanup (StartComponentCleanup)..."
$dism = Start-Process -FilePath 'dism.exe' `
-ArgumentList '/Online','/Cleanup-Image','/StartComponentCleanup','/ResetBase' `
-Wait -PassThru
Write-Host "DISM exited (code $($dism.ExitCode))."
Write-Host "Disk cleanup complete."
# ── Done ──────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM setup complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " MANDATORY NEXT STEPS:"
Write-Host ""
Write-Host " 1. SWITCH NIC TO HOST-ONLY (critical before snapshot!):"
Write-Host " VMware Workstation -> VM -> Settings -> Network Adapter"
Write-Host " Change from: VMnet8 (NAT)"
Write-Host " Change to: Custom: VMnet11"
Write-Host ""
Write-Host " 2. Shut down this VM: Start -> Shut down"
Write-Host ""
Write-Host " 3. Take snapshot in VMware Workstation:"
Write-Host " VM -> Snapshot -> Take Snapshot"
Write-Host " Name it EXACTLY: BaseClean"
Write-Host ""
Write-Host " 4. Leave VM powered off permanently."
Write-Host ""
Write-Host " 5. On the HOST - store credentials in Windows Credential Manager:"
Write-Host " New-StoredCredential -Target 'BuildVMGuest' -UserName '$BuildUsername' ``"
Write-Host " -Password '$BuildPassword' -Persist LocalMachine"
Write-Host " (Run in an elevated PowerShell with CredentialManager module)"
Write-Host ""