MSIPatches.psm1

function Get-MsiPatches {
    <#
    .SYNOPSIS
        Scans the "C:\Windows\Installer" directory for all installed and orpaned msp files.
    .DESCRIPTION
        Office installations can leave behind a large amount of orphaned patches which can take up many GBs of disk space.
        Using the "Get-MSIPatchInfo" cmdlet from the "MSI" module we can determine which msp files are currently installed.
        From there we can calculate the amount and size of the orpaned msp files.
    .EXAMPLE
        Get-MsiPatches
        
        This will display a PsCustomObject of all msp files and size, which are installed and size, and which are
        orpaned and their total size.
    .EXAMPLE
        Get-MsiPatches -Verbose
        
        Same as above only verbose information is displayed for each msp detailing whether they are installed or orphaned.
    .EXAMPLE
        Get-MsiPatches | FT

        Simply changes the format to a table view of the object.
    .NOTES
        Author: Mark Kerry
        Date: 08/01/2018
    #>
 

    [CmdletBinding()] 
    Param()

    # Begin by checking the user in running the function from elevated priviledges
    if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
        Write-Warning "You need to run this script from an elevated PowerShell prompt!`nPlease start PowerShell as an Administrator..."
        break
    }

    if ($PSVersionTable.PSEdition -eq 'Core') {
        Write-Warning "This function is not compatible with PowerShell Core as it reiles on the MSI module binaries."
        break
    }

    # Get each .msp in the Installer directory and calculate the size.
    $allMsp = Get-Item -Path C:\Windows\Installer\*.msp | Measure-Object -Property Length -Sum
    $allMspSum = "{0:N2}" -f ($allMsp.sum / 1GB) + " GB"

    # List all msp files in %windir%\Installer and convert to string
    $strAllMsp = Get-ChildItem -Path C:\Windows\Installer\*.msp | Select-Object Name -ExpandProperty Name

    # List all currently installed msp files and convert to string
    $strInstalledMsps = Get-MSIPatchInfo | Select-Object LocalPackage -ExpandProperty LocalPackage

    # Create a new array of all the installed msp files but run a regex query to strip the path from the string so the format is the same as the strAllMsp variable
    $array = @()
    foreach ($Msp in $strInstalledMsps) {
       $a = $Msp -creplace '(?s)^.*\\', ''
       $array += $a
    }
    # Remove any duplicate values from the array
    $array = $array | Select-Object -uniq

    # Now list both installed and Obsolete msps
    $inCount=0
    $obCount=0
    $inst = @()
    $obinst = @()
    foreach ($x in $strAllMsp) {
        if ($array.Contains($x)) {
            Write-Verbose "$x is installed."
            $inst += $x
            $inCount++
        }
        else {
            Write-Verbose "$x is obsolete and can be moved/deleted."
            $obinst += $x
            $obCount++
        }
    }
    
    # Scan for each installed msp and calculate the total size of them all.
    Write-Progress -Activity 'Retrieving installed msp files...' -Status '33% Complete.' -PercentComplete 33

    $size = $null
    foreach ($i in $inst) {
        $instSize = Get-ChildItem -Path C:\Windows\Installer | Where-Object {$_.Name -like $i} | Select-Object Length -ExpandProperty Length
        $size += $instSize
    }
    $TotalInst = "{0:N2}" -f ($size / 1GB) + " GB"

    # Scan for each obsolete msp and calculate the total size of them all.
    Write-Progress -Activity 'Retrieving orphaned msp files...' -Status '66% Complete.' -PercentComplete 66

    $obsize = $null
    foreach ($o in $obinst) {
        $obinstSize = Get-ChildItem -Path C:\Windows\Installer | Where-Object {$_.Name -like $o} | Select-Object Length -ExpandProperty Length
        $obsize += $obinstSize
    }
    $TotalOb = "{0:N2}" -f ($obsize / 1GB) + " GB"
    
    # Display them all in a PsCustomObject
    [PsCustomObject]@{
        TotalPatchCount = $allMsp.Count
        TotalPatchSize = $allMspSum
        InstalledPatchCount = $inCount
        InstalledPatchSize = $TotalInst
        OrphanedPatchCount = $obCount
        OrphanedPatchSize = $TotalOb
    }
}

