Skip to content

Instantly share code, notes, and snippets.

@Jaykul
Last active January 9, 2024 13:40
Show Gist options
  • Save Jaykul/6554721f2cedfc59ba55cea4695bbc6b to your computer and use it in GitHub Desktop.
Save Jaykul/6554721f2cedfc59ba55cea4695bbc6b to your computer and use it in GitHub Desktop.
Calling native executables that write non-error to stderr (with prefixes like "Warning:" and "Error:") ...
class EncodingAttribute : System.Management.Automation.ArgumentTransformationAttribute {
[object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
if ( $inputData -is [string] ) {
return [Text.Encoding]::GetEncoding($inputData)
} else {
return $inputData
}
}
}
class NativeErrorMessageException : System.Exception {
[string]$Path
[int]$ExitCode
NativeErrorMessageException([string]$message) : base($Message) {
$this.Path = ("$(Get-Variable Command -ValueOnly)" -replace "^[&\s]*((?<q>[""']).+?\k'q'|[^ ]+).*", '$1').Trim("`"'")
$FullPath = (Get-Command $this.Path -ErrorAction Ignore).Path
if ($FullPath) {
$this.Path = $FullPath
}
$this.ExitCode = $LastExitCode
}
}
class NativeExitCodeException : System.Exception {
[string]$Path
[int]$ExitCode
[int]$ProcessId
NativeExitCodeException(){
$this.Path = ("$(Get-Variable Command -ValueOnly)" -replace "^[&\s]*((?<q>[""']).+?\k'q'|[^ ]+).*", '$1').Trim("`"'")
$FullPath = (Get-Command $this.Path -ErrorAction Ignore).Path
if ($FullPath) {
$this.Path = $FullPath
}
$this.ExitCode = $LastExitCode
}
NativeExitCodeException([string]$message) : base($Message) {
$this.Path = ("$(Get-Variable Command -ValueOnly)" -replace "^[&\s]*((?<q>[""']).+?\k'q'|[^ ]+).*", '$1').Trim("`"'")
$FullPath = (Get-Command $this.Path -ErrorAction Ignore).Path
if ($FullPath) {
$this.Path = $FullPath
}
$this.ExitCode = $LastExitCode
}
NativeExitCodeException([string]$message, [string]$path) : base($Message) {
$this.Path = $path
$FullPath = (Get-Command $this.Path -ErrorAction Ignore).Path
if ($FullPath) {
$this.Path = $FullPath
}
$this.ExitCode = $LastExitCode
}
NativeExitCodeException([string]$message, [string]$path, [int]$exitCode, [int]$processId) : base($Message) {
$this.Path = $path
$FullPath = (Get-Command $this.Path -ErrorAction Ignore).Path
if ($FullPath) {
$this.Path = $FullPath
}
$this.ExitCode = $exitCode
$this.ProcessId = $processId
}
}
function Invoke-Native {
<#
.SYNOPSIS
Wraps native commands that output different encodings, and handles stderr streams that have verbose and warning messages.
.DESCRIPTION
Invoke-Native wraps the invocation of a native command to explicitly change the Console OutputEncoding so the output can be parsed correctly.
Invoke-Native also supports handling the stderr stream when it is used for verbose and warning messages, and you don't want those messages to be treated as errors.
Most apps write UTF8 to the terminal, but wsl.exe on Windows writes all of it's output in UTF16 ("Unicode"). In this case, the only way to successfully process the output as strings is to set your console's OutputEncoding before running the command.
Additionally, many apps (like git) write verbose output to stderr, which PowerShell can treat as an error.
.EXAMPLE
$dotgit = Invoke-Native { git rev-parse --git-dir } -ExceptionalExit
Find the gitdir of the current repository
.EXAMPLE
$distros = Invoke-Native { wsl --list } -Encoding Unicode
Get a list of installed wsl distributions
#>
[Alias("IN", "inc")]
[CmdletBinding()]
param(
# The scriptblock/command to run
[Parameter(Mandatory, Position = 0)]
[ScriptBlock]$Command,
# If set, throws an exception if the command returns a non-zero exit code
# Defaults to the value of $PSNativeCommandErrorActionPreference because we suppress those errors
[switch]$ExceptionalExit = $PSNativeCommandErrorActionPreference,
# The encoding to use for the output (defaults to UTF8)
[ArgumentCompleter({
[OutputType([System.Management.Automation.CompletionResult])]
param(
[string] $CommandName,
[string] $ParameterName,
[string] $WordToComplete,
[System.Management.Automation.Language.CommandAst] $CommandAst,
[System.Collections.IDictionary] $FakeBoundParameters
)
$CompletionResults = [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new()
[Text.Encoding]::GetEncodings().ForEach{
$CompletionResults.Add(
[System.Management.Automation.CompletionResult]::new($_.Name)
)
}
return $CompletionResults
})]
[EncodingAttribute()]
[Text.Encoding]$Encoding = [Text.Encoding]::UTF8,
# This is a regular expression that splits each line of output written to stderr into a stream and a message.
# * The default value is suitable for git and kubectl: "[\s#]*(?<stream>fatal|error|warning)?:\s*(?<message>.*)$"
[regex]$StreamPattern = "^[\s#]*(?<stream>fatal|error|warning|verbose|):\s*(?<message>.*)$",
# A lookup table to convert streams matched by the StreamPattern to valid stream names.
# If the stream name matches a PowerShell stream name, it will be written to that stream.
# Otherwise, we look for the stream mapping in this hashtable
# If nothing matches, the DefaultStream is used
# * The default value maps "fatal" to 'Error'
[hashtable]$StreamMapping = @{ "fatal" = 'Error' },
[ValidateSet('Information', 'Verbose', 'Debug', 'Warning', 'Error')]
[string]$DefaultStream = 'Information'
)
begin {
[ValidateSet('Information', 'Verbose', 'Debug', 'Warning', 'Error', '')]
$script:CurrentStream = ''
$script:Message = @()
$script:EmptyRecord = [System.Management.Automation.ErrorRecord]::new([Exception]::new(""), "NotAnError", "FromStdErr", $null)
}
end {
# We need to capture and put back the original OutputEncoding
$currentEncoding = [Console]::OutputEncoding
try {
try {
[Console]::OutputEncoding = $Encoding
} catch [System.IO.IOException] {
# PSAvoidUsingEmptyCatchBlock
Write-Verbose "Unable to set OutputEncoding. $_"
}
# Prevent the generation of errors from stderr output in this scope
$ErrorActionPreference, $oldErrorAction = 'Continue', $ErrorActionPreference
& $Command 2>&1 | & {
<# This command is neste#>
process {
if ($_ -isnot [System.Management.Automation.ErrorRecord]) {
Write-Debug "$([char]27)[38;2;255;0;0m$([char]27)[48;2;255;255;255m $($_.GetType().FullName) $([char]27)[38;2;255;255;255m$([char]27)[49m $_"
$_
} else {
if ($null = $_.Exception.Message -match $StreamPattern) {
$Script:Message += $Matches["message"]
$stream = switch ($Matches["stream"]) {
"error" { "Error" }
"warning" { "Warning" }
"verbose" { "Verbose" }
"information" { "Information" }
"debug" { "Debug" }
default {
if ($_ -and $StreamMapping.ContainsKey($_)) {
$StreamMapping[$_]
} else {
$DefaultStream
}
}
}
} else {
$Script:Message += $_.Exception.Message
}
Write-Debug "$([char]27)[38;2;255;0;0m$([char]27)[48;2;255;255;255m $local:stream line $([char]27)[38;2;255;255;255m$([char]27)[49m $($Message[-1])"
# If the stream has changed, then write the output
if ($CurrentStream -and $CurrentStream -ne $local:stream) {
if ($Message) {
# Error is a special case so we can control the invocation in the view
if ($CurrentStream -eq "Error") {
$PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new([NativeErrorMessageException]::new($Message -Join "`n"), $Command, "FromStdErr", $Command))
} else {
. "Write-$CurrentStream" ($Message -Join "`n") -ErrorAction Continue -Verbose -InformationAction Continue
}
}
$script:Message = @()
}
$script:CurrentStream = $local:stream
}
}
end {
if ($CurrentStream -and $CurrentStream -ne "Error") {
. "Write-$CurrentStream" ($Message -Join "`n") -ErrorAction Continue -Verbose -InformationAction Continue
$Message = @("NonZero LastExitCode")
}
if ($LASTEXITCODE -and $ExceptionalExit) {
# Terminating errors are throw ...
# But we need to hack the ErrorRecord to look like it came from Invoke-Native
# Currently using $PSCmdlet.ThrowTerminatingError produces double errors
$ErrorRecordType = [System.Management.Automation.ErrorRecord]
$ErrorRecord = $ErrorRecordType::new([NativeExitCodeException]::new($Message -Join "`n"), $Command, "FromStdErr", $Command)
$ErrorRecordType.InvokeMember('SetInvocationInfo', 'Instance, NonPublic, InvokeMethod', $null, $ErrorRecord, $PSCmdlet.MyInvocation)
throw $ErrorRecord
} elseif ($CurrentStream -eq "Error") {
# Error is a special case so we can control the invocation in the view
$PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new([NativeErrorMessageException]::new($Message -Join "`n"), $Command, "FromStdErr", $Command))
}
}
}
} finally {
$ErrorActionPreference = $oldErrorAction
try {
[Console]::OutputEncoding = $currentEncoding
} catch [System.IO.IOException] {
# PSAvoidUsingEmptyCatchBlock
Write-Verbose "Unable to set OutputEncoding`n$_"
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment