Functions/Invoke-InstallerCacheRepair.ps1

<#
.SYNOPSIS
    Copies FixMissingMSI locally, runs it non-interactively via .NET reflection,
    attempts to source missing MSI/MSP files from local and shared caches,
    and exports a CSV report of unresolved items.
 
.DESCRIPTION
    Intended for broad deployment (e.g., MECM). This script stages FixMissingMSI locally,
    then loads the FixMissingMSI.exe assembly and invokes its internal methods via reflection,
    bypassing the GUI to run non-interactively.
 
    For each configured source path, the script:
      1) Loads FixMissingMSI.exe and instantiates the hidden Form to satisfy internal dependencies.
      2) Points FixMissingMSI to a given setup source directory (MSI/MSP media).
      3) Scans setup media, installed products/patches, and LastUsedSource locations.
      4) Generates FixCommand entries for any missing/mismatched rows.
      5) If a FixCommand is still empty, attempts to build a COPY command from the shared cache
         under \\<Server>\<Share>\FixMissingMSI\Cache\{Products,Patches}.
      6) Exports unresolved rows without a FixCommand to the central Reports folder.
      7) Executes generated FixCommands (guarded by ShouldProcess) to repopulate C:\Windows\Installer.
 
    > Note: FixMissingMSI is a GUI application without a native CLI. This script leverages .NET
    > reflection to call internal types and methods directly. If FixMissingMSI internals change
    > in future versions, binding calls may need to be updated.
 
.PARAMETER FileSharePath
    UNC to the share root that contains the app tree. Example: \\FS01\Software
    The script expects the app at: \\<Server>\<Share>\FixMissingMSI
 
.PARAMETER SourcePaths
    One or more setup media folders (local or UNC) to scan for MSI/MSP packages.
    Defaults to the shared Cache root: \\<Server>\<Share>\FixMissingMSI\Cache
 
.PARAMETER LocalWorkPath
    Local working directory where FixMissingMSI is staged and executed.
    Defaults to $env:TEMP\FixMissingMSI.
 
.PARAMETER RunFromShare
    If specified, runs FixMissingMSI directly from the network share instead of
    copying it to a local working directory first.
 
    This can be used in trusted environments with reliable network access,
    where the performance benefit outweighs the added risk of running directly
    from a network path.
 
    By default, FixMissingMSI is copied locally before execution to avoid
    issues caused by antivirus scanning or intermittent share connectivity.
 
.PARAMETER ReportOnly
    If specified, do not execute any FixCommand operations.
    The script still scans sources and writes the unresolved CSV report.
 
.EXAMPLE
PS> Invoke-InstallerCacheRepair -FileSharePath \\FS01\Software
 
    Stages FixMissingMSI locally, scans \\FS01\Software\FixMissingMSI\Cache,
    attempts repair, exports unresolved CSV to \\FS01\Software\FixMissingMSI\Reports,
    and executes FixCommands to repopulate the local installer cache.
 
.EXAMPLE
PS> Invoke-InstallerCacheRepair -FileSharePath \\FS01\Software -SourcePaths 'D:\Media','\\FS01\Builds\Office'
 
    Scans the provided source paths in order (D:\Media, then \\FS01\Builds\Office) instead of the default shared cache.
 
.EXAMPLE
PS> Invoke-InstallerCacheRepair -FileSharePath \\FS01\Software -SourcePaths 'D:\Media','\\FS01\Builds\Office' -ReportOnly
 
    Scans the provided source paths in order (D:\Media, then \\FS01\Builds\Office) instead of the default shared cache.
 
.EXAMPLE
PS> Invoke-InstallerCacheRepair -FileSharePath \\FS01\Software -RunFromShare
 
    Runs FixMissingMSI directly from the network share, without copying it locally.
    Useful when testing or running in low-latency, trusted environments.
 
.NOTES
    Author: Joey Eckelbarger
 
    Credits:
        FixMissingMSI is authored and maintained by suyouquan
        Source: https://github.com/suyouquan/SQLSetupTools/releases/tag/V2.2.1
 
    Security:
        This script writes to C:\Windows\Installer via generated FixCommands.
 
    Requires:
        - PowerShell 5.1+
        - NTFS permissions to write to $LocalWorkPath
        - Read access to \\<Server>\<Share>\FixMissingMSI and subfolders
        - Write access to \\<Server>\<Share>\FixMissingMSI\Reports
#>

