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'
    )

    #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"
        }
    } 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 CustomSettings.json with parameters ────────
    $controlDir           = Join-Path $LocalPath 'Control'
    $customSettingsDest   = Join-Path $controlDir 'CustomSettings.json'

    if (-not (Test-Path $customSettingsDest)) {
        Write-Warning "CustomSettings.json not found at '$customSettingsDest' - skipping stamp."
    } else {
        if ($PSCmdlet.ShouldProcess($customSettingsDest, '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 $customSettingsDest -Raw | ConvertFrom-Json
            $settings.Deploy.Share    = $ShareUNC
            $settings.Deploy.Username = $DeployUsername
            $settings.Deploy.Password = $plainPassword

            $settings | ConvertTo-Json -Depth 10 | Set-Content -Path $customSettingsDest -Encoding UTF8
            Write-Verbose 'Deploy section stamped in CustomSettings.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

    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
}

function Build-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.
 
    .EXAMPLE
        Build-NDTPEImage
 
    .EXAMPLE
        Build-NDTPEImage -SkipISO -Verbose
 
    .EXAMPLE
        Build-NDTPEImage -LocalPath D:\Deploy2026 -SkipWDS
    #>

    [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
    )

    # ── Verify Administrator ────────────────────────────────────────────────────
    $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    if (-not $isAdmin) { throw 'Build-NDTPEImage must be run as Administrator.' }

    # ── Pre-flight: verify WDS is configured (skip if -SkipWDS) ────────────────
    if (-not $SkipWDS) {
        $wdsState = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\WDSServer\Parameters' `
                        -Name 'WdsInstallState' -ErrorAction SilentlyContinue).WdsInstallState
        # WdsInstallState 4 = fully configured; anything else (or missing key) means WDS is not ready.
        if ($wdsState -ne 4) {
            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: Build-NDTPEImage
 
To skip WDS and build the WIM only:
    Build-NDTPEImage -SkipWDS
'@

        }
    }

    # ── Resolve paths ───────────────────────────────────────────────────────────
    $wimFile            = Join-Path $LocalPath 'Boot\boot2026.wim'
    $isoFile            = Join-Path $LocalPath 'Boot\boot2026.iso'
    $customSettingsPath = Join-Path $LocalPath 'Control\CustomSettings.json'
    $winPEScriptDir     = Join-Path $LocalPath 'Scripts\unattend2026\WindowsPE'
    $deploySource       = Join-Path $winPEScriptDir 'Deploy'
    $unattendSource     = Join-Path $winPEScriptDir 'Unattend.xml'
    $settingsOutput     = Join-Path $deploySource  'settings.json'

    # ── 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'
    }

    $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"
    }

    try {
        # ── Step 1: Generate settings.json ─────────────────────────────────────
        Write-Host 'Step 1: Generating settings.json...' -ForegroundColor Cyan

        if (-not (Test-Path $customSettingsPath)) {
            throw "CustomSettings.json not found at: $customSettingsPath"
        }

        $customSettings = Get-Content -Path $customSettingsPath -Raw | ConvertFrom-Json
        if (-not $customSettings.Deploy) { throw 'Deploy section not found in CustomSettings.json.' }

        $deploySection = $customSettings.Deploy
        $settingsObj   = [ordered]@{
            Share    = $deploySection.Share
            Username = $deploySection.Username
            Password = $deploySection.Password
        }

        if (-not (Test-Path $deploySource)) {
            New-Item -Path $deploySource -ItemType Directory -Force | Out-Null
        }

        if ($PSCmdlet.ShouldProcess($settingsOutput, 'Write settings.json')) {
            $settingsObj | ConvertTo-Json | Set-Content -Path $settingsOutput -Encoding UTF8
            Write-Host ' [OK] settings.json written' -ForegroundColor Green
            Write-Verbose " Share : $($settingsObj.Share)"
            Write-Verbose " Username: $($settingsObj.Username)"
        }

        # ── 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')) {
            $result = dism /Mount-Wim /WimFile:"$stagingBootWim" /Index:1 /MountDir:"$MountDir" 2>&1
            if ($LASTEXITCODE -ne 0) { throw "DISM mount failed: $result" }
            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
                $result = dism /Image:"$MountDir" /Add-Package /PackagePath:"$cabPath" 2>&1
                if ($LASTEXITCODE -ne 0) { throw "DISM Add-Package failed for ${pkg}: $result" }
                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)) {
                Copy-Item -Path $file.FullName -Destination $wimDeployDir -Force
                Write-Host " [OK] Copied: $($file.Name)" -ForegroundColor Gray
            }

            # Inject startnet.cmd — contains only "wpeinit".
            # This is the fallback when NO winpeshl.ini is present, but we keep it
            # in place as a safety net.
            $startnetSource = Join-Path $winPEScriptDir 'startnet.cmd'
            $startnetDest   = Join-Path $MountDir 'Windows\System32\startnet.cmd'
            if (Test-Path $startnetSource) {
                Copy-Item -Path $startnetSource -Destination $startnetDest -Force
                Write-Host ' [OK] startnet.cmd -> Windows\System32\startnet.cmd' -ForegroundColor Gray
            } else {
                Write-Warning "startnet.cmd not found at: $startnetSource"
            }

            # Inject winpeshl.ini to Windows\System32\.
            # winpeshl.exe reads this and:
            # - Intercepts F8 at startup -> drops to cmd.exe shell (debug)
            # - Otherwise runs [LaunchApps] sequentially:
            # 1. wpeinit.exe — DHCP, PnP (same role as MDT's bddrun.exe)
            # 2. StartDeploy.cmd — wpeutil WaitForNetwork -> install.ps1
            # MDT equivalent: bddrun.exe calls wpeinit internally then launches LiteTouch.wsf
            $winpeshlSource = Join-Path $winPEScriptDir 'winpeshl.ini'
            $winpeshlDest   = Join-Path $MountDir 'Windows\System32\winpeshl.ini'
            if (Test-Path $winpeshlSource) {
                Copy-Item -Path $winpeshlSource -Destination $winpeshlDest -Force
                Write-Host ' [OK] winpeshl.ini -> Windows\System32\winpeshl.ini' -ForegroundColor Gray
            } else {
                Write-Warning "winpeshl.ini not found at: $winpeshlSource"
            }

            # 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')) {
            $result = dism /Unmount-Wim /MountDir:"$MountDir" /Commit 2>&1
            if ($LASTEXITCODE -ne 0) { throw "DISM unmount/commit failed: $result" }
            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
        }

        # ── 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')) {
                Write-Host ' Stopping WDS service...' -ForegroundColor Gray
                Stop-Service WDSServer -Force
                Write-Host ' [OK] WDS stopped' -ForegroundColor Gray

                Write-Host ' Removing old boot image...' -ForegroundColor Gray
                wdsutil /Remove-Image /Image:"PE Boot 2026" /ImageType:Boot /Architecture:x64 /Filename:"boot2026.wim" 2>&1 | Out-Null

                Write-Host ' Adding new boot image...' -ForegroundColor Gray
                $result = wdsutil /Verbose /Add-Image /ImageFile:"$wimFile" /ImageType:Boot /Name:"PE Boot 2026" 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) {
            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 "Build-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 AdminPassword
        Local administrator password.
    .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]$AdminPassword,
        [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('AdminPassword'))   { $entry.AdminPassword   = $AdminPassword }
    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]$AdminPassword,
        [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('AdminPassword'))  { $entry.Value.AdminPassword  = $AdminPassword }
        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