Public/Common/Invoke-NativeCommand.ps1
function Invoke-NativeCommand { [CmdletBinding()] param ( # The path to the command to run [Parameter( Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 0 )] [string] [Alias('PSPath')] $FilePath, # An optional list of arguments to pass to the command [Parameter( Mandatory = $false, ValueFromPipelineByPropertyName = $true, Position = 1 )] [Alias('Arguments')] [array] $ArgumentList, # The working directory to run the command in [Parameter( Mandatory = $false, Position = 2 )] [ValidateNotNullOrEmpty()] [string] $WorkingDirectory, # The allowed exit codes for the command [Parameter( Mandatory = $false, Position = 3 )] [array] $ExitCodes = @(0), # If passed, will return the output of the command as a PowerShell object [Parameter()] [switch] $PassThru, # If set output will be suppressed [Parameter()] [switch] $SuppressOutput, # The path to where the redirected output should be stored # Defaults to the contents of the environment variable 'RepoLogDirectory' if available # If that isn't set then defaults to a temp directory [Parameter( Mandatory = $false )] [ValidateNotNullOrEmpty()] [string] $RedirectOutputPath, # The prefix to use on the redirected streams, defaults to the command run time [Parameter( Mandatory = $false )] [ValidateNotNullOrEmpty()] [string] $RedirectOutputPrefix, # The suffix for the redirected streams (defaults to log) [Parameter( Mandatory = $false )] [ValidateNotNullOrEmpty()] [string] $RedirectOutputSuffix = 'log' ) begin { if (('RedirectOutputPath' -in $PSBoundParameters.Keys) -or ('RedirectOutputPrefix' -in $PSBoundParameters.Keys) -or ('RedirectOutputSuffix' -in $PSBoundParameters.Keys) -and !$SuppressOutput) { throw 'Cannot redirect output if SuppressOutput is not set' } } process { # Start off by ensuring we can find the command and then get it's full path. # This is useful when using things like Set-Alias as the Start-Process command won't have access to these # as aliases are not passed through to the child process so instead we can use the full path to the alias Write-Verbose "Finding absolute path to command $FilePath" try { $AbsoluteCommandPath = (Get-Command $FilePath -ErrorAction Stop).Definition } catch { throw "Could not find command $FilePath.`n$($_.Exception.Message)" } # Note: the arguments may leak sensitive information so be wary of exposing them Write-Debug "Calling '$AbsoluteCommandPath' with arguments: '$($ArgumentList -join ' ')'" Write-Debug "Valid exit codes: $($ExitCodes -join ', ')" # When we want to suppress output we need to redirect the output to a file if ($SuppressOutput) { # Set redirected output to the repos log directory if it exists, otherwise to temp if (!$RedirectOutputPath) { if ($global:RepoLogDirectory) { $RedirectOutputPath = $global:RepoLogDirectory } else { # Determine our temp directory depending on flavour of PowerShell if ($PSVersionTable.PSEdition -eq 'Desktop') { $RedirectOutputPath = $env:TEMP } else { $RedirectOutputPath = (Get-PSDrive Temp).Root } } } # Check the redirect stream path is valid try { $RedirectOutputPathCheck = Get-Item $RedirectOutputPath -Force } catch { throw "$RedirectOutputPath does not appear to be a valid directory." } if (!$RedirectOutputPathCheck.PSIsContainer) { throw "$RedirectOutputPath must be a directory" } Write-Verbose "Redirecting output to: $RedirectOutputPath" # If we don't have a redirect output prefix then create one if (-not $RedirectOutputPrefix) { # See if the value in $FilePath is a path or just a command name. # If it's a path we don't want to use that as a prefix for our redirected output files as it could be stupidly long # If it's a command name then we can just straight up use that as our redirect name try { $isPath = Resolve-Path $FilePath -ErrorAction Stop } catch { $RedirectOutputPrefix = $FilePath } # We've got a path, do some work to extract just the name of the program from the file path if ($isPath) { try { $RedirectOutputPrefix = $isPath | Get-Item | Select-Object -ExpandProperty Name -ErrorAction Stop } catch { # Don't throw, we'll still get a valid filename below anyways it'll just be missing a prefix Write-Warning 'Failed to auto-generate RedirectOutputPrefix' } } } # Define our redirected stream names $StdOutFileName = "$($RedirectOutputPrefix)_$(Get-Date -Format yyMMddhhmm)_stdout.$($RedirectOutputSuffix)" $StdErrFileName = "$($RedirectOutputPrefix)_$(Get-Date -Format yyMMddhhmm)_stderr.$($RedirectOutputSuffix)" # Set the paths $StdOutFilePath = Join-Path $RedirectOutputPath -ChildPath $StdOutFileName $StdErrFilePath = Join-Path $RedirectOutputPath -ChildPath $StdErrFileName # Set the default calling params $ProcessParams = @{ FilePath = $AbsoluteCommandPath RedirectStandardError = $StdErrFilePath RedirectStandardOutput = $StdOutFilePath PassThru = $true NoNewWindow = $true Wait = $true } # Add optional params if we have them if ($ArgumentList) { $ProcessParams.Add('ArgumentList', $ArgumentList) } if ($WorkingDirectory) { $ProcessParams.Add('WorkingDirectory', $WorkingDirectory) } # Run the process try { $Process = Start-Process @ProcessParams } catch { # If we get a failure at this stage we won't have any stderr to grab so just return our exception throw $_.Exception.Message } # Check the exit code is expected, if not grab the contents of stderr (if we can) and return it if ($Process.ExitCode -notin $ExitCodes) { $ErrorContent = Get-Content $StdErrFilePath -Raw -ErrorAction SilentlyContinue # Write-Error is preferable to 'throw' as it gives much cleaner output, it also allows more control over how errors are handled Write-Error "$FilePath has returned a non-zero exit code: $($Process.ExitCode).`n$ErrorContent" } # If we've requested the output from this command then return it along with the paths to our StdOut and StdErr files should we need them try { $OutputContent = Get-Content $StdOutFilePath } catch { Write-Error "Unable to get contents of $StdOutFilePath.`n$($_.Exception.Message)" } } else { # Open an array to store potential error messages (more on this later) $ErrorStream = @() $OutputContent = @() if ($WorkingDirectory) { try { Push-Location Set-Location $WorkingDirectory } catch { throw "Failed to set working directory to '$WorkingDirectory'.`n$($_.Exception.Message)" } } # When we're not suppressing output then we want to stream output to both stdout/stderr and capture in a variable & { & $AbsoluteCommandPath $ArgumentList } 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { # Some commands will return info/verbose messages to stderr, we don't want to terminate on these so we store the information # so we can use it later on if we need to. $ErrorStream += $_ # Try to write it out as verbose output Write-Verbose $_ -ErrorAction 'SilentlyContinue' } else { $OutputContent += $_ Write-Host $_ -ErrorAction 'SilentlyContinue' } } | Tee-Object -Variable 'OutputContent' # Tee the output to a variable if ($WorkingDirectory) { Pop-Location } if ($LASTEXITCODE -notin $ExitCodes) { Write-Error "Command $FilePath exited with code $LASTEXITCODE.`n$ErrorStream" } } } end { if ($PassThru) { $Return = @{} if ($OutputContent) { $Return.Add('OutputContent', $OutputContent) } if ($StdOutFilePath) { $Return.Add('StdOutFilePath', $StdOutFilePath) } if ($StdErrFilePath) { $Return.Add('StdErrFilePath', $StdErrFilePath) } if ($Return.GetEnumerator().Count -gt 0) { Return $Return } else { Return $null } } } } |