TM-ProfileUtility.psm1

#Requires -Module TM-PSGitHubGistManagement
using namespace System
using namespace System.Management.Automation

function Get-CurrentPath {
<#
    .SYNOPSIS
    Returns the current location's provider path.
 
    .OUTPUTS
    Returns the ProviderPath except when the current location matches the start to a UNC path.
    In those cases the $executionContext.SessionState.Path.CurrentLocation.Path is returned instead.
#>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    $ProviderPath = (Get-Location).ProviderPath
    $result = if ($ProviderPath -match '\\\\') {
        $executionContext.SessionState.Path.CurrentLocation.Path
    } else {
        $ProviderPath
    }
    return $result
}


function Get-LastExecutionDuration {
<#
    .SYNOPSIS
    Returns the duration of the last executed command.
    This logic was taken from Steve Lee's powershell profile:
        https://gist.github.com/SteveL-MSFT/a208d2bd924691bae7ec7904cab0bd8e
#>

    [CmdletBinding()]
    [OutputType([string])]
    param($LastCommand = (Get-History -Count 1 -ErrorAction Ignore))
    if ($null -ne $LastCommand) {
        $cmdTime = if ($PSVersionTable.PSEdition -eq 'Desktop') {
            ($LastCommand.EndExecutionTime - $LastCommand.StartExecutionTime).TotalMilliseconds
        } else {
            $LastCommand.Duration.TotalMilliseconds
        }
        $units = 'ms'
        if ($cmdTime -ge 1000) {
            $units = 's'
            $cmdTime = $LastCommand.Duration.TotalSeconds
            if ($cmdTime -ge 60) {
                $units = 'm'
                $cmdTIme = $LastCommand.Duration.TotalMinutes
            }
        }
        if ($cmdTime) { return "$($cmdTime.ToString('#.##'))$units " }
    }
    return ''
}


function Import-TMProfileItem {
<#
    .SYNOPSIS
    Import-TMProfileItem attempts to import a module and will automatically install it if it doesn't exist.
 
    .PARAMETER Name
    The name of the module to be imported.
 
    .PARAMETER MinimumVersion
    The minimum version of the module to be imported. Default is '0.0.1'.
 
    .PARAMETER AllowPrerelease
    Allow the import of prerelease module versions.
 
    .PARAMETER ArgumentList
    The ArgumentList passed into the Import-Module command.
#>

    [CmdletBinding()]
    [OutputType([Void])]
    param (
        [Parameter(Mandatory)]
        [string]$Name,

        [Parameter(Mandatory=$false)]
        [string]$MinimumVersion = '0.0.1',

        [Parameter(Mandatory=$false)]
        [switch]$AllowPrerelease,

        [Parameter(Mandatory=$false)]
        [string[]]$ArgumentList = @()
    )

    $InstallModule = [hashtable]@{
        Name            = $Name
        ArgumentList    = $ArgumentList
        MinimumVersion  = $MinimumVersion
        AllowPrerelease = $AllowPrerelease
        Scope           = 'CurrentUser'
        Repository      = 'PSGallery'
        AcceptLicense   = $true
        Force           = $true
    }

    try {
        Import-Module $Name -ErrorAction Stop
    } catch [FileNotFoundException] {
        Install-Module @InstallModule
        Import-Module $Name
    }
}


function New-DateTimeEnvVar {
<#
    .SYNOPSIS
    Creates a new environment variable with a value of the current datetime.
 
    .PARAMETER VarName
    The name of the new environment variable to create.
#>

    [CmdletBinding()]
    [OutputType([Void])]
    param (
        [Parameter(Mandatory)]
        [string]$VarName
    )

    [Environment]::SetEnvironmentVariable( $VarName, [DateTime]::Now, [EnvironmentVariableTarget]::User )
    [Environment]::SetEnvironmentVariable( $VarName, [DateTime]::Now, [EnvironmentVariableTarget]::Process )
}


