Public/Utilities.ps1

function Test-IsElevated {
    <#
    .SYNOPSIS
    Check if current session is elevated.
    .DESCRIPTION
    Returns $true if the session is running with administrator privileges on Windows
    or as root on non-Windows platforms.
    .EXAMPLE
    Test-IsElevated
    Returns $true if the current session is elevated.
    #>

    [CmdletBinding()]
    param()

    if ($PSVersionTable.PSVersion.Major -gt 5 -and -not $IsWindows) {
        return ((id -u) -eq 0)
    }
    return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function New-GlobalConstant {
    <#
    .SYNOPSIS
    Create a global constant variable.
    .DESCRIPTION
    Creates a variable with Constant and AllScope options in the Global scope.
    .PARAMETER Name
    The name of the variable to create.
    .PARAMETER Value
    The value to assign to the variable.
    .EXAMPLE
    New-GlobalConstant 'MyConst' 42
    Creates a global constant variable named MyConst with value 42.
    #>

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

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        $Value
    )
    New-Variable -Name $Name -Value $Value -Option Constant,AllScope -Scope Global
}

function New-PathVariable {
    <#
    .SYNOPSIS
    Create a global constant variable if the given path exists.
    .DESCRIPTION
    Validates that the path exists before creating the variable via New-GlobalConstant.
    .PARAMETER Name
    The name of the variable to create.
    .PARAMETER Path
    The filesystem path to validate and assign. Variable is only created if the path exists.
    .EXAMPLE
    New-PathVariable 'RepoRoot' 'D:\source\repos'
    Creates a global constant RepoRoot if the path exists.
    #>

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

        [string]$Path
    )
    if ($null -ne $Path -and (Test-Path -Path $Path) -eq $true) {
        New-GlobalConstant $Name $Path
    }
}

function Get-SessionTitle {
    <#
    .SYNOPSIS
    Build a descriptive title for the current PowerShell session.
    .DESCRIPTION
    Returns a string like 'PowerShell 7.4.0 (X64)' with prefixes for Windows PowerShell and elevated sessions.
    .EXAMPLE
    Get-SessionTitle
    Returns 'Elevated: PowerShell 7.4.0 (X64)' when running as administrator.
    #>

    [CmdletBinding()]
    param()
    $title = "PowerShell $($PSVersionTable.PSVersion) ($([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture))"
    if ($PSVersionTable.PSVersion.Major -le 5) {
        $title = "Windows $title"
    }
    if (Test-IsElevated) {
        $profileIsElevated = $global:WTProfile -and $global:WTProfile.name -match 'Elevated'
        if (-not $profileIsElevated) {
            $title = "🛡️ $title"
        }
    }
    return $title
}

function Reset-TerminalModes {
    <#
    .SYNOPSIS
    Reset the terminal's DEC private modes and kitty keyboard flags after a crashed TUI.
    .DESCRIPTION
    A TUI program (e.g. the Copilot engine) that crashes can leave the terminal
    with mouse tracking, focus reporting, the alternate screen buffer, bracketed paste,
    synchronized output, and the kitty keyboard protocol still enabled — making the
    shell unusable. This emits a DECRST sequence that disables all of them so the shell
    recovers.
 
    The modes reset (via `CSI ? Pm l` — the '?' is REQUIRED; the ANSI-RM form `CSI Pm l`
    without it is a silent no-op for these DEC private modes): 2026 synchronized output,
    2004 bracketed paste, 1049 alternate screen, 1006/1003/1000 SGR/any-event/normal
    mouse, and 1004 focus reporting. It then force-resets the kitty keyboard flags with
    `CSI = 0 u` (more robust for crash recovery than `CSI < u`, which only pops one entry
    off the kitty stack).
 
    All modes are already off in a healthy terminal, so this is idempotent and harmless
    to call repeatedly (e.g. from the prompt). No-ops when stdout is redirected so the
    escape bytes can't corrupt captured output.
    .EXAMPLE
    Reset-TerminalModes
    Restores normal terminal input/output after an engine crash.
    #>

    [CmdletBinding()]
    param()
    if ([Console]::IsOutputRedirected) { return }
    [Console]::Write("`e[?2026;2004;1049;1006;1004;1003;1000l`e[=0u")
}

function Import-ModuleSafe {
    <#
    .SYNOPSIS
    Import a PowerShell module if its path exists.
    .DESCRIPTION
    Checks whether the specified path exists before attempting to import the module.
    Silently does nothing if the path is not found.
    .PARAMETER Path
    The path to the module file or directory. The module is only imported if the path exists.
    .EXAMPLE
    Import-ModuleSafe 'D:\PowerShell\Modules\MyModule\MyModule.psd1'
    Imports the module if the file exists.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Path
    )
    if ((Test-Path -Path $Path) -eq $true) {
        Import-Module $Path
    }
}

