Private/Set-PSADTDataDrivenScript.ps1

function Set-PSADTDataDrivenScript {
    <#
    .SYNOPSIS
        Rewrites a scaffolded PSADT v4 Invoke-AppDeployToolkit.ps1 to be data-driven.
    .DESCRIPTION
        Applies a fixed set of patches that contain NO app-specific values, so untrusted
        winget/registry values never enter a code position. The patched script reads all
        app-specific settings from SupportFiles\AppConfig.json at runtime (as data):
 
          1. A loader (`$appConfig = ... ConvertFrom-Json`) is inserted before the session hashtable.
          2. AppVendor/AppName/AppVersion/AppArch/AppScriptDate/AppProcessesToClose are pointed at
             $appConfig instead of literal values.
          3. A generic install routine is placed at the '## <Perform Installation tasks here>' marker:
             msix/appx via Add-AppxProvisionedPackage (SYSTEM) / Add-AppxPackage (interactive sandbox),
             everything else non-MSI via Start-ADTProcess with the configured SilentArgs.
          4. A generic uninstall routine is placed at the '## <Perform Uninstallation tasks here>'
             marker: msi product codes, exe uninstallers, and msix identity-based removal
             (Remove-AppxProvisionedPackage + Remove-AppxPackage -AllUsers, exact Name match).
          5. An install "tattoo" is placed at the Post-Install/Post-Uninstall markers: it writes
             HKLM:\SOFTWARE\<AppScriptAuthor>\<AppVendor>\<App.DisplayName>\Version at install and
             removes it at uninstall, so the Intune detection rule can confirm install state + correct
             version. Driven from $appConfig (App.DisplayName is populated even for MSI Zero-Config,
             where App.Name is empty), so it works for both EXE and MSI.
 
        MSI installers keep PSADT Zero-Config (empty App.Name); the tattoo uses App.DisplayName so it
        still records the MSI. The function is idempotent — a script that already contains the loader is
        left unchanged.
 
        See knowledge-base/designs/data-driven-generation.md.
    .PARAMETER ScriptPath
        Full path to the project's Invoke-AppDeployToolkit.ps1.
    .EXAMPLE
        Set-PSADTDataDrivenScript -ScriptPath 'C:\...\Projects\Git_x64_2.53.0\Invoke-AppDeployToolkit.ps1'
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ScriptPath,

        # Hard apps: leave the Install region for the operator to author instead of
        # inserting the data-driven install routine. Uninstall stays automated.
        [switch]$ManualInstall
    )

    if (-not (Test-Path -LiteralPath $ScriptPath)) {
        Write-Warning "PSADT script not found: $ScriptPath"
        return $false
    }

    $content = Get-Content -LiteralPath $ScriptPath -Raw

    # Idempotent: already patched?
    if ($content -match [regex]::Escape('$appConfig =')) { return $true }

    # ---- Fixed replacement blocks (single-quoted here-strings: every $ is literal) ----

    $loader = @'
# Data-driven deployment values (win32-toolkit): read app-specific settings as DATA.
$appConfig = if (Test-Path -LiteralPath "$PSScriptRoot\SupportFiles\AppConfig.json") {
    # -Encoding UTF8: AppConfig.json is BOM-less UTF-8 and this runs under Windows PowerShell 5.1 on the
    # device, whose Get-Content default decodes a BOM-less file as ANSI (non-ASCII vendor/app names would
    # mojibake). 5.1's -Encoding UTF8 reader handles both BOM and BOM-less input.
    Get-Content -LiteralPath "$PSScriptRoot\SupportFiles\AppConfig.json" -Raw -Encoding UTF8 | ConvertFrom-Json
} else {
    [pscustomobject]@{ App = [pscustomobject]@{}; Installer = $null; Uninstall = $null; ProcessesToClose = @() }
}
 
