Last active
January 9, 2024 13:40
-
-
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:") ...
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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