function New-PrivateConstantVariable {
<#
    .SYNOPSIS
    Create Private, Constant, Locally Scoped variables for use within the profile.
 
    .DESCRIPTION
    This function is intended to make the "profile script" variables invisible to the user/debuggers.
    These variables drive some of the profile script functions and previously visibly poluted the users profile session.
    The variables are now less like to distract the end user, and because the variables are constants they will cause
    an error if the user accidentally tries to overwrite them.
 
    .PARAMETER Name
    The name of the variable that will be created.
 
    .PARAMETER Value
    The variable value.
 
    .PARAMETER PassThru
    A switch that indicates that the PSVariable should be returned to the caller.
 
    .OUTPUTS
    Void unless PassThru is selected. A PSVariable is returned when PassThru is selected.
#>

    [CmdletBinding()]
    [OutputType([Void], [PSVariable])]
    param(
        [Parameter(Mandatory)]
        [string]$Name,

        [Parameter(Mandatory)]
        [object]$Value,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Local', 'Script')]
        [string]$Scope = 'Script',

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    $preExistingVar = Get-Variable -Name $Name -Scope $Scope -ErrorAction Ignore
    if ($null -ne $preExistingVar){
        if ($value -eq $preExistingVar.Value){
            return # exit early and don't notify if we're just trying to reset the same value.
        }
    }

    [hashtable]$newVariableSplat = @{
        Name       = $Name
        Value      = $Value
        Visibility = 'Private'
        Option     = 'Constant'
        Scope      = $Scope
        PassThru   = $PassThru
    }
    return (New-Variable @newVariableSplat)
}


function Set-ProfileLinks {
<#
    .SYNOPSIS
    The goal for this function is to make all editions of your powershell profile link back to the same file under
    the $ShellPath.
    This means that when you make an edit to the $ShellProfile script it effects all your powershell sessions/editions.
 
    .PARAMETER ShellPath
    The path to the .shell directory.
 
    .PARAMETER PSProfileEdition
    The currently running PowerShell editionstring.
 
    .PARAMETER CommandPath
    The path of the currently executing script.
#>

    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(Mandatory)]
        [string]$ShellPath,

        [Parameter(Mandatory)]
        [string]$PSProfileEdition,

        [Parameter(Mandatory)]
        [string]$CommandPath
    )

    # The path to the shell profile script
    $ShellProfile = Join-Path -Path $ShellPath -ChildPath 'profile.ps1'
    # The name of the environment variable storing the status of profile linking
    $ProfileLink = $PSProfileEdition + '_UpdateProfileLinks'

    # Check if the ProfileLink environment variable is empty
    if ([string]::IsNullOrEmpty((Get-Item -Path "Env:\$ProfileLink" -ErrorAction Ignore).Value)) {

        # If the operating system is Windows and the executing script is not linked to the shell profile script
        if (
            ($env:OS -eq 'Windows_NT') -and (
                ($null -eq (Get-Item -Path $CommandPath).Target) -or
                ((Get-Item -Path $ShellProfile -ErrorAction Ignore).Target -notcontains $CommandPath)
            )
        ) {
            # Parameters for creating a new hard link
            $NewHardLinkParams = [hashtable]@{
                ItemType = 'HardLink'
                Force    = $true
                Verbose  = $true
            }

            # If the current executing script is not the same as the shell profile script
            if ($CommandPath -ne $ShellProfile) {
                Write-Warning (
                    "Currently executing profile script '$CommandPath' " +
                    "has not been linked to the ShellProfile '$ShellProfile'."
                )
                # Prompt the user to link the current executing script to the shell profile script
                if ((Read-Host "Link '$CommandPath' to '$ShellProfile'? (Y/N)") -ieq 'Y') {
                    New-Item -Path $ShellProfile -Value $CommandPath @NewHardLinkParams | Out-Null
                }
            }

            if <# the $Profile.CurrentUserAllHosts is not linked to the shell profile script #> (
                ($null -eq (Get-Item -Path $Profile.CurrentUserAllHosts -ErrorAction Ignore).Target) -and
                ((Resolve-Path -Path $CommandPath) -eq (Resolve-Path -Path $ShellProfile))
            ) {
                Write-Warning (
                    "Profile script '$($Profile.CurrentUserAllHosts)' " +
                    "has not been linked to the ShellProfile '$ShellProfile'."
                )
                # Prompt the user to link the profile for all hosts of the current user to the shell profile script
                if ((Read-Host "Link '$($Profile.CurrentUserAllHosts)' to '$ShellProfile'? (Y/N)") -ieq 'Y') {
                    New-Item -Path $Profile.CurrentUserAllHosts -Value $ShellProfile @NewHardLinkParams | Out-Null
                }
            }
        }

        # If the $Profile.CurrentUserAllHosts script doesn't exist then link it to the shell profile.
        if ($null -eq (Get-Item -Path $Profile.CurrentUserAllHosts -ErrorAction Ignore)) {
            Write-Warning "PowerShell Profile '$($Profile.CurrentUserAllHosts)' does not exist."
            $LinkProfilePrompt = (. {
                "$(if ($env:OS -eq 'Windows_NT') { 'Hard' } else { 'Symbolic' })" +
                "Link '$($Profile.CurrentUserAllHosts)' to '$ShellProfile'? (Y/N)"
            })

            # Prompt the user to link the profile for all hosts of the current user to the shell profile script
            if ((Read-Host -Prompt $LinkProfilePrompt) -ieq 'Y') {
                $LinkProfileProcess = [hashtable]@{
                    FilePath     = 'pwsh'
                    Wait         = $true
                    NoNewWindow  = $true
                    ErrorAction  = 'Stop'
                    ArgumentList = '-NoProfile', '-ec', (
                        '"' + [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes(@"
`$newItemSplat = @{
Path = '$($Profile.CurrentUserAllHosts)'
ItemType = '$(if ($env:OS -eq 'Windows_NT') { 'HardLink' } else { 'SymbolicLink' })'
Value = '$ShellProfile'
Force = `$true
Verbose = `$true
ErrorAction = 'Stop'
}
New-Item @newItemSplat
"@
)) + '"')
                }
                Start-Process @LinkProfileProcess
            }
        }

        # Create or update the ProfileLink environment variable with the current date and time
        New-DateTimeEnvVar -VarName $ProfileLink
    }
}


function Set-WindowTitle {
<#
    .SYNOPSIS
    Sets the window title based on the current PowerShell session and provider.
 
    .DESCRIPTION
    Updates the window title with the PowerShell version, provider name, and adds an [Admin] prefix
    if the session is running with administrative privileges.
#>

    [CmdletBinding()]
    [OutputType([Void])]
    param()
    try {
        $StartText = if ($script:IsAdmin) { '[Admin] ' } else { [string]::Empty }
        $Host.Ui.RawUi.WindowTitle = "$StartText$script:PSVersion [$($pwd.Provider.Name)]"
    } catch { <# Do Not Fail #> }
}


function Update-ProfileScriptFromGist {
<#
    .SYNOPSIS
    This function updates the PowerShell profile script from the gist linked in the PROJECTURI which should be defined
    at the top of the $CommandPath script.
 
    .DESCRIPTION
    Update-ProfileScriptFromGist checks the last update time from the environment variable 'ProfileGistUpdate'.
    If it has been more than a 25 hours since the last update, the function starts a new job that updates the profile
    script. The script is then reloaded.
 
    .PARAMETER CommandPath
    The path to the PowerShell profile script. The function updates the script with the latest version from the gist.
 
    .EXAMPLE
    Update-ProfileScriptFromGist -CommandPath $PSCommandPath
 
    .NOTES
    This function requires internet access to reach the gist URL.
    It also requires the functions Update-GistScript and Get-GistScript (from TM-PSGitHubGistManagement) to be defined
    in the current session.
#>

    [CmdletBinding()]
    [OutputType([PSRemotingJob],[ThreadJob.ThreadJob],[string])]
    param (
        [Parameter(Mandatory)]
        [string]$CommandPath
    )

    $UpdateTime = if ([string]::IsNullOrWhiteSpace($env:ProfileGistUpdate) -eq $false) {
        [DateTime]::Parse($env:ProfileGistUpdate)
    } else {
        [DateTime]::Now.AddHours(-25)
    }

    return (
        if ($UpdateTime.AddDays(1) -lt [DateTime]::Now) {
            $UpdateProfileJobParams = [hashtable]@{
                Name         = 'Update Profile'
                ArgumentList = @(
                    $CommandPath,
                    "function Update-GistScript { ${function:Update-GistScript} }",
                    "function Get-GistScript { ${function:Get-GistScript} }"
                )
                ScriptBlock  = {
                    param ($ProfilePath, $UpdateScriptFunction, $GetGistFunction)
                    . ([ScriptBlock]::Create($UpdateScriptFunction))
                    . ([ScriptBlock]::Create($GetGistFunction))
                    if ([string]::IsNullOrWhiteSpace($ProfilePath) -eq $false) {
                        Update-GistScript -ScriptPath $ProfilePath | Out-Null
                    }
                }
            }
            if ($PSVersionTable.PSEdition -eq 'Desktop') {
                Start-Job @script:UpdateProfileJobParams
            } else {
                Start-ThreadJob @script:UpdateProfileJobParams
            }
            New-DateTimeEnvVar -VarName 'ProfileGistUpdate'
        } else { [string]::Empty }
    )
}


if ($IsWindows) {
    # Add Support for retrieving hardlink definitions on windows using dotnet core.
    # Recreates the "Target" property with integrated hardlink support which emulates Windows PowerShell functionality.
    # This c# code was based on https://github.com/PowerShell/PowerShell/issues/15139#issuecomment-812567971
    # This functionality is specifically used by Set-ProfileLinks - but its also just nice to have in general.
    $AddWinUtilNTFS = [hashtable]@{
        Name             = 'NTFS'
        Namespace        = 'WinUtil'
        UsingNamespace   = 'System.Text', 'System.Collections.Generic', 'System.IO'
        MemberDefinition = @'
#nullable enable
#region WinAPI P/Invoke declarations
public static readonly IntPtr INVALID_HANDLE_VALUE = (IntPtr)(-1); // 0xffffffff;
public const int MAX_PATH = 65535; // Max. NTFS path length.
 
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern IntPtr FindFirstFileNameW(
  string lpFileName,
  uint dwFlags,
  ref uint StringLength,
  StringBuilder LinkName
);
 
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool FindNextFileNameW(
  IntPtr hFindStream,
  ref uint StringLength,
  StringBuilder LinkName
);
 
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool FindClose(IntPtr hFindFile);
 
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool GetVolumePathName(
  string lpszFileName,
  [Out] StringBuilder lpszVolumePathName,
  uint cchBufferLength
);
#endregion
 
/// <summary>
/// Returns the enumeration of hardlinks for the given filepath excluding the input file itself.
/// </summary>
/// <param name="filePath"></param>
/// <returns>
/// If the file has one or more hardlink (including itself) then the enumerate hardlinks (excluding itself)
/// are returned. <br/>
///
/// This means that if the hardlink only links to itself then you will receive an empty array. <br/>
///
/// If the target volume doesn't support enumerating hardlinks, the filePath doesn't exist, or the filePath
/// is the path to a directory than a null value is returned instead.
/// </returns>
public static string[]? GetHardLinks(string filePath) {
string fullFilePath = Path.GetFullPath(filePath);
 
// If the filepath is a directory or the file does not exist then return early.
if (Directory.Exists(fullFilePath) || File.Exists(fullFilePath) == false) { return null; }
 
// Generate Volume Path
StringBuilder sbPath = new(MAX_PATH);
_ = GetVolumePathName(fullFilePath, sbPath, MAX_PATH); // Get target file volume (e.g. "C:\")
 
// Trim the trailing "\" from the volume path, to enable simple concatenation with the volume-relative
// paths returned by the FindFirstFileNameW() and FindFirstFileNameW() functions, which have a leading "\"
string volume = sbPath.ToString()[..(sbPath.Length > 0 ? sbPath.Length - 1 : 0)];
 
// Loop over and collect all hard links as their full paths.
uint charCount = MAX_PATH; // in/out character-count variable for the WinAPI calls.
IntPtr findHandle;
if (INVALID_HANDLE_VALUE != (findHandle = FindFirstFileNameW(fullFilePath, 0, ref charCount, sbPath))) {
  List<string> result = new();
  // Add each non-self path to the results list
  do {
    string fullHardlinkPath = volume + sbPath.ToString();
    if (fullHardlinkPath.Equals(fullFilePath, StringComparison.OrdinalIgnoreCase) == false) {
      result.Add(fullHardlinkPath); // Add the full path to the result list.
    }
    charCount = MAX_PATH; // Prepare for the next FindNextFileNameW() call.
  } while (FindNextFileNameW(findHandle, ref charCount, sbPath));
 
  FindClose(findHandle);
  return result.ToArray();
}
 
return null;
}
#nullable disable
'@

    }
    Add-Type @AddWinUtilNTFS
    Update-TypeData -Force -TypeName System.IO.FileInfo -MemberName Target -MemberType ScriptProperty -Value {
        # Output the target, if the file at hand is a symbolic link (reparse point).
        [string[]]$local:TempTarget = [InternalSymbolicLinkLinkCodeMethods]::GetTarget($this)
        if ($local:TempTarget) {
            , [string[]]$local:TempTarget
        } else {
            [string[]]$local:TempHardlinks = [WinUtil.NTFS]::GetHardLinks($this.FullName)
            if ($null -ne $local:TempHardlinks -and $local:TempHardlinks.Length -gt 0) {
                , [string[]]$local:TempHardlinks
            }
        }
    }
}