$adtSession = @{
'@


    $installSnippet = @'
## Data-driven install (values from SupportFiles\AppConfig.json).
    if ($appConfig.Installer -and $appConfig.Installer.Type -in @('msix', 'appx')) {
        $installerPath = Join-Path $adtSession.DirFiles $appConfig.Installer.FileName
        if (Test-Path -LiteralPath $installerPath) {
            Write-ADTLogEntry -Message "Installing AppX/MSIX package from: $installerPath" -Severity 1
            # SYSTEM (Intune): provision for all users. Interactive (sandbox tests): register for the
            # current user - a provisioned-only package would be invisible to the logged-on operator.
            if ([Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) {
                # -Regions 'all' is REQUIRED, not cosmetic. Per the DISM reference: "When a list of
                # regions is not specified, the package will be provisioned only if it is pinned to
                # start layout." Without it, provisioning silently no-ops for most packages: the
                # command succeeds, the tattoo is written, Intune reports Installed, and no user ever
                # gets the app.
                #
                # There is deliberately NO Add-AppxPackage fallback here. It would run AS SYSTEM and
                # register the package into SYSTEM's own profile, where no real user can ever see it -
                # and since it wouldn't throw, the tattoo would still be written and Intune would
                # report Installed for an app nobody has. A provisioning failure must fail the
                # deployment loudly. -ErrorAction Stop is what makes that happen: these Appx cmdlets
                # emit NON-TERMINATING errors by default, so without it a failure would just be logged
                # and the script would sail on to write the tattoo.
                try { Add-AppxProvisionedPackage -Online -PackagePath $installerPath -SkipLicense -Regions 'all' -ErrorAction Stop | Out-Null }
                catch {
                    Write-ADTLogEntry -Message "Provisioning failed: $($_.Exception.Message)" -Severity 3
                    throw
                }
            } else {
                Add-AppxPackage -Path $installerPath -ErrorAction Stop
            }
        } else {
            Write-ADTLogEntry -Message "Installer file not found: $installerPath" -Severity 3
            throw "Installer file not found: $installerPath"
        }
    }
    elseif ($appConfig.Installer -and $appConfig.Installer.Type -ne 'msi') {
        $installerPath = Join-Path $adtSession.DirFiles $appConfig.Installer.FileName
        if (Test-Path -LiteralPath $installerPath) {
            Write-ADTLogEntry -Message "Installing $($appConfig.App.Name) from: $installerPath" -Severity 1
            $spInstall = @{ FilePath = $installerPath; PassThru = $true }
            if ($appConfig.Installer.SilentArgs) { $spInstall['ArgumentList'] = $appConfig.Installer.SilentArgs }
            Start-ADTProcess @spInstall
        } else {
            Write-ADTLogEntry -Message "Installer file not found: $installerPath" -Severity 3
            throw "Installer file not found: $installerPath"
        }
    }
'@


    $uninstallSnippet = @'
## Data-driven uninstall (values from SupportFiles\AppConfig.json).
    if ($appConfig.Uninstall) {
        $uninstallSuccess = $false
        # Codes that mean "the app is gone" — checked identically by both loops below so they cannot
        # drift. 0 = removed. 1605 = "this product is not installed": the goal state already holds
        # (a user removed it by hand), which is a no-op success, matching how the msix branch treats an
        # already-absent package. 1641/3010 = removed AND a reboot was initiated/is required. Treating
        # 1641 or 1605 as failure would make the uninstall throw on a successful removal.
        $w32tUninstallOk = @(0, 1605, 1641, 3010)
        foreach ($pc in @($appConfig.Uninstall.ProductCodes)) {
            if (-not $pc -or $uninstallSuccess) { continue }
            $r = Start-ADTMsiProcess -Action Uninstall -ProductCode $pc -PassThru
            if ($r -and $r.ExitCode -in $w32tUninstallOk) { $uninstallSuccess = $true }
        }
        foreach ($u in @($appConfig.Uninstall.Uninstallers)) {
            if (-not $u -or $uninstallSuccess) { continue }
            $r = $null
            switch ($u.Type) {
                'msi' { $r = Start-ADTMsiProcess -Action Uninstall -ProductCode $u.ProductCode -PassThru }
                'exe' {
                    $spUninstall = @{ FilePath = $u.Path; PassThru = $true }
                    if ($u.Args) { $spUninstall['ArgumentList'] = $u.Args }
                    $r = Start-ADTProcess @spUninstall
                }
                'msix' {
                    # Identity-driven removal (exact Name match; Remove-AppxPackage has no exit code,
                    # so success = the package is gone for ALL users afterwards; $r stays $null).
                    if ($u.PackageName) {
                        foreach ($prov in @(Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq $u.PackageName })) {
                            try { Remove-AppxProvisionedPackage -Online -PackageName $prov.PackageName | Out-Null }
                            catch { Write-ADTLogEntry -Message "Deprovisioning failed: $($_.Exception.Message)" -Severity 2 }
                        }
                        foreach ($pkg in @(Get-AppxPackage -AllUsers -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $u.PackageName })) {
                            try { Remove-AppxPackage -Package $pkg.PackageFullName -AllUsers -ErrorAction Stop }
                            catch { Write-ADTLogEntry -Message "Remove-AppxPackage failed: $($_.Exception.Message)" -Severity 2 }
                        }
                        if (@(Get-AppxPackage -AllUsers -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $u.PackageName }).Count -eq 0) {
                            $uninstallSuccess = $true
                        }
                    }
                }
            }
            if ($r -and $r.ExitCode -in $w32tUninstallOk) { $uninstallSuccess = $true }
        }
        # Fail loudly when nothing actually uninstalled. $uninstallSuccess used to be computed and then
        # never read: every uninstaller could fail and the script still returned 0, so Intune recorded a
        # successful uninstall and the post-uninstall tattoo removal then made the app UNDETECTED - the
        # app stays on the device and Intune believes it is gone. Only raise this when there was
        # something to do (an empty Uninstall section is a separate, configure-time problem).
        if (-not $uninstallSuccess -and (@($appConfig.Uninstall.ProductCodes).Count + @($appConfig.Uninstall.Uninstallers).Count) -gt 0) {
            throw 'Uninstall failed: no uninstaller reported success (see the entries above for the cause).'
        }
        foreach ($folder in @($appConfig.Uninstall.CleanupPaths)) {
            if (-not $folder) { continue }
            if (Test-Path -LiteralPath $folder) {
                try { Remove-ADTFolder -Path $folder }
                catch { Write-ADTLogEntry -Message "Cleanup failed for ${folder}: $($_.Exception.Message)" -Severity 2 }
            }
        }
    }