function Invoke-InLocation {
    <#
    .SYNOPSIS
    Invoke a script with a given current location
    .DESCRIPTION
    The script is run with the chosen location and will return to the current location after, even if the script is interrupted.
    .PARAMETER Location
    The location to change to before running the script.
    .PARAMETER ScriptBlock
    The script to run in the location
    .EXAMPLE
    Invoke-InLocation -Location $HOME -ScriptBlock { ls }
    #>

    [CmdletBinding()]
    param(
        [Alias('Path')]
        [ValidateScript({ Test-Path $_ })]
        [string]$Location,
        [Alias('Process')]
        [scriptblock]$ScriptBlock
    )
    begin {
        Push-Location -Path $Location
    }
    process {
        & $ScriptBlock
    }
    clean {
        Pop-Location
    }
}

function Repair-GlobalJson {
    <#
    .SYNOPSIS
    Reset global.json SDK rollForward policy to 'disable'.
    .DESCRIPTION
    Reads the global.json in the current directory, sets sdk.rollForward to 'disable', and writes it back.
    .EXAMPLE
    Repair-GlobalJson
    Resets the rollForward policy in the current directory's global.json.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param()
    if ($PSCmdlet.ShouldProcess('global.json', "Set sdk.rollForward to 'disable'")) {
        $a = Get-Content .\global.json | ConvertFrom-Json
        $a.sdk.rollForward = 'disable'
        $a | ConvertTo-Json | Set-Content .\global.json
    }
}

function Get-ServiceProcess {
    <#
    .SYNOPSIS
    Get the hosting process for one or more Windows services.
    .DESCRIPTION
    Resolves each service name (wildcards supported) to its hosting process via Win32_Service.
    When a single running service is matched, the underlying System.Diagnostics.Process is
    returned so the cmdlet composes naturally with Stop-Process, Wait-Process, etc.
    Otherwise, one ServiceProcess object is emitted per service with metadata including
    the svchost command line for shared-host services.
    .PARAMETER Name
    One or more service names. Supports wildcards. Accepts pipeline input by value (string)
    or by property name (e.g. Get-Service | Get-ServiceProcess).
    .PARAMETER PerService
    Reconfigure each shared-svchost service to run in its own process via
    'sc.exe config <name> type= own'. Requires elevation. The change takes effect on next
    service restart.
    .EXAMPLE
    Get-ServiceProcess Spooler
    Returns the System.Diagnostics.Process hosting the Print Spooler.
    .EXAMPLE
    Get-ServiceProcess Spooler | Stop-Process
    Stops the process hosting the Spooler service.
    .EXAMPLE
    Get-Service W3* | Get-ServiceProcess
    Returns one ServiceProcess object per matching service.
    .EXAMPLE
    Get-ServiceProcess BITS -PerService -WhatIf
    Previews reconfiguring BITS to run in its own process.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([System.Diagnostics.Process], [pscustomobject])]
    param(
        [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Name,

        [switch]$PerService
    )
    begin {
        if ($PerService -and -not $WhatIfPreference -and -not (Test-IsElevated)) {
            throw '-PerService requires an elevated session.'
        }
        $results = [System.Collections.Generic.List[pscustomobject]]::new()
        # Bulk-fetch all Win32_Service CIM data once, index by name
        $cimIndex = @{}
        foreach ($c in (Get-CimInstance Win32_Service)) {
            $cimIndex[$c.Name] = $c
        }
    }
    process {
        foreach ($pattern in $Name) {
            $services = Get-Service -Name $pattern -ErrorAction SilentlyContinue
            if (-not $services) {
                Write-Error "No service matched '$pattern'."
                continue
            }
            foreach ($svc in $services) {
                if ($PerService) {
                    if ($PSCmdlet.ShouldProcess($svc.Name, "sc.exe config <name> type= own")) {
                        & sc.exe config $svc.Name type= own | Out-Null
                        if ($LASTEXITCODE -ne 0) {
                            Write-Error "sc.exe config $($svc.Name) type= own failed with exit code $LASTEXITCODE."
                            continue
                        }
                        Write-Warning "$($svc.Name): reconfigured to type=own. Restart the service for the change to take effect."
                    }
                }
                $cim = $cimIndex[$svc.Name]
                $procId = if ($cim) { [int]$cim.ProcessId } else { 0 }
                $proc = if ($procId -gt 0) { Get-Process -Id $procId -ErrorAction SilentlyContinue } else { $null }
                $results.Add([pscustomobject]@{
                    PSTypeName  = 'ServiceProcess'
                    Name        = $svc.Name
                    DisplayName = $svc.DisplayName
                    Status      = $svc.Status
                    ProcessId   = $procId
                    ProcessName = if ($proc) { $proc.ProcessName } else { '' }
                    Process     = $proc
                    CommandLine = if ($cim) { $cim.PathName } else { '' }
                })
            }
        }
    }
    end {
        if ($results.Count -eq 1 -and $null -ne $results[0].Process) {
            $results[0].Process
        } else {
            $results
        }
    }
}