#Requires -RunAsAdministrator
function Invoke-InstallerCacheRepair {
    param(
        [Parameter(Mandatory = $true)]
        [string]$FileSharePath,
        [string[]]$SourcePaths = "",
        [string]$LocalWorkPath = (Join-Path $env:TEMP 'FixMissingMSI'),
        [switch]$RunFromShare,
        [switch]$ReportOnly
    )

    $ErrorActionPreference = 'Stop'

    # We want to ensure an empty string to be in here to ensure that the for loop runs at least 1x with an emptry string so FixMissingMSI tries to recover using sources from the original installation metadata in registry
    if($sourcePaths -notcontains ""){
        $sourcePaths += ""
    }

    # Compose shared paths (app folder and caches) once. Keep all shared I/O under the app folder.
    $ShareRoot      = $FileSharePath.TrimEnd('\')
    $AppFolder      = Join-Path $ShareRoot 'FixMissingMSI'
    $CacheRoot      = Join-Path $AppFolder 'Cache'
    $ProductsCache  = Join-Path $CacheRoot 'Products'
    $PatchesCache   = Join-Path $CacheRoot 'Patches'
    $ReportsPath    = Join-Path $AppFolder 'Reports'

    # Prepare local working directory and transcript path first (Start-Transcript requires an existing folder).
    if (-not (Test-Path -LiteralPath $LocalWorkPath)) {
        New-Item -ItemType Directory -Path $LocalWorkPath -Force | Out-Null
    }
    $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
    $TranscriptPath = Join-Path $LocalWorkPath "Transcript-Invoke-InstallerCacheRepair-$($env:COMPUTERNAME)-$timestamp.txt"
    Start-Transcript -Path $TranscriptPath | Out-Null

    try {
        # Stage FixMissingMSI locally. Copy only top-level binaries/config files.
        # Why: We need FixMissingMSI.exe and its dependencies, but not Cache\ or Reports\ folders.
        if($RunFromShare -eq $false){
            Get-ChildItem -Path $AppFolder -File | ForEach-Object {
                if((Test-Path -LiteralPath (Join-Path $LocalWorkPath $_.Name)) -eq $false){
                    Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $LocalWorkPath $_.Name) -Force
                }
            }

            $exePath = Join-Path $LocalWorkPath 'FixMissingMSI.exe'
        } else {
            $exePath = Join-Path $AppFolder 'FixMissingMSI.exe'
        }

        Push-Location $LocalWorkPath

        try {
            $serverName = $env:COMPUTERNAME

            $mergedBadRowsWithoutFix = @()
            $mergedFixCommands       = @()

            foreach ($source in $SourcePaths) {
                $sourceLabel = if($source -eq ""){
                    "No specified source; FixMissingMSI will look for local sources from Registry install metadata (as well as the shared cache if available/populated)."
                } else {
                    $source
                }

                # 1) Load the FixMissingMSI assembly
                if (-not (Test-Path -LiteralPath $exePath)) {
                    throw "FixMissingMSI.exe not found at expected path: $exePath"
                }
                $asm = [System.Reflection.Assembly]::LoadFrom($exePath)

                # Create an instance of the main form. The backend expects a form handle; no UI is shown.
                Add-Type -AssemblyName System.Windows.Forms
                $formType = $asm.GetTypes() | Where-Object { $_.Name -eq 'Form1' }
                if (-not $formType) { throw "Form1 type not found in FixMissingMSI assembly." }
                $form = [Activator]::CreateInstance($formType)
                [System.Windows.Forms.Application]::EnableVisualStyles()
                [void]$form.Handle  # Initialize handle to avoid null reference exceptions in backend calls.

                # 2) Access internal types and required fields
                $myDataType = $asm.GetType('FixMissingMSI.myData')
                if (-not $myDataType) { throw "Type FixMissingMSI.myData not found." }

                # Set filters off and clear filter string
                $null = $myDataType.GetField('isFilterOn', [Reflection.BindingFlags]'Static,Public')
                $fldFilterStr = $myDataType.GetField('filterString', [Reflection.BindingFlags]'Static,Public')
                if ($fldFilterStr) { $fldFilterStr.SetValue($null, '') }

                # 3) Point to setup media source directory
                $fldSetupSource = $myDataType.GetField('setupSource', [Reflection.BindingFlags]'Static,Public')
                if ($fldSetupSource) { $fldSetupSource.SetValue($null, $source) }

                # 4) Scan supplied media for MSI/MSP packages
                $scanMedia = $myDataType.GetMethod('ScanSetupMedia', [Reflection.BindingFlags]'Static,Public')
                if ($scanMedia) { $null = $scanMedia.Invoke($null, @()) }

                # 5) Scan installed products and patches
                $scanProducts = $myDataType.GetMethod('ScanProducts', [Reflection.BindingFlags]'Static,Public')
                if ($scanProducts) { $null = $scanProducts.Invoke($null, @()) }

                # 6) Include extra packages from LastUsedSource (mirrors AfterDone behavior)
                $addFromLast = $myDataType.GetMethod('AddMsiMspPackageFromLastUsedSource', [Reflection.BindingFlags]'Static,NonPublic')
                if ($addFromLast) { $null = $addFromLast.Invoke($null, @()) }

                # 7) Generate FixCommand strings for missing/mismatched rows
                $updateFix = $myDataType.GetMethod('UpdateFixCommand', [Reflection.BindingFlags]'Static,Public')
                if ($updateFix) { $null = $updateFix.Invoke($null, @()) }

                # 8) Retrieve rows collection
                $rowsField = $myDataType.GetField('rows', [Reflection.BindingFlags]'Static,Public')
                $rows = if ($rowsField) { $rowsField.GetValue($null) } else { $null }
                if (-not $rows) { Write-Verbose "No rows returned from myData.rows."; continue }

                # 9) Filter to missing or mismatched
                $badRows = $rows | Where-Object { $_.Status -in 'Missing','Mismatched' }

                if($null -eq $badRows){
                    Write-Output "No missing files; exiting"
                    break 
                }

                # 9.5) If FixCommand is empty, try building a COPY command from the shared cache
                foreach ($row in ($badRows | Where-Object { -not $_.FixCommand })) {
                    if($row.ProductCode -and $row.PackageCode -and $row.PackageName){
                        $productCandidate = Join-Path $ProductsCache (Join-Path $($row.ProductCode) (Join-Path $($row.PackageCode) $($row.PackageName)))
                        if ((Test-Path -LiteralPath $productCandidate)) {
                            Write-Output "Found missing files in shared cache, populating FixCommand value"
                            $row.FixCommand = "COPY `"$productCandidate`" `"C:\Windows\Installer\$($row.CachedMsiMsp)`""
                            continue
                        }
                    }

                    if($row.ProductCode -and $row.PatchCode -and $row.PackageName){
                        $patchCandidate   = Join-Path $PatchesCache (Join-Path $($row.ProductCode) (Join-Path $($row.PatchCode)   $($row.PackageName)))
                        if ((Test-Path -LiteralPath $patchCandidate)) {
                            Write-Output "Found missing files in shared cache, populating FixCommand value"
                            $row.FixCommand = "COPY `"$patchCandidate`" `"C:\Windows\Installer\$($row.CachedMsiMsp)`""

                            continue
                        }
                    }
                }

                $badRowsWithFix    = @($badRows | Where-Object { $_.FixCommand })      | Select-Object *,@{N='Hostname';E={$serverName}}, @{N='SourcePath';E={$source}}, @{N="CompareString";E={"$($_.ProductCode)-$($_.PackageCode)-$($_.PatchCode)-$($_.PackageName)"}}
                $badRowsWithoutFix = @($badRows | Where-Object { -not $_.FixCommand }) | Select-Object *,@{N='Hostname';E={$serverName}}, @{N='SourcePath';E={$source}}, @{N="CompareString";E={"$($_.ProductCode)-$($_.PackageCode)-$($_.PatchCode)-$($_.PackageName)"}}
                
                if($badRowsWithoutFix){
                    $mergedBadRowsWithoutFix += $badRowsWithoutFix | Where-Object {$_.CompareString -notin $mergedBadRowsWithoutFix.CompareString}
                }

                $mergedBadRowsWithoutFix = $mergedBadRowsWithoutFix | Where-Object {$_.CompareString -notin $badRowsWithFix.CompareString}
                

                [array]$mergedFixCommands += $badRowsWithFix | Select-Object -ExpandProperty FixCommand

                $missingCount    = @($badRows | Where-Object { $_.Status -eq 'Missing'   }).Count
                $mismatchedCount = @($badRows | Where-Object { $_.Status -eq 'Mismatched'}).Count
                Write-Output "Source: $sourceLabel"
                Write-Output "Missing: $missingCount`nMismatched: $mismatchedCount`nTo be fixed: $($badRowsWithFix.Count)"
            }

            $mergedFixCommands = $mergedFixCommands | Sort-Object * -Unique

            # 10) Execute fix commands. Copies to C:\Windows\Installer as needed.
            foreach ($fixCommand in $mergedFixCommands) {
                Write-Output "$fixCommand" # logs cmd executed to the transcript
                if($reportOnly){
                    continue # dont run if report only
                }
                & cmd /c $fixCommand
            }
            # Export unresolved rows to central report (per host). Overwrites by host design; adjust if you prefer timestamped files.
            $reportFile = Join-Path $ReportsPath "$serverName.csv"
            $mergedBadRowsWithoutFix | Sort-Object * -Unique | Export-Csv -Path $reportFile -NoTypeInformation -Force
        } finally {
            Pop-Location
        }
    } finally {
        Stop-Transcript | Out-Null
    }
}