PSProfileWatcher.psm1


<#
    .SYNOPSIS
        Registers the PSProfileWatcher check for the current user's profile.
    .DESCRIPTION
        Registers the PSProfileWatcher check for the current user's profile.
    .INPUTS
        None
    .OUTPUTS
        None
    .EXAMPLE
        Register-PSProfileCheck
    .NOTES
        None
#>


function Add-PSProfileCheck {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param()
    <#
    .SYNOPSIS
        Adds a call to Test-PSProfileHash at the beginning of the current user's profile.
    .DESCRIPTION
        Inserts a call to Test-PSProfileHash at the very beginning of the current user's
        PowerShell profile so that the hash check runs before any other profile code.
    .INPUTS
        None
    .OUTPUTS
        None
    .EXAMPLE
        Add-PSProfileCheck

        Adds the following line to the beginning of the user's profile:
            `Test-PSProfileHash`
    .NOTES
        None
    #>

    
    # Block to insert at top of profile: import module then call the check
    $profileCheckBlock = "Import-Module PSProfileWatcher -ErrorAction SilentlyContinue`nTest-PSProfileHash"
    
    try {
        # Read the current profile content
        $profileContent = Get-Content -Path $profile -Raw -ErrorAction Stop
    }
    catch {
        Write-Error "❌ Could not read profile at '$profile'."
        throw $_.Exception.Message
    }

    # Check if the check (or the import+call) is already in the profile
    if ($profileContent -match 'Test-PSProfileHash' -or $profileContent -match 'Import-Module\s+PSProfileWatcher') {
        Write-Host "⚠️ Profile check or module import already exists in profile." -ForegroundColor Yellow
        return
    }

    # Add the import+call block to the beginning of the profile
    $newProfileContent = "$profileCheckBlock`n`n$profileContent"

    $actionTarget = "$profile"
    if ($PSCmdlet.ShouldProcess($actionTarget, 'Add PSProfileWatcher check')) {
        try {
            $newProfileContent | Out-File -FilePath $profile -Encoding UTF8 -Force -ErrorAction Stop
            Write-Host "✅ Profile check added successfully to the beginning of '$profile'." -ForegroundColor Green
        }
        catch {
            Write-Error "❌ Could not write profile check to file."
            throw $_.Exception.Message
        }
    }
    else {
        continue
    }
}
<#
    .SYNOPSIS
        Saves the current user's profile hash for comparison at next run.
    .DESCRIPTION
        Saves the current user's profile hash for comparison at next run.
    .INPUTS
        None
    .OUTPUTS
        None
    .EXAMPLE
        Export-PSProfileHash

        Writes the following files to the current user's profile directory (e.g.):
            - Microsoft.VSCode_profile.ps1_83580fb6-fc89-4756-8a58-b1f4d3f3b7ff.hash
            - Microsoft.VSCode_profile.ps1_83580fb6-fc89-4756-8a58-b1f4d3f3b7ff.backup
        
        The hash file contains the SHA256 hash of the current user's profile at that point.
        The backup file contains a copy of the current user's profile at that point.
    .NOTES
        None
#>


