ndt.psm1
|
function Install-NDT { <# .SYNOPSIS Installs and configures an NDT deployment share. .DESCRIPTION Bootstraps an NDT deployment share by downloading the repository ZIP from GitHub, extracting it directly into the target LocalPath (preserving the full folder structure), stamping the Deploy section of Control\CustomSettings.json with the supplied parameters, creating the Windows SMB share, and granting the deploy account the required permissions. .PARAMETER LocalPath Local filesystem path where the deployment share will be created. Default: C:\Deploy2026 .PARAMETER ShareName Name of the Windows SMB share to create. Default: Deploy2026 .PARAMETER ShareUNC Full UNC path used to access the deployment share (stored in CustomSettings.json). Default: \\<current hostname>\Deploy2026 .PARAMETER DeployUsername Domain account used by clients to connect to the deployment share. Default: Corp\Deploy2026 .PARAMETER DeployPassword Password for the deploy account as a SecureString. Stored in CustomSettings.json. Mandatory - you will be prompted if not supplied. .PARAMETER RepoZipUrl URL of the GitHub repository archive ZIP to download seed control files from. Default: https://github.com/AB-Lindex/NDT-NextGenerationDeploymentToolkit/archive/refs/heads/main.zip .EXAMPLE Install-NDT .EXAMPLE Install-NDT -LocalPath D:\Deploy2026 -ShareName Deploy2026 -DeployUsername "Corp\Deploy2026" .EXAMPLE Install-NDT -RepoZipUrl 'https://github.com/AB-Lindex/NDT-NextGenerationDeploymentToolkit/archive/refs/heads/dev.zip' #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter()] [string]$ShareName = 'Deploy2026', [Parameter()] [string]$ShareUNC = "\\$($env:COMPUTERNAME)\Deploy2026", [Parameter()] [string]$DeployUsername = 'Corp\Deploy2026', [Parameter(Mandatory)] [SecureString]$DeployPassword, [Parameter()] [string]$RepoZipUrl = 'https://github.com/AB-Lindex/NDT-NextGenerationDeploymentToolkit/archive/refs/heads/main.zip', [Parameter()] [int]$MonitorPort = 443, [Parameter()] [switch]$SkipMonitor ) #region -- Download and extract repository ZIP into LocalPath ---------------- $tempZip = Join-Path $env:TEMP 'ndt-repo.zip' $tempDir = Join-Path $env:TEMP 'ndt-repo' try { Write-Verbose "Downloading NDT repository ZIP from '$RepoZipUrl'..." if ($PSCmdlet.ShouldProcess($RepoZipUrl, 'Download repository ZIP')) { Invoke-WebRequest -Uri $RepoZipUrl -OutFile $tempZip -UseBasicParsing Write-Verbose " Downloaded: $tempZip" } if ($PSCmdlet.ShouldProcess($tempDir, 'Extract repository ZIP')) { if (Test-Path $tempDir) { Remove-Item $tempDir -Recurse -Force } Expand-Archive -Path $tempZip -DestinationPath $tempDir -Force Write-Verbose " Extracted to: $tempDir" } # GitHub archive ZIPs always extract into a single top-level folder # (e.g. NDT-NextGenerationDeploymentToolkit-main). Find it. $repoRoot = Get-ChildItem -Path $tempDir -Directory | Select-Object -First 1 -ExpandProperty FullName if (-not $repoRoot) { throw 'Could not locate repository root in the downloaded ZIP.' } Write-Verbose " Repository root: $repoRoot" if ($PSCmdlet.ShouldProcess($LocalPath, 'Copy repository content to LocalPath')) { if (-not (Test-Path $LocalPath)) { New-Item -ItemType Directory -Path $LocalPath -Force | Out-Null } Copy-Item -Path (Join-Path $repoRoot '*') -Destination $LocalPath -Recurse -Force Write-Verbose " Repository content copied to: $LocalPath" # Remove files/folders that belong only in the source repository and # must not exist on a live deployment share. $repoOnlyItems = @('.github', '.vscode', '.gitignore', 'README.md') foreach ($item in $repoOnlyItems) { $itemPath = Join-Path $LocalPath $item if (Test-Path $itemPath) { Remove-Item -Path $itemPath -Recurse -Force -ErrorAction SilentlyContinue Write-Verbose " Removed repo-only item: $item" } } # Remove all .gitkeep placeholder files used to preserve empty folders in git Get-ChildItem -Path $LocalPath -Filter '.gitkeep' -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue Write-Verbose " Removed .gitkeep: $($_.FullName)" } # Ensure mandatory share folders exist (git does not track empty directories) $mandatoryFolders = @('Operating Systems', 'Reference', 'Scratch') foreach ($folder in $mandatoryFolders) { $folderPath = Join-Path $LocalPath $folder if (-not (Test-Path $folderPath)) { New-Item -ItemType Directory -Path $folderPath -Force | Out-Null Write-Verbose " Created mandatory folder: $folder" } } } } finally { if (Test-Path $tempZip) { Remove-Item $tempZip -Force -ErrorAction SilentlyContinue } if (Test-Path $tempDir) { Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue } } #endregion #region -- Stamp Deploy section of Sections.json with parameters ------------- $controlDir = Join-Path $LocalPath 'Control' $sectionsDest = Join-Path $controlDir 'Sections.json' if (-not (Test-Path $sectionsDest)) { Write-Warning "Sections.json not found at '$sectionsDest' - skipping stamp." } else { if ($PSCmdlet.ShouldProcess($sectionsDest, 'Stamp Deploy section')) { # Decode the SecureString to plain text only long enough to write the file. $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($DeployPassword) $plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) $settings = Get-Content $sectionsDest -Raw | ConvertFrom-Json $settings.Deploy.Share = $ShareUNC $settings.Deploy.Username = $DeployUsername $settings.Deploy.Password = $plainPassword # Derive the monitor URL from the share host so clients reach it the same # way they reach the share (e.g. \\ndt01\Deploy2026 -> https://ndt01:443). $shareHost = ($ShareUNC.TrimStart('\') -split '\\')[0] $monitorUrl = "https://${shareHost}:$MonitorPort" if ($settings.Deploy.PSObject.Properties.Name -contains 'MonitorUrl') { $settings.Deploy.MonitorUrl = $monitorUrl } else { $settings.Deploy | Add-Member -NotePropertyName MonitorUrl -NotePropertyValue $monitorUrl } $settings | ConvertTo-Json -Depth 10 | Set-Content -Path $sectionsDest -Encoding UTF8 Write-Verbose 'Deploy section stamped in Sections.json.' $plainPassword = $null } } #endregion #region -- SMB share --------------------------------------------------------- $existingShare = Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue if ($existingShare) { Write-Verbose "SMB share '$ShareName' already exists - skipping creation." } else { if ($PSCmdlet.ShouldProcess($ShareName, "Create SMB share pointing to '$LocalPath'")) { New-SmbShare -Name $ShareName -Path $LocalPath -Description 'NDT Deployment Share' | Out-Null Write-Verbose "Created SMB share '$ShareName' -> '$LocalPath'." } } #endregion #region -- Share permissions ------------------------------------------------- # Grant the deploy account Full Access on the share. $existingAccess = Get-SmbShareAccess -Name $ShareName -ErrorAction SilentlyContinue | Where-Object { $_.AccountName -eq $DeployUsername } if (-not $existingAccess) { if ($PSCmdlet.ShouldProcess($DeployUsername, "Grant Full access to share '$ShareName'")) { Grant-SmbShareAccess -Name $ShareName -AccountName $DeployUsername ` -AccessRight Full -Force | Out-Null Write-Verbose "Granted Full access on '$ShareName' to '$DeployUsername'." } } else { Write-Verbose "Access for '$DeployUsername' on '$ShareName' already configured." } # Revoke the built-in Everyone read access that New-SmbShare adds by default. $everyoneAccess = Get-SmbShareAccess -Name $ShareName -ErrorAction SilentlyContinue | Where-Object { $_.AccountName -eq 'Everyone' } if ($everyoneAccess) { if ($PSCmdlet.ShouldProcess('Everyone', "Revoke access from share '$ShareName'")) { Revoke-SmbShareAccess -Name $ShareName -AccountName 'Everyone' -Force | Out-Null Write-Verbose "Revoked Everyone access from '$ShareName'." } } #endregion #region -- NDT Monitor (IIS progress web service) ---------------------------- if ($SkipMonitor) { Write-Verbose 'NDT Monitor installation skipped (-SkipMonitor).' } else { try { Install-NDTMonitor -LocalPath $LocalPath -Port $MonitorPort } catch { Write-Warning "NDT Monitor installation failed: $_" Write-Warning 'The deployment share is still usable. Re-run Install-NDTMonitor to retry.' } } #endregion Write-Host "NDT deployment share installed successfully." -ForegroundColor Green Write-Host " Local path : $LocalPath" Write-Host " Share : \\$(hostname)\$ShareName" Write-Host " UNC (ref) : $ShareUNC" Write-Host " Deploy user: $DeployUsername" Write-Host "" Write-Host "Edit Control\CustomSettings.json to match your environment before deploying." -ForegroundColor Cyan Write-Host "" Write-Host "Next steps:" -ForegroundColor Cyan Write-Host "" Write-Host " 1. Build the WinPE boot image (WIM and optionally ISO):" -ForegroundColor White Write-Host " New-NDTPEImage" -ForegroundColor Gray Write-Host " This requires the Windows ADK and WinPE Add-on to be installed." -ForegroundColor Gray Write-Host " The resulting Boot\boot2026.wim is registered automatically in WDS." -ForegroundColor Gray Write-Host " Use -SkipISO if you only need a WDS-served WIM (no ISO file)." -ForegroundColor Gray Write-Host " Use -SkipWDS if WDS is not yet configured on this server." -ForegroundColor Gray Write-Host "" Write-Host " 2. Import operating system images (WIM files):" -ForegroundColor White Write-Host " Add-NDTOs -Key WIN2025DCG -Path 'Operating Systems\<folder>\install.wim' -Index 1" -ForegroundColor Gray Write-Host " Place WIM files under $LocalPath\Operating Systems\" -ForegroundColor Gray Write-Host " Use Get-NDTOs to list registered entries and Set-NDTOs to update them." -ForegroundColor Gray Write-Host " If you captured a reference image, use Move-NDTReferenceImage to move it" -ForegroundColor Gray Write-Host " from the Reference\ folder into Operating Systems\ automatically." -ForegroundColor Gray Write-Host "" Write-Host " NOTE: Certain files are not included in the repository and must be added" -ForegroundColor Yellow Write-Host " manually to the deployment share after installation. Examples:" -ForegroundColor Yellow Write-Host " - Certificate files (.pfx, .cer) used by application installers" -ForegroundColor Yellow Write-Host " - WIM files for operating systems and reference images" -ForegroundColor Yellow Write-Host " - Licensed or third-party application installers" -ForegroundColor Yellow Write-Host " - Site-specific content under Applications2026\" -ForegroundColor Yellow } function Install-NDTMonitor { <# .SYNOPSIS Installs the NDT deployment-progress web service in IIS (idempotent). .DESCRIPTION Provisions an IIS-hosted REST endpoint that receives deployment progress updates from each deploying machine (WinPE phase and the post-image step engine) - the NDT equivalent of the MDT monitoring web service. The function is fully idempotent and performs the following steps: 1. Installs the required IIS roles/features (Web-Server, ASP.NET 4.x, ISAPI, management + scripting tools). Install-WindowsFeature skips anything already present. 2. Creates the progress-log folder. 3. Deploys the site content (web.config, App_Code\ProgressHandler.cs, Default.htm dashboard) from install\NDTMonitor into the site path, stamping the real log-folder path into web.config. 4. Imports the PFX certificate into LocalMachine\My (idempotent by thumbprint). 5. Creates / updates a dedicated application pool (.NET v4.0, Integrated). 6. Creates / updates the HTTPS website bound to the requested port, removes any existing HTTP bindings, and attaches the imported certificate. 7. Grants the app-pool identity Modify rights on the log folder. 8. Opens the inbound firewall port. 9. Starts the app pool and website. Endpoints (served on https://<host>:<Port>): POST /progress receive a progress update (JSON body) GET /progress all machine states as a JSON array GET /progress?mac=.. a single machine's latest state GET / live HTML dashboard (Default.htm) .PARAMETER LocalPath Root of the NDT deployment share (source of the site content). Default: C:\Deploy2026 .PARAMETER Port TCP port the monitor website listens on. Default: 443 .PARAMETER SitePath Physical path for the IIS site. Default: C:\inetpub\ndtmonitor .PARAMETER LogRoot Folder where per-machine progress JSON is stored. Default: <LocalPath>\Logs\progress .PARAMETER SiteName IIS website name. Default: NDTMonitor .PARAMETER AppPoolName IIS application pool name. Default: NDTMonitor .PARAMETER CertificatePath Path to the PFX certificate file to bind to the HTTPS site. Default: <LocalPath>\install\NDTMonitor\ndt01.corp.dev.pfx .PARAMETER CertificatePassword Password for the PFX certificate as a SecureString. If not supplied, uses the standard NDT Monitor certificate password. .EXAMPLE Install-NDTMonitor .EXAMPLE Install-NDTMonitor -Port 8443 -Verbose #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter()] [int]$Port = 443, [Parameter()] [string]$SitePath = 'C:\inetpub\ndtmonitor', [Parameter()] [string]$LogRoot, [Parameter()] [string]$SiteName = 'NDTMonitor', [Parameter()] [string]$AppPoolName = 'NDTMonitor', [Parameter()] [string]$CertificatePath, [Parameter()] [SecureString]$CertificatePassword ) # -- Verify Administrator ---------------------------------------------------- $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { throw 'Install-NDTMonitor must be run as Administrator.' } # -- Ensure Windows PowerShell 5.1 ------------------------------------------- # The WebAdministration IIS: PSDrive provider does not work under PowerShell 7+. # When invoked from PS7 (e.g. by Install-NDT running in pwsh), relaunch this # command in Windows PowerShell 5.1 and return. if ($PSVersionTable.PSVersion.Major -ge 6) { Write-Host 'Install-NDTMonitor requires Windows PowerShell 5.1 (IIS provider) - relaunching under powershell.exe...' -ForegroundColor Yellow $ps5 = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' if (-not (Test-Path $ps5)) { throw 'Windows PowerShell 5.1 (powershell.exe) not found - cannot install the IIS monitor.' } $manifest = Join-Path $LocalPath 'install\NDT\ndt.psd1' if (-not (Test-Path $manifest)) { throw "NDT module manifest not found at: $manifest" } $inner = "Import-Module '$manifest' -Force; Install-NDTMonitor -LocalPath '$LocalPath' -Port $Port -SitePath '$SitePath' -SiteName '$SiteName' -AppPoolName '$AppPoolName'" if ($PSBoundParameters.ContainsKey('LogRoot') -and $LogRoot) { $inner += " -LogRoot '$LogRoot'" } if ($CertificatePath) { $inner += " -CertificatePath '$CertificatePath'" } if ($CertificatePassword) { $bstr5 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($CertificatePassword) $plain5 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr5) [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr5) $inner += " -CertificatePassword (ConvertTo-SecureString '$plain5' -AsPlainText -Force)" $plain5 = $null } if ($VerbosePreference -eq 'Continue') { $inner += ' -Verbose' } & $ps5 -NoProfile -ExecutionPolicy Bypass -Command $inner if ($LASTEXITCODE -ne 0) { Write-Warning "Windows PowerShell 5.1 monitor install exited with code $LASTEXITCODE." } return } if (-not $LogRoot) { $LogRoot = Join-Path $LocalPath 'Logs\progress' } if (-not $CertificatePath) { $CertificatePath = Join-Path $LocalPath 'install\NDTMonitor\ndt01.corp.dev.pfx' } if (-not $CertificatePassword) { $CertificatePassword = ConvertTo-SecureString '1q2w3e4r' -AsPlainText -Force } $sourceDir = Join-Path $LocalPath 'install\NDTMonitor' if (-not (Test-Path $sourceDir)) { throw "NDT Monitor source content not found at: $sourceDir" } if (-not (Test-Path $CertificatePath)) { throw "Certificate not found at: $CertificatePath -- place the PFX file there before running Install-NDTMonitor." } Write-Host "`nInstalling NDT Monitor (IIS)..." -ForegroundColor Cyan # -- Step 1: Install IIS roles/features (idempotent) ------------------------- Write-Host 'Step 1: Ensuring IIS roles and features...' -ForegroundColor Cyan $features = @( 'Web-Server', 'Web-Common-Http', 'Web-Static-Content', 'Web-Default-Doc', 'Web-Http-Errors', 'Web-Net-Ext45', 'Web-Asp-Net45', 'Web-ISAPI-Ext', 'Web-ISAPI-Filter', 'Web-Mgmt-Console', 'Web-Scripting-Tools' ) if ($PSCmdlet.ShouldProcess('IIS', "Install features: $($features -join ', ')")) { # Install-WindowsFeature is inherently idempotent - installed features are skipped. $result = Install-WindowsFeature -Name $features -IncludeManagementTools -ErrorAction Stop if ($result.RestartNeeded -eq 'Yes') { Write-Warning ' A restart is required to complete IIS feature installation.' } Write-Host ' [OK] IIS features present' -ForegroundColor Green } Import-Module WebAdministration -ErrorAction Stop # -- Step 2: Create the progress-log folder ---------------------------------- Write-Host 'Step 2: Ensuring progress-log folder...' -ForegroundColor Cyan if (-not (Test-Path $LogRoot)) { New-Item -ItemType Directory -Path $LogRoot -Force | Out-Null Write-Host " [OK] Created: $LogRoot" -ForegroundColor Green } else { Write-Host " [OK] Exists: $LogRoot" -ForegroundColor Gray } # -- Step 3: Deploy site content --------------------------------------------- Write-Host 'Step 3: Deploying site content...' -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($SitePath, 'Deploy NDT Monitor site content')) { if (-not (Test-Path $SitePath)) { New-Item -ItemType Directory -Path $SitePath -Force | Out-Null } # Copy App_Code, Default.htm and web.config (overwrite = idempotent). Copy-Item -Path (Join-Path $sourceDir '*') -Destination $SitePath -Recurse -Force # Stamp the real LogRoot into the deployed web.config appSetting. $webConfig = Join-Path $SitePath 'web.config' $xml = [xml](Get-Content $webConfig -Raw) $node = $xml.configuration.appSettings.add | Where-Object { $_.key -eq 'LogRoot' } if ($node) { $node.value = $LogRoot } else { $add = $xml.CreateElement('add') $add.SetAttribute('key', 'LogRoot') $add.SetAttribute('value', $LogRoot) $xml.configuration.appSettings.AppendChild($add) | Out-Null } $xml.Save($webConfig) Write-Host " [OK] Content deployed to $SitePath (LogRoot -> $LogRoot)" -ForegroundColor Green } # -- Step 4: Import SSL certificate ------------------------------------------ Write-Host 'Step 4: Importing SSL certificate...' -ForegroundColor Cyan $thumbprint = $null if ($PSCmdlet.ShouldProcess($CertificatePath, 'Import PFX certificate into LocalMachine\My store')) { # Read the thumbprint from the PFX without importing first so the import is idempotent. $certObj = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( $CertificatePath, $CertificatePassword, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::DefaultKeySet ) $thumbprint = $certObj.Thumbprint $certObj.Dispose() $existingCert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq $thumbprint } | Select-Object -First 1 if ($existingCert) { Write-Host " [OK] Certificate already in store (thumbprint: $thumbprint)" -ForegroundColor Gray } else { Import-PfxCertificate -FilePath $CertificatePath ` -Password $CertificatePassword ` -CertStoreLocation Cert:\LocalMachine\My | Out-Null Write-Host " [OK] Certificate imported (thumbprint: $thumbprint)" -ForegroundColor Green } } # -- Step 5: Application pool ------------------------------------------------- Write-Host 'Step 5: Configuring application pool...' -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($AppPoolName, 'Create/update IIS application pool')) { if (-not (Test-Path "IIS:\AppPools\$AppPoolName")) { New-WebAppPool -Name $AppPoolName | Out-Null Write-Host " [OK] Created app pool: $AppPoolName" -ForegroundColor Green } else { Write-Host " [OK] App pool exists: $AppPoolName" -ForegroundColor Gray } Set-ItemProperty "IIS:\AppPools\$AppPoolName" -Name managedRuntimeVersion -Value 'v4.0' Set-ItemProperty "IIS:\AppPools\$AppPoolName" -Name managedPipelineMode -Value 'Integrated' Set-ItemProperty "IIS:\AppPools\$AppPoolName" -Name startMode -Value 'AlwaysRunning' } # -- Step 6: Website (HTTPS) -------------------------------------------------- Write-Host 'Step 6: Configuring website (HTTPS)...' -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($SiteName, "Create/update website on port $Port (HTTPS)")) { $existingSite = Get-Website -Name $SiteName -ErrorAction SilentlyContinue if (-not $existingSite) { # New-Website creates an HTTP binding by default; remove it, then add HTTPS. New-Website -Name $SiteName -Port $Port -PhysicalPath $SitePath ` -ApplicationPool $AppPoolName -Force | Out-Null Get-WebBinding -Name $SiteName -Protocol http -ErrorAction SilentlyContinue | Remove-WebBinding -ErrorAction SilentlyContinue Write-Host " [OK] Created website: $SiteName" -ForegroundColor Green } else { Set-ItemProperty "IIS:\Sites\$SiteName" -Name physicalPath -Value $SitePath Set-ItemProperty "IIS:\Sites\$SiteName" -Name applicationPool -Value $AppPoolName # Remove any stale HTTP bindings (idempotent transition from HTTP to HTTPS). Get-WebBinding -Name $SiteName -Protocol http -ErrorAction SilentlyContinue | Remove-WebBinding -ErrorAction SilentlyContinue Write-Host " [OK] Website updated: $SiteName" -ForegroundColor Green } # Ensure an HTTPS binding exists on the requested port. $hasHttpsBinding = Get-WebBinding -Name $SiteName -Protocol https -ErrorAction SilentlyContinue | Where-Object { $_.bindingInformation -like "*:${Port}:*" } if (-not $hasHttpsBinding) { New-WebBinding -Name $SiteName -Protocol https -Port $Port -IPAddress '*' -SslFlags 0 | Out-Null Write-Host " [OK] HTTPS binding added (port $Port)" -ForegroundColor Green } else { Write-Host " [OK] HTTPS binding exists (port $Port)" -ForegroundColor Gray } # Bind the SSL certificate to the port (idempotent: only replace if thumbprint differs). if ($thumbprint) { $sslBind = Get-Item "IIS:\SslBindings\0.0.0.0!$Port" -ErrorAction SilentlyContinue if ($sslBind -and $sslBind.Thumbprint -eq $thumbprint) { Write-Host " [OK] SSL certificate already bound (thumbprint: $thumbprint)" -ForegroundColor Gray } else { if ($sslBind) { Remove-Item "IIS:\SslBindings\0.0.0.0!$Port" -ErrorAction SilentlyContinue } $httpsWebBinding = Get-WebBinding -Name $SiteName -Protocol https | Where-Object { $_.bindingInformation -like "*:${Port}:*" } | Select-Object -First 1 $httpsWebBinding.AddSslCertificate($thumbprint, 'MY') Write-Host " [OK] SSL certificate bound (thumbprint: $thumbprint)" -ForegroundColor Green } } else { Write-Warning " SSL thumbprint unavailable (ShouldProcess skipped step 4) -- HTTPS binding has no certificate." } } # -- Step 7: Grant app-pool identity write access to the log folder ---------- Write-Host 'Step 7: Granting log-folder permissions...' -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($LogRoot, "Grant Modify to IIS AppPool\$AppPoolName")) { # icacls re-grant is idempotent (updates the existing ACE). icacls $LogRoot /grant "IIS AppPool\${AppPoolName}:(OI)(CI)M" /T /C | Out-Null Write-Host " [OK] Modify granted to 'IIS AppPool\$AppPoolName'" -ForegroundColor Green } # -- Step 8: Firewall rule (idempotent) -------------------------------------- Write-Host 'Step 8: Ensuring firewall rule...' -ForegroundColor Cyan $ruleName = "NDT Monitor $Port" if ($PSCmdlet.ShouldProcess($ruleName, 'Create inbound firewall rule')) { $existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue if (-not $existingRule) { New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow ` -Protocol TCP -LocalPort $Port | Out-Null Write-Host " [OK] Firewall rule created: $ruleName" -ForegroundColor Green } else { Write-Host " [OK] Firewall rule exists: $ruleName" -ForegroundColor Gray } } # -- Step 9: Start app pool + site ------------------------------------------- Write-Host 'Step 9: Starting monitor...' -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($SiteName, 'Start app pool and website')) { if ((Get-WebAppPoolState -Name $AppPoolName).Value -ne 'Started') { Start-WebAppPool -Name $AppPoolName } if ((Get-WebsiteState -Name $SiteName).Value -ne 'Started') { Start-Website -Name $SiteName } Write-Host ' [OK] Monitor started' -ForegroundColor Green } $hostName = [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName Write-Host "`nNDT Monitor installed." -ForegroundColor Green Write-Host " Dashboard : https://${hostName}:$Port/" -ForegroundColor White Write-Host " Endpoint : https://${hostName}:$Port/progress" -ForegroundColor White Write-Host " Log folder: $LogRoot" -ForegroundColor Gray } function New-NDTPEImage { <# .SYNOPSIS Builds the NDT WinPE boot WIM and optionally a bootable ISO. .DESCRIPTION Performs the full PE media build pipeline: 1. Generates settings.json from the Deploy section of CustomSettings.json and writes it into the WindowsPE\Deploy folder. 2. Creates a fresh WinPE staging tree from the ADK base using copype (always builds clean - never patches an existing WIM). 3. Mounts the staging boot.wim with DISM. 4. Adds required WinPE optional packages in dependency order: WinPE-WMI, WinPE-NetFx, WinPE-Scripting, WinPE-PowerShell, WinPE-StorageWMI, WinPE-DismCmdlets. 5. Injects the Deploy folder (install.ps1 + settings.json) and Unattend.xml (wpeinit + install.ps1 autorun) into the mounted image. 6. Commits the WIM and copies it to Boot\boot2026.wim. 7. Updates the WDS boot image (unless -SkipWDS is specified). 8. Creates a hybrid BIOS/EFI bootable ISO using MakeWinPEMedia (unless -SkipISO is specified). Requires the Windows ADK and WinPE Add-on: https://learn.microsoft.com/windows-hardware/get-started/adk-install .PARAMETER LocalPath Root of the NDT deployment share on this machine. Default: C:\Deploy2026 .PARAMETER MountDir Temporary directory used to mount the WIM during the build. Default: C:\WinPE_Mount .PARAMETER IsoStagingDir Temporary directory used by copype when building the ISO media tree. Default: C:\WinPE_ISO_Staging .PARAMETER SkipWDS Skip the WDS boot-image update step (Step 7). .PARAMETER SkipISO Skip ISO creation (Step 8). Useful when only a WDS-served WIM is needed. .PARAMETER RegisterOnly Skip the full PE build (Steps 1-6) and register the existing Boot\boot2026.wim directly in WDS. Implies -SkipISO. Use this when the WIM is already built and only the WDS boot-image registration needs to be refreshed. .PARAMETER DeploySection Name of the top-level key in Sections.json to read share credentials from. Default: Deploy. Use this when the NDT server being built uses an alternate deploy section (e.g. DeployDC01). .EXAMPLE New-NDTPEImage .EXAMPLE New-NDTPEImage -SkipISO -Verbose .EXAMPLE New-NDTPEImage -LocalPath D:\Deploy2026 -SkipWDS .EXAMPLE New-NDTPEImage -RegisterOnly #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter()] [string]$MountDir = 'C:\WinPE_Mount', [Parameter()] [string]$IsoStagingDir = 'C:\WinPE_ISO_Staging', [Parameter()] [switch]$SkipWDS, [Parameter()] [switch]$SkipISO, [Parameter()] [switch]$RegisterOnly, [Parameter()] [string]$DeploySection = 'Deploy' ) # -- Verify Administrator ---------------------------------------------------- $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { throw 'New-NDTPEImage must be run as Administrator.' } # -- Pre-flight: verify WDS is configured (skip if -SkipWDS) ---------------- if (-not $SkipWDS) { # Two-stage check: # 1. Service exists - the WDS role is installed. # 2. wdsutil /get-server exit code - 0 means WDS is initialized and configured; # non-zero means the role is present but wdsutil /Initialize-Server has not # been run yet (or the service is stopped/broken). # Note: the WdsInstallState registry value documented in older guides is not # present on all Windows Server versions and cannot be relied upon. $wdsSvc = Get-Service -Name 'WDSServer' -ErrorAction SilentlyContinue $wdsReady = $false if ($wdsSvc) { wdsutil /get-server /show:config 2>&1 | Out-Null $wdsReady = ($LASTEXITCODE -eq 0) } if (-not $wdsReady) { Write-Warning @' WDS (Windows Deployment Services) is not configured on this server. The build will complete, but the WDS boot-image update (Step 7) will fail. To configure WDS before running this command: 1. Install the WDS role if not already present: Install-WindowsFeature WDS -IncludeManagementTools 2. Configure it (replace paths/options as needed): wdsutil /Initialize-Server /RemInst:"C:\RemoteInstall" 3. Then re-run: New-NDTPEImage To skip WDS and build the WIM only: New-NDTPEImage -SkipWDS '@ } } # -- Resolve paths ----------------------------------------------------------- $wimFile = Join-Path $LocalPath 'Boot\boot2026.wim' $isoFile = Join-Path $LocalPath 'Boot\boot2026.iso' $sectionsPath = Join-Path $LocalPath 'Control\Sections.json' $winPEScriptDir = Join-Path $LocalPath 'Scripts\unattend2026\WindowsPE' $deploySource = Join-Path $winPEScriptDir 'Deploy' $unattendSource = Join-Path $winPEScriptDir 'Unattend.xml' # -- Locate Windows ADK ------------------------------------------------------ $adkRoot = $null $adkRegPath = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots' if (Test-Path $adkRegPath) { $kitsRoot = (Get-ItemProperty -Path $adkRegPath -Name 'KitsRoot10' -ErrorAction SilentlyContinue).KitsRoot10 if ($kitsRoot) { $adkRoot = Join-Path $kitsRoot 'Assessment and Deployment Kit' } } if (-not $adkRoot -or -not (Test-Path $adkRoot)) { $adkRoot = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit' } if (-not $RegisterOnly) { $winPERoot = Join-Path $adkRoot 'Windows Preinstallation Environment' $copypeCmd = Join-Path $winPERoot 'copype.cmd' $winPEArch = Join-Path $winPERoot 'amd64' $winPEOCs = Join-Path $winPERoot 'amd64\WinPE_OCs' # Set ADK environment variables required by copype / MakeWinPEMedia. # Normally injected by DandISetEnv.bat; must be set manually from a plain PS session. $env:WinPERoot = $winPERoot $env:OSCDImgRoot = Join-Path $adkRoot 'Deployment Tools\amd64\Oscdimg' $env:DISMRoot = Join-Path $adkRoot 'Deployment Tools\amd64\DISM' if ($env:PATH -notlike "*$($env:OSCDImgRoot)*") { $env:PATH = $env:OSCDImgRoot + ';' + $env:PATH } if (-not (Test-Path $copypeCmd)) { throw "copype.cmd not found at: $copypeCmd`nInstall the Windows ADK + WinPE Add-on: https://learn.microsoft.com/windows-hardware/get-started/adk-install" } if (-not (Test-Path $winPEArch)) { throw "WinPE amd64 files not found at: $winPEArch`nThe WinPE Add-on is a separate download from the ADK: https://learn.microsoft.com/windows-hardware/get-started/adk-install" } } else { if (-not (Test-Path $wimFile)) { throw "RegisterOnly: boot WIM not found at: $wimFile - run New-NDTPEImage first." } Write-Host 'RegisterOnly: skipping build (Steps 1-6), using existing boot2026.wim.' -ForegroundColor Yellow } try { if (-not $RegisterOnly) { # -- Step 1: Generate settings.json ------------------------------------- Write-Host 'Step 1: Generating settings.json...' -ForegroundColor Cyan if (-not (Test-Path $sectionsPath)) { throw "Sections.json not found at: $sectionsPath" } $sections = Get-Content -Path $sectionsPath -Raw | ConvertFrom-Json if (-not $sections.$DeploySection) { throw "Deploy section '$DeploySection' not found in Sections.json." } $deployCfg = $sections.$DeploySection $settingsObj = [ordered]@{ Share = $deployCfg.Share Username = $deployCfg.Username Password = $deployCfg.Password } # settings.json is injected directly into the WIM in Step 5 - the source-folder # copy (Scripts\unattend2026\WindowsPE\Deploy\settings.json) is intentionally # NOT modified so it stays as a safe placeholder in source control. Write-Host ' [OK] settings.json prepared (will be written into WIM in Step 5)' -ForegroundColor Green Write-Host " Deploy section : $DeploySection" -ForegroundColor Gray Write-Host " Share : $($settingsObj.Share)" -ForegroundColor Gray Write-Host " Username : $($settingsObj.Username)" -ForegroundColor Gray # -- Step 2: Create fresh WinPE staging tree with copype ----------------- # Always build from the clean ADK base - never patch an existing WIM. Write-Host "`nStep 2: Creating fresh WinPE staging tree..." -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($IsoStagingDir, 'Run copype to create fresh ADK base')) { if (Test-Path $IsoStagingDir) { Write-Host ' Removing old staging directory...' -ForegroundColor Gray Remove-Item -Path $IsoStagingDir -Recurse -Force } cmd.exe /c "cd /d `"$winPERoot`" && copype.cmd amd64 `"$IsoStagingDir`"" if ($LASTEXITCODE -ne 0) { throw "copype.cmd failed (exit $LASTEXITCODE)" } Write-Host " [OK] Fresh staging tree created: $IsoStagingDir" -ForegroundColor Green } $stagingBootWim = Join-Path $IsoStagingDir 'media\sources\boot.wim' # -- Step 3: Mount the fresh base WIM ------------------------------------ Write-Host "`nStep 3: Mounting base WIM..." -ForegroundColor Cyan if (-not (Test-Path $MountDir)) { New-Item -Path $MountDir -ItemType Directory -Force | Out-Null } if ($PSCmdlet.ShouldProcess($MountDir, 'Mount staging boot.wim')) { dism /Mount-Wim /WimFile:"$stagingBootWim" /Index:1 /MountDir:"$MountDir" if ($LASTEXITCODE -ne 0) { throw "DISM mount failed (exit $LASTEXITCODE)" } Write-Host " [OK] WIM mounted at: $MountDir" -ForegroundColor Green } # -- Step 4: Add required WinPE optional packages ------------------------- # WinPE-WMI - WMI (prerequisite for PowerShell) # WinPE-NetFx - .NET Framework (prerequisite for PowerShell) # WinPE-Scripting - scripting support (prerequisite for PowerShell) # WinPE-PowerShell - powershell.exe in WinPE # WinPE-StorageWMI - storage management via WMI # WinPE-DismCmdlets - DISM PowerShell cmdlets Write-Host "`nStep 4: Adding WinPE optional packages..." -ForegroundColor Cyan $packages = @( 'WinPE-WMI', 'WinPE-NetFx', 'WinPE-Scripting', 'WinPE-PowerShell', 'WinPE-StorageWMI', 'WinPE-DismCmdlets' ) if ($PSCmdlet.ShouldProcess($MountDir, 'Add WinPE optional packages')) { foreach ($pkg in $packages) { $cabPath = Join-Path $winPEOCs "$pkg.cab" if (-not (Test-Path $cabPath)) { Write-Warning " Package not found, skipping: $cabPath" continue } Write-Host " Adding $pkg ..." -ForegroundColor Gray dism /Image:"$MountDir" /Add-Package /PackagePath:"$cabPath" if ($LASTEXITCODE -ne 0) { throw "DISM Add-Package failed for ${pkg} (exit $LASTEXITCODE)" } Write-Host " [OK] $pkg" -ForegroundColor Gray } Write-Host ' [OK] All optional packages added' -ForegroundColor Green } # -- Step 5: Inject Deploy folder and Unattend.xml ----------------------- Write-Host "`nStep 5: Injecting Deploy folder into WIM..." -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($MountDir, 'Inject Deploy folder and Unattend.xml')) { $wimDeployDir = Join-Path $MountDir 'Deploy' if (-not (Test-Path $wimDeployDir)) { New-Item -Path $wimDeployDir -ItemType Directory -Force | Out-Null } foreach ($file in (Get-ChildItem -Path $deploySource -File)) { if ($file.Name -eq 'settings.json') { # Skip - settings.json is written fresh from CustomSettings.json below # to avoid baking hardcoded placeholder values into the WIM. continue } Copy-Item -Path $file.FullName -Destination $wimDeployDir -Force Write-Host " [OK] Copied: $($file.Name)" -ForegroundColor Gray } # Write settings.json directly from the Deploy section of CustomSettings.json. # This ensures the WIM always contains the correct share/credentials regardless # of what the on-disk source placeholder file contains. $settingsDestPath = Join-Path $wimDeployDir 'settings.json' $settingsObj | ConvertTo-Json | Set-Content -Path $settingsDestPath -Encoding UTF8 Write-Host ' [OK] settings.json generated -> X:\Deploy\settings.json (from CustomSettings.json)' -ForegroundColor Gray # Generate StartDeploy.cmd directly into X:\Deploy\ inside the WIM. # # The MDT pattern: launch the deployment script in a NEW window with # 'start', so this cmd.exe window stays alive as a permanent debug shell. # The user gets two windows from the moment PE boots: # Window 1 (this cmd) - free debug shell, Z: already mapped # Window 2 - the running install.ps1 # No F8 polling, no HTA, no bddrun.exe needed. $startDeployContent = @' @echo off wpeinit wpeutil WaitForNetwork start "NDT Deploy" powershell.exe -NoLogo -ExecutionPolicy Bypass -File X:\Deploy\install.ps1 echo. echo *** NDT debug shell - deployment is running in the other window *** echo Type EXIT to close this window (deployment will continue unaffected) cmd.exe /k '@ $startDeployDest = Join-Path $wimDeployDir 'StartDeploy.cmd' Set-Content -Path $startDeployDest -Value $startDeployContent -Encoding ASCII Write-Host ' [OK] StartDeploy.cmd generated -> X:\Deploy\StartDeploy.cmd' -ForegroundColor Gray # Generate winpeshl.ini - controls what winpeshl.exe runs at PE boot. # # [LaunchApps] lists what to execute. startnet.cmd is NOT run when # winpeshl.ini is present, so wpeinit must be called from StartDeploy.cmd # (which it is). # # Note: 'DebugShell=Yes' is MDT-specific (requires bddrun.exe) and is NOT # a standard WinPE winpeshl.ini option - do not add it here. $winpeshlContent = @' [LaunchApps] %SYSTEMDRIVE%\Deploy\StartDeploy.cmd '@ $winpeshlDest = Join-Path $MountDir 'Windows\System32\winpeshl.ini' Set-Content -Path $winpeshlDest -Value $winpeshlContent -Encoding ASCII Write-Host ' [OK] winpeshl.ini generated -> Windows\System32\winpeshl.ini' -ForegroundColor Gray # startnet.cmd fallback - only executed when winpeshl.ini is absent. # Write a minimal version so that if winpeshl.ini were ever missing the # machine still initialises the network; without this it would hang silently. $startnetContent = "@echo off`r`nwpeinit`r`n" $startnetDest = Join-Path $MountDir 'Windows\System32\startnet.cmd' Set-Content -Path $startnetDest -Value $startnetContent -Encoding ASCII Write-Host ' [OK] startnet.cmd (fallback) -> Windows\System32\startnet.cmd' -ForegroundColor Gray # Unattend.xml at WIM root - display settings only. # RunSynchronous is NOT used here; winpeshl.ini is the launcher. $unattendDest = Join-Path $MountDir 'Unattend.xml' if (Test-Path $unattendSource) { Copy-Item -Path $unattendSource -Destination $unattendDest -Force Write-Host ' [OK] Unattend.xml -> X:\Unattend.xml (WIM root)' -ForegroundColor Gray } else { Write-Warning "Unattend.xml not found at: $unattendSource" } } # -- Step 6: Commit WIM and copy to Boot\boot2026.wim -------------------- Write-Host "`nStep 6: Committing WIM..." -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($MountDir, 'Commit and unmount WIM')) { dism /Unmount-Wim /MountDir:"$MountDir" /Commit if ($LASTEXITCODE -ne 0) { throw "DISM unmount/commit failed (exit $LASTEXITCODE)" } Write-Host ' [OK] WIM committed and unmounted' -ForegroundColor Green $bootDir = Split-Path $wimFile -Parent if (-not (Test-Path $bootDir)) { New-Item -Path $bootDir -ItemType Directory -Force | Out-Null } Copy-Item -Path $stagingBootWim -Destination $wimFile -Force Write-Host " [OK] boot2026.wim updated: $wimFile" -ForegroundColor Green } } # end -not RegisterOnly # -- Step 7: Update WDS -------------------------------------------------- if (-not $SkipWDS) { Write-Host "`nStep 7: Updating WDS..." -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess('WDSServer', 'Stop service, replace boot image, start service')) { $wdsImageName = 'NDT PE Boot 2026' $wimLeaf = Split-Path $wimFile -Leaf # -- Find existing image name via wdsutil output (service must be running) -- # Get-WdsBootImage is unreliable for FileName matching; parse wdsutil text # output instead to get the exact registered display name. Write-Host ' Checking for existing boot image...' -ForegroundColor Gray $existingImageName = $null $wdsRawOutput = (wdsutil /Get-AllImages /ImageType:Boot 2>&1) -join "`n" # Each image block starts with " Image Name:"; split on that boundary. foreach ($block in ($wdsRawOutput -split '(?m)(?=^ {4}Image Name:)')) { if ($block -match '(?m)^ {4,}File Name:\s*(\S+)' -and $Matches[1].Trim() -eq $wimLeaf) { if ($block -match '(?m)^ {4}Image Name:\s*(.+)') { $existingImageName = $Matches[1].Trim() } } } if ($existingImageName) { Write-Host " Found existing boot image: '$existingImageName' - removing database entry..." -ForegroundColor Gray wdsutil /Remove-Image /Image:"$existingImageName" /ImageType:Boot /Architecture:x64 2>&1 | Out-Null } else { Write-Host ' No existing database entry found for this WIM.' -ForegroundColor Gray } Write-Host ' Stopping WDS service...' -ForegroundColor Gray Stop-Service WDSServer -Force Write-Host ' [OK] WDS stopped' -ForegroundColor Gray # -- Delete physical WIM from WDS image store -------------------------- # wdsutil /Add-Image copies the WIM to RemoteInstall\Boot\x64\Images\. # If that file already exists (even after a Remove-Image) it fails 0x50. # Read the RemoteInstall path from the registry; fall back to the default. $wdsRoot = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WDSServer\Parameters' ` -Name RootDirectory -ErrorAction SilentlyContinue).RootDirectory if (-not $wdsRoot) { $wdsRoot = 'C:\RemoteInstall' } $physicalWim = Join-Path $wdsRoot "Boot\x64\Images\$wimLeaf" if (Test-Path $physicalWim) { Remove-Item $physicalWim -Force Write-Host " Deleted physical WIM from store: $physicalWim" -ForegroundColor Gray } Write-Host ' Adding new boot image...' -ForegroundColor Gray $result = wdsutil /Verbose /Add-Image /ImageFile:"$wimFile" /ImageType:Boot /Name:"$wdsImageName" 2>&1 if ($LASTEXITCODE -ne 0) { throw "wdsutil Add-Image failed: $result" } Write-Host ' [OK] Boot image updated in WDS' -ForegroundColor Green Write-Host ' Starting WDS service...' -ForegroundColor Gray Start-Service WDSServer Write-Host ' [OK] WDS started' -ForegroundColor Green } } else { Write-Verbose 'Step 7: WDS update skipped (-SkipWDS).' } # -- Step 8: Create bootable ISO ----------------------------------------- if (-not $SkipISO -and -not $RegisterOnly) { Write-Host "`nStep 8: Creating bootable ISO..." -ForegroundColor Cyan if ($PSCmdlet.ShouldProcess($isoFile, 'Build bootable ISO with MakeWinPEMedia')) { if (Test-Path $isoFile) { Remove-Item -Path $isoFile -Force } cmd.exe /c "cd /d `"$winPERoot`" && MakeWinPEMedia.cmd /iso `"$IsoStagingDir`" `"$isoFile`"" if ($LASTEXITCODE -ne 0) { throw "MakeWinPEMedia.cmd failed (exit $LASTEXITCODE)" } Write-Host " [OK] ISO created: $isoFile" -ForegroundColor Green Remove-Item -Path $IsoStagingDir -Recurse -Force -ErrorAction SilentlyContinue Write-Host ' [OK] Staging directory cleaned up' -ForegroundColor Gray Write-Host '' Write-Host ' Mount this ISO to a Gen 1 VM DVD drive before booting:' -ForegroundColor Yellow Write-Host " Set-VMDvdDrive -VMName '<vmname>' -Path '$isoFile'" -ForegroundColor Yellow } } else { Write-Verbose 'Step 8: ISO creation skipped (-SkipISO).' } Write-Host "`n========================================" -ForegroundColor Green Write-Host 'PE build complete!' -ForegroundColor Green Write-Host "========================================`n" -ForegroundColor Green } catch { Write-Error "New-NDTPEImage failed: $_" # Attempt to discard a still-mounted WIM if (Test-Path $MountDir) { Write-Warning 'Attempting to discard WIM mount...' dism /Unmount-Wim /MountDir:"$MountDir" /Discard 2>&1 | Out-Null } throw } } #region -- Server management (CustomSettings.json) --------------------------- function Get-NDTServer { <# .SYNOPSIS Retrieves server entries from CustomSettings.json. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER MAC Return only the entry with this MAC address. .PARAMETER Computername Return only entries matching this computer name. .EXAMPLE Get-NDTServer .EXAMPLE Get-NDTServer -MAC '00:15:5D:02:56:01' .EXAMPLE Get-NDTServer -Computername srv02 #> [CmdletBinding()] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [string]$MAC, [Parameter()] [string]$Computername ) $path = Join-Path $LocalPath 'Control\CustomSettings.json' if (-not (Test-Path $path)) { throw "CustomSettings.json not found at: $path" } $settings = Get-Content $path -Raw | ConvertFrom-Json $macPattern = '^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$' $entries = $settings.PSObject.Properties | Where-Object { $_.Name -match $macPattern } | ForEach-Object { $obj = [ordered]@{ MAC = $_.Name.ToUpper() } foreach ($prop in $_.Value.PSObject.Properties) { $obj[$prop.Name] = $prop.Value } [PSCustomObject]$obj } if ($MAC) { $entries = $entries | Where-Object { $_.MAC -eq $MAC.ToUpper() } } if ($Computername) { $entries = $entries | Where-Object { $_.Computername -eq $Computername } } $entries } function Add-NDTServer { <# .SYNOPSIS Adds a new server entry to CustomSettings.json. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER MAC MAC address of the server (colon-separated, any case). .PARAMETER Computername Target computer name. .PARAMETER OS OS key from OS.json to deploy. .PARAMETER IPAddress Static IP in CIDR notation (e.g. 10.0.3.22/24), or 'DHCP'. .PARAMETER LocalAdmin Local administrator password (stored as plain text in CustomSettings.json). .PARAMETER Sections Hashtable of section references, e.g. @{ Locale = 'Sweden'; ADSettings = 'ADJoinCorp' } .PARAMETER DeploymentSteps Ordered array of deployment group names from Deployment.json. .PARAMETER Properties Hashtable of arbitrary extra key-value pairs to include in the entry. .EXAMPLE Add-NDTServer -MAC '00:15:5D:02:56:05' -Computername srv05 -OS WIN2025DCG ` -IPAddress '10.0.3.25/24' -DeploymentSteps 'General Settings','SMC' ` -Sections @{ Locale = 'Sweden'; NetworkSettings = 'NicAuto'; ADSettings = 'ADJoinCorp' } #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(Mandatory)] [string]$MAC, [Parameter(Mandatory)] [string]$Computername, [Parameter(Mandatory)] [string]$OS, [Parameter()] [string]$IPAddress, [Parameter()] [string]$LocalAdmin, [Parameter()] [hashtable]$Sections, [Parameter()] [string[]]$DeploymentSteps, [Parameter()] [hashtable]$Properties ) $path = Join-Path $LocalPath 'Control\CustomSettings.json' if (-not (Test-Path $path)) { throw "CustomSettings.json not found at: $path" } $normalMAC = $MAC.ToUpper() $settings = Get-Content $path -Raw | ConvertFrom-Json if ($settings.PSObject.Properties[$normalMAC]) { throw "Server '$normalMAC' already exists. Use Set-NDTServer to update it." } $entry = [ordered]@{ OS = $OS; Computername = $Computername } if ($PSBoundParameters.ContainsKey('IPAddress')) { $entry.IPAddress = $IPAddress } if ($PSBoundParameters.ContainsKey('LocalAdmin')) { $entry.AdminPassword = $LocalAdmin } if ($PSBoundParameters.ContainsKey('Sections')) { $entry.Sections = $Sections } if ($PSBoundParameters.ContainsKey('DeploymentSteps')) { $entry.DeploymentSteps = $DeploymentSteps } if ($PSBoundParameters.ContainsKey('Properties')) { foreach ($kv in $Properties.GetEnumerator()) { $entry[$kv.Key] = $kv.Value } } if ($PSCmdlet.ShouldProcess($normalMAC, 'Add server entry')) { $settings | Add-Member -MemberType NoteProperty -Name $normalMAC -Value ([PSCustomObject]$entry) $settings | ConvertTo-Json -Depth 10 | Set-Content -Path $path -Encoding UTF8 Write-Verbose "Added server '$normalMAC' ($Computername)." } } function Set-NDTServer { <# .SYNOPSIS Updates an existing server entry in CustomSettings.json. Only parameters that are explicitly supplied are changed. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER MAC MAC address of the server entry to update. .PARAMETER Properties Hashtable of arbitrary extra key-value pairs to set or add. .EXAMPLE Set-NDTServer -MAC '00:15:5D:02:56:01' -DeploymentSteps 'General Settings','SMC','SQL2025' .EXAMPLE Set-NDTServer -MAC '00:15:5D:02:56:01' -Properties @{ SQLServer = 'SQL2026' } #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [string]$MAC, [Parameter()] [string]$Computername, [Parameter()] [string]$OS, [Parameter()] [string]$IPAddress, [Parameter()] [string]$LocalAdmin, [Parameter()] [hashtable]$Sections, [Parameter()] [string[]]$DeploymentSteps, [Parameter()] [hashtable]$Properties ) $path = Join-Path $LocalPath 'Control\CustomSettings.json' if (-not (Test-Path $path)) { throw "CustomSettings.json not found at: $path" } $normalMAC = $MAC.ToUpper() $settings = Get-Content $path -Raw | ConvertFrom-Json $entry = $settings.PSObject.Properties[$normalMAC] if (-not $entry) { throw "Server '$normalMAC' not found in CustomSettings.json." } if ($PSCmdlet.ShouldProcess($normalMAC, 'Update server entry')) { if ($PSBoundParameters.ContainsKey('Computername')) { $entry.Value.Computername = $Computername } if ($PSBoundParameters.ContainsKey('OS')) { $entry.Value.OS = $OS } if ($PSBoundParameters.ContainsKey('IPAddress')) { $entry.Value.IPAddress = $IPAddress } if ($PSBoundParameters.ContainsKey('LocalAdmin')) { $entry.Value.AdminPassword = $LocalAdmin } if ($PSBoundParameters.ContainsKey('Sections')) { $entry.Value.Sections = $Sections } if ($PSBoundParameters.ContainsKey('DeploymentSteps')){ $entry.Value.DeploymentSteps = $DeploymentSteps } if ($PSBoundParameters.ContainsKey('Properties')) { foreach ($kv in $Properties.GetEnumerator()) { if ($entry.Value.PSObject.Properties[$kv.Key]) { $entry.Value.PSObject.Properties[$kv.Key].Value = $kv.Value } else { $entry.Value | Add-Member -MemberType NoteProperty -Name $kv.Key -Value $kv.Value } } } $settings | ConvertTo-Json -Depth 10 | Set-Content -Path $path -Encoding UTF8 Write-Verbose "Updated server '$normalMAC'." } } function Remove-NDTServer { <# .SYNOPSIS Removes a server entry from CustomSettings.json. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER MAC MAC address of the server entry to remove. .EXAMPLE Remove-NDTServer -MAC '00:15:5D:02:56:01' .EXAMPLE Get-NDTServer -Computername srv02 | Remove-NDTServer #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [string]$MAC ) $path = Join-Path $LocalPath 'Control\CustomSettings.json' if (-not (Test-Path $path)) { throw "CustomSettings.json not found at: $path" } $normalMAC = $MAC.ToUpper() $settings = Get-Content $path -Raw | ConvertFrom-Json if (-not $settings.PSObject.Properties[$normalMAC]) { throw "Server '$normalMAC' not found in CustomSettings.json." } if ($PSCmdlet.ShouldProcess($normalMAC, 'Remove server entry')) { $settings.PSObject.Properties.Remove($normalMAC) $settings | ConvertTo-Json -Depth 10 | Set-Content -Path $path -Encoding UTF8 Write-Verbose "Removed server '$normalMAC'." } } #endregion #region -- OS management (OS.json) ------------------------------------------- function Get-NDTOs { <# .SYNOPSIS Retrieves OS entries from OS.json. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER Key Return only the entry with this key. .EXAMPLE Get-NDTOs .EXAMPLE Get-NDTOs -Key WIN2025DCG #> [CmdletBinding()] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [string]$Key ) $path = Join-Path $LocalPath 'Control\OS.json' if (-not (Test-Path $path)) { throw "OS.json not found at: $path" } $catalog = Get-Content $path -Raw | ConvertFrom-Json $entries = $catalog.PSObject.Properties | ForEach-Object { [PSCustomObject]@{ Key = $_.Name Path = $_.Value.Path Index = $_.Value.Index } } if ($Key) { $entries = $entries | Where-Object { $_.Key -eq $Key } } $entries } function Add-NDTOs { <# .SYNOPSIS Adds a new OS entry to OS.json. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER Key Unique key for this OS entry (e.g. WIN2025DCG). .PARAMETER Path Share-relative path to the WIM file (backslash-rooted). .PARAMETER Index WIM image index to apply. .EXAMPLE Add-NDTOs -Key WIN2025DCG -Path 'Operating Systems\ref-w2025dcg\w2025dcg.wim' -Index 1 #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(Mandatory)] [string]$Key, [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [int]$Index ) $osPath = Join-Path $LocalPath 'Control\OS.json' if (-not (Test-Path $osPath)) { throw "OS.json not found at: $osPath" } $catalog = Get-Content $osPath -Raw | ConvertFrom-Json if ($catalog.PSObject.Properties[$Key]) { throw "OS key '$Key' already exists. Use Set-NDTOs to update it." } if ($PSCmdlet.ShouldProcess($Key, 'Add OS entry')) { $catalog | Add-Member -MemberType NoteProperty -Name $Key -Value ([PSCustomObject]@{ Path = $Path; Index = $Index }) $catalog | ConvertTo-Json -Depth 10 | Set-Content -Path $osPath -Encoding UTF8 Write-Verbose "Added OS '$Key'." } } function Set-NDTOs { <# .SYNOPSIS Updates the Path and/or Index of an existing OS entry in OS.json. Only parameters that are explicitly supplied are changed. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER Key Key of the OS entry to update. .PARAMETER Path New share-relative WIM path. .PARAMETER Index New WIM image index. .EXAMPLE Set-NDTOs -Key WIN2025DCG -Index 2 .EXAMPLE Get-NDTOs -Key WIN2025DCG | Set-NDTOs -Path 'Operating Systems\new\install.wim' -Index 1 #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [string]$Key, [Parameter()] [string]$Path, [Parameter()] [int]$Index ) $osPath = Join-Path $LocalPath 'Control\OS.json' if (-not (Test-Path $osPath)) { throw "OS.json not found at: $osPath" } $catalog = Get-Content $osPath -Raw | ConvertFrom-Json $entry = $catalog.PSObject.Properties[$Key] if (-not $entry) { throw "OS key '$Key' not found in OS.json." } if ($PSCmdlet.ShouldProcess($Key, 'Update OS entry')) { if ($PSBoundParameters.ContainsKey('Path')) { $entry.Value.Path = $Path } if ($PSBoundParameters.ContainsKey('Index')) { $entry.Value.Index = $Index } $catalog | ConvertTo-Json -Depth 10 | Set-Content -Path $osPath -Encoding UTF8 Write-Verbose "Updated OS '$Key'." } } function Remove-NDTOs { <# .SYNOPSIS Removes an OS entry from OS.json. .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER Key Key of the OS entry to remove. .EXAMPLE Remove-NDTOs -Key WIN2025DCG .EXAMPLE Get-NDTOs | Where-Object Index -eq 3 | Remove-NDTOs #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026', [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [string]$Key ) $osPath = Join-Path $LocalPath 'Control\OS.json' if (-not (Test-Path $osPath)) { throw "OS.json not found at: $osPath" } $catalog = Get-Content $osPath -Raw | ConvertFrom-Json if (-not $catalog.PSObject.Properties[$Key]) { throw "OS key '$Key' not found in OS.json." } if ($PSCmdlet.ShouldProcess($Key, 'Remove OS entry')) { $catalog.PSObject.Properties.Remove($Key) $catalog | ConvertTo-Json -Depth 10 | Set-Content -Path $osPath -Encoding UTF8 Write-Verbose "Removed OS '$Key'." } } #endregion #region -- Reference image management ---------------------------------------- function Move-NDTReferenceImage { <# .SYNOPSIS Moves captured reference WIM files from \Reference into \Operating Systems\. .DESCRIPTION For every *.wim found directly inside the Reference folder the function: 1. Derives the destination folder name from the WIM stem (e.g. 'ref-w2025dcg.wim' -> folder 'ref-w2025dcg'). 2. Strips the 'ref-' prefix from the stem to form the destination file name (e.g. 'ref-w2025dcg' -> 'w2025dcg.wim'). 3. Creates 'Operating Systems\<stem>\' if it does not exist. 4. Moves the WIM to 'Operating Systems\<stem>\<name-without-prefix>.wim', removing it from the Reference folder. Example: Reference\ref-w2025dcg.wim -> Operating Systems\ref-w2025dcg\w2025dcg.wim .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .PARAMETER Force Overwrite the destination WIM if it already exists. .EXAMPLE Move-NDTReferenceImage .EXAMPLE Move-NDTReferenceImage -WhatIf .EXAMPLE Move-NDTReferenceImage -LocalPath D:\Deploy2026 #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$LocalPath = 'C:\Deploy2026' ) $refDir = Join-Path $LocalPath 'Reference' $osDir = Join-Path $LocalPath 'Operating Systems' if (-not (Test-Path $refDir)) { throw "Reference folder not found: $refDir" } $wims = Get-ChildItem -Path $refDir -Filter '*.wim' -File if (-not $wims) { Write-Warning "No WIM files found in: $refDir" return } else { Write-Host "Found $($wims.Count) WIM(s) in Reference folder:" -ForegroundColor Cyan foreach ($wim in $wims) { Write-Host " $($wim.Name)" -ForegroundColor Gray } } foreach ($wim in $wims) { $stem = $wim.BaseName # e.g. ref-w2025dcg $destName = $stem -replace '^ref-', '' # e.g. w2025dcg $destDir = Join-Path $osDir $stem # e.g. Operating Systems\ref-w2025dcg $destFile = Join-Path $destDir "$destName.wim" # e.g. ...\w2025dcg.wim $relDest = $destFile.Replace($LocalPath + '\', '') Write-Host " Source : $($wim.FullName)" -ForegroundColor Gray Write-Host " Dest : $relDest" -ForegroundColor Gray if (Test-Path $destFile) { Write-Host " Status : destination exists - overwriting" -ForegroundColor Yellow } else { Write-Host " Status : new file" -ForegroundColor Gray } if ($PSCmdlet.ShouldProcess($destFile, "Move reference WIM '$($wim.Name)'")) { if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null Write-Host " Created folder: $destDir" -ForegroundColor Gray } Move-Item -Path $wim.FullName -Destination $destFile -Force Write-Host " [OK] $($wim.Name) -> $relDest (moved)" -ForegroundColor Green } } } #endregion #region -- Deployment validation --------------------------------------------- function Test-NDTDeployment { <# .SYNOPSIS Validates the entire deployment configuration for a given MAC address. .DESCRIPTION Performs a dry-run check of all configuration references for a specific machine: - MAC entry exists in CustomSettings.json - Required fields are present (Computername, OS, DeploymentSteps) - Each referenced section exists as a top-level key in CustomSettings.json - OS key exists in OS.json - WIM file exists on disk - Each DeploymentSteps group exists in DeploymentGroups.json - Each step's Reference key exists in Deployment.json - Each script file referenced in Deployment.json exists on disk No changes are made - this is a read-only validation. Returns \$true if all checks passed, \$false if any check failed. .PARAMETER MAC MAC address of the machine to validate (colon-separated, any case). .PARAMETER LocalPath Root of the NDT deployment share. Default: C:\Deploy2026 .EXAMPLE Test-NDTDeployment -MAC '00:15:5D:03:9C:11' .EXAMPLE Test-NDTDeployment -MAC '00:15:5D:03:9C:11' -LocalPath D:\Deploy2026 #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$MAC, [Parameter()] [string]$LocalPath = 'C:\Deploy2026' ) $script:checkErrors = 0 $script:checkWarnings = 0 function Write-Check { param( [string]$Label, [bool]$Pass, [string]$Detail = '', [switch]$IsWarning ) if ($Pass) { Write-Host " [PASS] $Label" -ForegroundColor Green if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGreen } } elseif ($IsWarning) { Write-Host " [WARN] $Label" -ForegroundColor Yellow if ($Detail) { Write-Host " $Detail" -ForegroundColor Yellow } $script:checkWarnings++ } else { Write-Host " [FAIL] $Label" -ForegroundColor Red if ($Detail) { Write-Host " $Detail" -ForegroundColor Red } $script:checkErrors++ } } $normalMAC = $MAC.ToUpper() Write-Host "`nNDT Deployment Validation: $normalMAC" -ForegroundColor Cyan Write-Host ('-' * 60) -ForegroundColor DarkGray # -- Control file paths ------------------------------------------------------ $csPath = Join-Path $LocalPath 'Control\CustomSettings.json' $snPath = Join-Path $LocalPath 'Control\Sections.json' $djPath = Join-Path $LocalPath 'Control\Deployment.json' $dgPath = Join-Path $LocalPath 'Control\DeploymentGroups.json' $osPath = Join-Path $LocalPath 'Control\OS.json' # -- [1] Control files ------------------------------------------------------- Write-Host "`n[1] Control files" -ForegroundColor White $csOk = Test-Path $csPath; Write-Check 'CustomSettings.json' $csOk $csPath $snOk = Test-Path $snPath; Write-Check 'Sections.json' $snOk $snPath $djOk = Test-Path $djPath; Write-Check 'Deployment.json' $djOk $djPath $dgOk = Test-Path $dgPath; Write-Check 'DeploymentGroups.json' $dgOk $dgPath $osOk = Test-Path $osPath; Write-Check 'OS.json' $osOk $osPath if (-not ($csOk -and $snOk -and $djOk -and $dgOk -and $osOk)) { Write-Host "`n One or more control files missing - cannot continue." -ForegroundColor Red Write-Host "`nResult: $($script:checkErrors) error(s), $($script:checkWarnings) warning(s)`n" -ForegroundColor Red return $false } $settings = Get-Content $csPath -Raw | ConvertFrom-Json $sectionsCatalog = Get-Content $snPath -Raw | ConvertFrom-Json $deployment = Get-Content $djPath -Raw | ConvertFrom-Json $groups = Get-Content $dgPath -Raw | ConvertFrom-Json $osCatalog = Get-Content $osPath -Raw | ConvertFrom-Json # -- [2] MAC entry ----------------------------------------------------------- Write-Host "`n[2] Machine entry" -ForegroundColor White $machineEntry = $settings.PSObject.Properties[$normalMAC] Write-Check "MAC $normalMAC in CustomSettings.json" ([bool]$machineEntry) if (-not $machineEntry) { Write-Host "`n MAC not found - cannot continue." -ForegroundColor Red Write-Host "`nResult: $($script:checkErrors) error(s), $($script:checkWarnings) warning(s)`n" -ForegroundColor Red return $false } $machine = $machineEntry.Value # -- [3] Required fields ----------------------------------------------------- Write-Host "`n[3] Required fields" -ForegroundColor White Write-Check 'Computername' ([bool]$machine.Computername) $machine.Computername Write-Check 'OS' ([bool]$machine.OS) $machine.OS Write-Check 'AdminPassword' ([bool]$machine.AdminPassword) '(set)' Write-Check 'DeploymentSteps' ([bool]$machine.DeploymentSteps) ($machine.DeploymentSteps -join ', ') # -- [4] Sections ------------------------------------------------------------ if ($machine.Sections) { Write-Host "`n[4] Sections" -ForegroundColor White foreach ($sectionProp in $machine.Sections.PSObject.Properties) { $sectionName = $sectionProp.Value $exists = [bool]$sectionsCatalog.PSObject.Properties[$sectionName] Write-Check "[$($sectionProp.Name)] '$sectionName'" $exists } } # -- [5] Operating system ---------------------------------------------------- Write-Host "`n[5] Operating system" -ForegroundColor White $osKey = $machine.OS if ($osKey) { $osEntry = $osCatalog.PSObject.Properties[$osKey] Write-Check "Key '$osKey' in OS.json" ([bool]$osEntry) if ($osEntry) { $wimRelPath = $osEntry.Value.Path $wimIndex = $osEntry.Value.Index $wimAbsPath = Join-Path $LocalPath $wimRelPath.TrimStart('\') Write-Check 'WIM file exists on disk' (Test-Path $wimAbsPath) "$wimRelPath (index $wimIndex)" } } # -- [6] Deployment groups --------------------------------------------------- if ($machine.DeploymentSteps) { Write-Host "`n[6] Deployment groups" -ForegroundColor White $resolvedRefs = [System.Collections.Generic.List[string]]::new() foreach ($groupName in $machine.DeploymentSteps) { $groupEntry = $groups.PSObject.Properties[$groupName] Write-Check "Group '$groupName'" ([bool]$groupEntry) if ($groupEntry) { foreach ($stepProp in $groupEntry.Value.PSObject.Properties) { $ref = $stepProp.Value.Reference if ($ref -and -not $resolvedRefs.Contains($ref)) { $resolvedRefs.Add($ref) } } } } # -- [7] Action references ----------------------------------------------- Write-Host "`n[7] Deployment action references" -ForegroundColor White foreach ($ref in $resolvedRefs) { $actionEntry = $deployment.PSObject.Properties[$ref] Write-Check "'$ref'" ([bool]$actionEntry) } # -- [8] Script files ---------------------------------------------------- Write-Host "`n[8] Script files" -ForegroundColor White $anyScripts = $false foreach ($ref in $resolvedRefs) { $action = $deployment.PSObject.Properties[$ref] if (-not $action) { continue } $scriptRel = $action.Value.Script if (-not $scriptRel) { continue } $anyScripts = $true $scriptAbs = Join-Path $LocalPath $scriptRel.TrimStart('\') Write-Check "'$ref'" (Test-Path $scriptAbs) $scriptRel } if (-not $anyScripts) { Write-Host ' (no Script actions in this machine-s steps)' -ForegroundColor DarkGray } } # -- Summary ----------------------------------------------------------------- Write-Host "`n$('-' * 60)" -ForegroundColor DarkGray if ($script:checkErrors -eq 0 -and $script:checkWarnings -eq 0) { Write-Host 'Result: ALL CHECKS PASSED' -ForegroundColor Green } elseif ($script:checkErrors -eq 0) { Write-Host "Result: PASSED with $($script:checkWarnings) warning(s)" -ForegroundColor Yellow } else { Write-Host "Result: $($script:checkErrors) error(s), $($script:checkWarnings) warning(s)" -ForegroundColor Red } Write-Host '' return ($script:checkErrors -eq 0) } #endregion # Backward-compatible alias for the former command name (Build- is not an # approved verb under Windows PowerShell 5.1). Exported via ndt.psd1. New-Alias -Name Build-NDTPEImage -Value New-NDTPEImage -Force -ErrorAction SilentlyContinue |