'@


    # Install "tattoo": write HKLM:\SOFTWARE\<Author>\<Vendor>\<Name>\Version at install and remove it at
    # uninstall. The Intune detection rule keys off this value (installed AND correct version). Values come
    # from $appConfig (App.DisplayName is always populated — including MSI Zero-Config, where App.Name is
    # empty), so the key is byte-identical to what Get-Win32DetectionRules computes and every empty-segment
    # case is guarded out. Works for both EXE and MSI (no UseDefaultMsi exclusion).
    $postInstallTattoo = @'
## <Perform Post-Installation tasks here>
 
    ## win32-toolkit install tattoo - records install state + version for the Intune detection rule.
    $w32tName = if ($appConfig.App.DisplayName) { $appConfig.App.DisplayName } else { $appConfig.App.Name }
    if ($w32tName -and $appConfig.App.Vendor -and $appConfig.App.Version -and $appConfig.App.ScriptAuthor) {
        Set-ADTRegistryKey -Key "HKLM:\SOFTWARE\$($appConfig.App.ScriptAuthor)\$($appConfig.App.Vendor)\$w32tName" -Name 'Version' -Value "$($appConfig.App.Version)" -Type 'String'
    }
'@


    $postUninstallTattoo = @'
## <Perform Post-Uninstallation tasks here>
 
    ## win32-toolkit install tattoo - remove the key written during install.
    $w32tName = if ($appConfig.App.DisplayName) { $appConfig.App.DisplayName } else { $appConfig.App.Name }
    if ($w32tName -and $appConfig.App.Vendor -and $appConfig.App.ScriptAuthor) {
        Remove-ADTRegistryKey -Key "HKLM:\SOFTWARE\$($appConfig.App.ScriptAuthor)\$($appConfig.App.Vendor)\$w32tName" -Recurse
    }