function Get-OrphanedPatches {
    <#
    .SYNOPSIS
        Scans the "C:\Windows\Installer" directory for all orpaned msp files.
    .DESCRIPTION
        Office installations can leave behind a large amount of orphaned patches which can take up many GBs of disk space.
        Using the "Get-MSIPatchInfo" cmdlet from the "MSI" module we can determine which msp files are currently installed.
        From there we can calculate the amount and size of the orpaned msp files.

        This can be run on it's own to list the orphaned patches, or piped to Move-OrphanedPatches or Remove-OrphanedPatches.
    .EXAMPLE
        Get-OrphanedPatches
        
        Simply lists orphaned msp files in "C:\Windows\Installer"
    .EXAMPLE
        Get-OrphanedPatches | Move-OrphanedPatches -Destination <String>
        
        If you want to free up space I recommend moving the orphaned msp files to another location so they can easily be
        restored. Supports the "-Whatif" and "Verbose" paramters.
    .EXAMPLE
        Get-OrphanedPatches | Remove-OrphanedPatches

        This will permanently delete the orphaned msp files from "C:\Windows\OInstaller". Supports the "-Whatif" and
        "Verbose" paramters.
    .NOTES
        Author: Mark Kerry
        Date: 08/01/2018
    #>
 

    [CmdletBinding()] 
    [OutputType([System.IO.FileInfo])]
    Param()

    # Begin by checking the user in running the function from elevated priviledges
    if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
        Write-Warning "You need to run this script from an elevated PowerShell prompt!`nPlease start PowerShell as an Administrator..."
        break
    }

    if ($PSVersionTable.PSEdition -eq 'Core') {
        Write-Warning "This function is not compatible with PowerShell Core as it reiles on the MSI module binaries."
        break
    }

    # List all msp files in %windir%\Installer and convert to string
    $strAllMsp = Get-ChildItem -Path C:\Windows\Installer\*.msp | Select-Object Name -ExpandProperty Name

    # List all currently installed msp files and convert to string
    $strInstalledMsps = Get-MSIPatchInfo | Select-Object LocalPackage -ExpandProperty LocalPackage

    # Create a new array of all the installed msp files but run a regex query to strip the path from the string so the format is the same as the strAllMsp variable
    $array = @()
    foreach ($Msp in $strInstalledMsps) {
       $a = $Msp -creplace '(?s)^.*\\', ''
       $array += $a
    }
    # Remove any duplicate values from the array
    $array = $array | Select-Object -uniq

    $obinst = @()
    foreach ($x in $strAllMsp) {
        if (!($array.Contains($x))) {
            $obinst += $x
        }
    }
    
    $obinst | ForEach-Object {Get-ChildItem -Path C:\Windows\Installer\$_} -ov item
}

function Move-OrphanedPatches {
    <#
    .SYNOPSIS
        Moves the orphaned patches to a specified backup location.
    .DESCRIPTION
        This function recieves the [System.IO.FileInfo] objects through the pipeline from Get-OrphanedPatches.
        It will move each object to a specified backup location. The location can be created if it doesn't exist.
    .EXAMPLE
        Get-OrphanedPatches | Move-OrphanedPatches -Destination <String>
        
        This will move the orphaned patches to a different location so they can be restored to "C:\Windows\Installer"
        directory if needed. Supports the "-Whatif" and "Verbose" paramters.
    .NOTES
        Author: Mark Kerry
        Date: 08/01/2018
    #>
 

    [CmdletBinding(SupportsShouldProcess=$true)]
    Param(
        [Parameter(
        Mandatory=$true,
        ValueFromPipeline=$true)]
        [System.IO.FileInfo]$item,

        [Parameter(
        Mandatory=$true)]
        [String]$Destination
    )
    
    begin {
        if (!(Test-Path -Path $Destination -PathType Container)) {
            $confirmation = Read-Host "Destination: $Destination does not exist. Create it? [y/n]"
            while ($confirmation -ne 'y') {
                if ($confirmation -eq 'n') {
                    break
                }
                $confirmation = Read-Host "Destination: $Destination does not exist. Create it? [y/n]"
            }
            if ($confirmation -eq 'y') {
                try {
                    New-Item -ItemType Directory $Destination
                }
                catch {
                    Write-Warning $_.exception.message
                }
            }
            else {
                break
            }
        }
    }

    process {
        try {
            Move-Item -Path $item -Destination $Destination -Verbose
        }
        catch {
            Write-Warning $_.exception.message
            break
        }
    }
}

function Restore-OrphanedPatches {
    <#
    .SYNOPSIS
        Restores the previously backed up msp files
    .DESCRIPTION
        This function can be run in the event of needing to restore the moved msp files.
    .EXAMPLE
        Restore-OrphanedPatches -BackupLocation D:\Backup

    .NOTES
        Author: Mark Kerry
        Date: 18/01/2018
    #>
 
    [CmdletBinding(SupportsShouldProcess=$true)] 
    Param(
        [Parameter(
        Mandatory=$true)]
        [String]$BackupLocation
    )

    # Begin by checking the user in running the function from elevated priviledges
    if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
        Write-Warning "You need to run this script from an elevated PowerShell prompt!`nPlease start PowerShell as an Administrator..."
        break
    }

    # Check the backup location is exists or is accessible
    if (Test-Path -Path $BackupLocation) {
        # If so check there are msp files in the location. Then attempt to move them back to the installer directory.
        if (Get-ChildItem -Path "$($BackupLocation)\*.msp") { 
            try {
                Move-Item -Path "$($BackupLocation)\*.msp" -Destination 'C:\Windows\Installer' -Verbose
            }
            catch {
                Write-Warning $_.exception.message
                break
            }
        }
        else {
            Write-Warning "Unable to find any msp files in $BackupLocation"
        }
    }
    else {
        Write-Warning "Unable to find $BackupLocation"
    }
}