function Export-PSProfileHash {
    $guid = [guid]::NewGuid()
    $profileHashFile = "$($profile)_$($guid).hash"
    $profileBackupFile = "$($profile)_$($guid).backup"

    try {
        $Hash = Get-FileHash -Path $profile -Algorithm SHA256 -ErrorAction Stop | Select-Object -ExpandProperty Hash 
    }
    catch {
        Write-Warning "⚠️ Could not calculate hash for profile."
        throw $_.Exception.Message
    }

    try {
        $Hash | Out-File -FilePath $profileHashFile -Encoding ASCII -Force -ErrorAction Stop
        Write-Host "✅ Profile hash saved successfully to '$profileHashFile'." -ForegroundColor Green
    }
    catch {
        Write-Error "❌ Could not write hash to file."
        throw $_.Exception.Message
    }

    try {
        Copy-Item -Path $profile -Destination $profileBackupFile -Force -ErrorAction Stop
        Write-Host "✅ Profile backup saved successfully to '$profileBackupFile'." -ForegroundColor Green
    }
    catch {
        Write-Error "❌ Could not save profile backup to '$profileBackupFile'."
        throw $_.Exception.Message
    }
}
function Remove-PSProfileCheck {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param()
    <#
    .SYNOPSIS
        Removes the Test-PSProfileHash call from the current user's profile.
    .DESCRIPTION
        Removes the Test-PSProfileHash function call that was added to the beginning
        of the current user's PowerShell profile by Add-PSProfileCheck.
    .INPUTS
        None
    .OUTPUTS
        None
    .EXAMPLE
        Remove-PSProfileCheck

        Removes the Test-PSProfileHash call from the user's profile.
    .NOTES
        None
    #>

    
    # Block components to remove from the profile: import statement and the check call
    $importLinePattern = 'Import-Module\s+PSProfileWatcher(?:\s+-ErrorAction\s+\w+)?'
    $callLinePattern = 'Test-PSProfileHash'
    
    try {
        # Read the current profile content
        $profileContent = Get-Content -Path $profile -Raw -ErrorAction Stop
    }
    catch {
        Write-Error "❌ Could not read profile at '$profile'."
        throw $_.Exception.Message
    }

    # Check if the check exists in the profile
    if ($profileContent -notmatch $callLinePattern -and $profileContent -notmatch $importLinePattern) {
        Write-Host "⚠️ Profile check does not exist in profile." -ForegroundColor Yellow
        return
    }

    # Remove the import line and the check call (and any immediate blank lines)
    # Use the pattern variables (not literal strings) when calling -replace so the regex is applied
    $newProfileContent = $profileContent -replace ($importLinePattern + '\r?\n?'), ''
    $newProfileContent = $newProfileContent -replace ($callLinePattern + '\r?\n\s*'), ''

    # Trim any leading blank lines that may remain after removal
    $newProfileContent = $newProfileContent.TrimStart("`r", "`n")

    $actionTarget = "$profile"
    if ($PSCmdlet.ShouldProcess($actionTarget, 'Remove PSProfileWatcher check')) {
        try {
            $newProfileContent | Out-File -FilePath $profile -Encoding UTF8 -Force -ErrorAction Stop
            Write-Host "✅ Profile check removed successfully from '$profile'." -ForegroundColor Green
        }
        catch {
            Write-Error "❌ Could not write to profile file."
            throw $_.Exception.Message
        }
    }
    else {
        continue
    }
}
function Test-PSProfileHash {
    <#
    .SYNOPSIS
        Test the current user's profile hash against the expected stored hash
    .DESCRIPTION
        Test the current user's profile hash against the expected stored hash.
        This function is designed to be called at the beginning of your PowerShell profile
        to detect if the profile file has been modified since the last hash export.
    .INPUTS
        None
    .OUTPUTS
        System.Boolean
        Returns $true if the profile hash matches the stored hash, $false otherwise.
    .EXAMPLE
        Test-PSProfileHash

        Compares the current profile's hash against the most recently saved hash file.
    .NOTES
        None
    #>


    try {
        $profilesPath = Split-Path -Path $profile -Parent
        $latestHashFile = Get-ChildItem -Path $profilesPath -Filter "*.hash" -ErrorAction Stop | 
            Sort-Object -Property CreationTime | 
            Select-Object -Last 1
        
        if (-not $latestHashFile) {
            Write-Warning "⚠️ No profile hash file found. Run 'Export-PSProfileHash' first."
        }

        $lastProfileHash = Get-Content -Path $latestHashFile.FullName -ErrorAction Stop
    }
    catch {
        Write-Error "❌ Could not read stored profile hash."
        throw $_.Exception.Message
    }

    try {
        $currentProfileHash = Get-FileHash -Path $profile -Algorithm SHA256 -ErrorAction Stop | 
            Select-Object -ExpandProperty Hash
    }
    catch {
        Write-Error "❌ Could not calculate current profile hash."
        throw $_.Exception.Message
    }

    if ($lastProfileHash -eq $currentProfileHash) {
        Write-Host "✅ Profile hash matches." -ForegroundColor Green
    }
    else {
        try {
            Write-Host @"
██████╗ ███████╗██████╗ ██████╗ ██████╗ ███████╗██╗██╗ ███████╗██╗ ██╗ █████╗ ████████╗ ██████╗██╗ ██╗███████╗██████╗
██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔═══██╗██╔════╝██║██║ ██╔════╝██║ ██║██╔══██╗╚══██╔══╝██╔════╝██║ ██║██╔════╝██╔══██╗
██████╔╝███████╗██████╔╝██████╔╝██║ ██║█████╗ ██║██║ █████╗ ██║ █╗ ██║███████║ ██║ ██║ ███████║█████╗ ██████╔╝
██╔═══╝ ╚════██║██╔═══╝ ██╔══██╗██║ ██║██╔══╝ ██║██║ ██╔══╝ ██║███╗██║██╔══██║ ██║ ██║ ██╔══██║██╔══╝ ██╔══██╗
██║ ███████║██║ ██║ ██║╚██████╔╝██║ ██║███████╗███████╗╚███╔███╔╝██║ ██║ ██║ ╚██████╗██║ ██║███████╗██║ ██║
"@

            
            Write-Host "`nProfile hash does not match; verify these changes are expected and if so, run Export-PSProfileHash to update the stored hash." -ForegroundColor Yellow
            Write-Host "`nIf you did not expect these changes, abort loading the profile and remove unexpected changes prior to next load." -ForegroundColor Yellow
            $latestBackupFile = Get-ChildItem -Path $profilesPath -Filter "*.backup" -ErrorAction Stop | 
                Sort-Object -Property CreationTime | 
                Select-Object -Last 1
            
            if ($latestBackupFile) {
                $lastProfileContent = Get-Content -Path $latestBackupFile.FullName -ErrorAction Stop
                $currentProfileContent = Get-Content -Path $profile -ErrorAction Stop
                
                Write-Host "`n----- Profile Changes -----" -ForegroundColor Yellow
                Compare-Object -ReferenceObject $lastProfileContent -DifferenceObject $currentProfileContent | 
                    Out-String | 
                    Write-Host
            }

            $userInput = Read-Host "Do you want to continue loading the profile? (y/N)"
            switch ( $userInput ) {
                'y' {
                    continue
                }
                'Y' {
                    continue
                }
                default {
                    Write-Host "Aborting profile load." -ForegroundColor Red
                    exit
                }
            }
        }
        catch {
            Write-Warning "⚠️ Could not display profile differences."
        }
    }
}
Export-ModuleMember -Function *