'@


    # ---- Verify every anchor BEFORE patching (PSADT template drift protection) ----
    # A missed String.Replace is a silent no-op: the project would LOOK configured but isn't.
    # CRITICAL anchors abort without writing (a half-patched script whose snippets reference an
    # undefined $appConfig, or that installs nothing, must never ship). Non-critical misses still
    # write but warn + return $false so callers surface "did not complete cleanly".
    # NOTE: verification only runs on first patch — the idempotency early-return above means a
    # previously patched script is not re-validated on re-runs.
    $anchors = @(
        @{ Anchor = '$adtSession = @{';                          Label = 'session loader';           Critical = $true }
        @{ Anchor = '## <Perform Installation tasks here>';      Label = 'Install marker';           Critical = $true }
        @{ Anchor = "AppVendor = ''";                            Label = 'AppVendor';                Critical = $false }
        @{ Anchor = "AppName = ''";                              Label = 'AppName';                  Critical = $false }
        @{ Anchor = "AppVersion = ''";                           Label = 'AppVersion';               Critical = $false }
        @{ Anchor = "AppArch = ''";                              Label = 'AppArch';                  Critical = $false }
        @{ Anchor = 'AppProcessesToClose = @()';                 Label = 'AppProcessesToClose';      Critical = $false }
        @{ Anchor = '## <Perform Uninstallation tasks here>';    Label = 'Uninstall marker';         Critical = $false }
        @{ Anchor = '## <Perform Post-Installation tasks here>'; Label = 'Post-Install marker (tattoo)';   Critical = $false }
        @{ Anchor = '## <Perform Post-Uninstallation tasks here>'; Label = 'Post-Uninstall marker (tattoo)'; Critical = $false }
    )
    $missed = @($anchors | Where-Object { -not $content.Contains($_.Anchor) })
    $dateMatch = [regex]::Match($content, "AppScriptDate = '\d{4}-\d{2}-\d{2}'")
    if (-not $dateMatch.Success) {
        $missed += @{ Anchor = "AppScriptDate = '<date>'"; Label = 'AppScriptDate'; Critical = $false }
    }
    foreach ($m in $missed) {
        Write-Warning "Data-driven patch anchor not found: $($m.Label) — the installed PSADT template may have drifted from what this module expects."
    }
    if (@($missed | Where-Object { $_.Critical }).Count -gt 0) {
        Write-Warning "Critical anchor(s) missing — leaving $ScriptPath UNCHANGED. Update the module for this PSADT version, or pin the PSADT template version."
        return $false
    }

    # ---- Apply patches with ordinal String.Replace (no regex/$-token pitfalls) ----
    $content = $content.Replace('$adtSession = @{', $loader)
    $content = $content.Replace("AppVendor = ''",  'AppVendor = $appConfig.App.Vendor')
    $content = $content.Replace("AppName = ''",    'AppName = $appConfig.App.Name')
    $content = $content.Replace("AppVersion = ''", 'AppVersion = $appConfig.App.Version')
    $content = $content.Replace("AppArch = ''",    'AppArch = $appConfig.App.Arch')
    $content = $content.Replace('AppProcessesToClose = @()', 'AppProcessesToClose = @($appConfig.ProcessesToClose | Where-Object { $_ })')

    # AppScriptDate default varies per scaffold — extract the literal, then ordinal-replace it.
    if ($dateMatch.Success) {
        $content = $content.Replace($dateMatch.Value, 'AppScriptDate = $appConfig.App.ScriptDate')
    }

    if ($ManualInstall) {
        # Hard app — leave the Install region markers for the operator to author.
        $content = $content.Replace(
            '## <Perform Installation tasks here>',
            "## <Perform Installation tasks here>`r`n ## MANUAL APP: write your Pre-Install / Install / Post-Install logic in these regions.`r`n ## The uninstall is auto-generated from the sandbox capture — no action needed there.")
    }
    else {
        $content = $content.Replace('## <Perform Installation tasks here>', $installSnippet)
    }
    $content = $content.Replace('## <Perform Uninstallation tasks here>', $uninstallSnippet)

    # Install tattoo (both install modes, incl. manual/hard apps) — value-free, safe to splice.
    $content = $content.Replace('## <Perform Post-Installation tasks here>',   $postInstallTattoo)
    $content = $content.Replace('## <Perform Post-Uninstallation tasks here>', $postUninstallTattoo)

    # UTF-8 WITH BOM: this script runs under Windows PowerShell 5.1 on the device (Intune's
    # powershell.exe), which decodes a BOM-less file as ANSI — non-ASCII branding/vendor/app names would
    # mojibake silently. PS7's Set-Content -Encoding UTF8 writes NO BOM, so write the bytes ourselves.
    [System.IO.File]::WriteAllText($ScriptPath, $content, (New-Object System.Text.UTF8Encoding($true)))

    # Sanity: confirm the result still parses.
    $errs = $null
    [System.Management.Automation.Language.Parser]::ParseFile($ScriptPath, [ref]$null, [ref]$errs) | Out-Null
    if ($errs -and $errs.Count) {
        Write-Warning "Data-driven patch produced parse errors in $ScriptPath : $($errs[0].Message)"
        return $false
    }
    # Non-critical anchor misses: the file was written, but signal "not cleanly configured".
    return ($missed.Count -eq 0)
}