Skip to content

Instantly share code, notes, and snippets.

@LindnerBrewery
Last active November 14, 2022 09:19
Show Gist options
  • Save LindnerBrewery/def296407e4cc5720b22ff4a614f6d42 to your computer and use it in GitHub Desktop.
Save LindnerBrewery/def296407e4cc5720b22ff4a614f6d42 to your computer and use it in GitHub Desktop.
Start-ProcessPlus
function Start-ProcessPlus {
<#
.SYNOPSIS
Starts an external process
.DESCRIPTION
Starts an external process
.EXAMPLE
PS C:\> Start-ProcessPlus -FilePath ping.exe -ArgumentList "127.0.0.1 -n 60"
Process will be executed without any feedback until it is finished
.EXAMPLE
PS C:\> Start-ProcessPlus -FilePath ping.exe -ArgumentList "127.0.0.1 -n 60" -ConsoleOut
Process will be executed and print out the stdout and error out directly to the console and the output object
.EXAMPLE
PS C:\> Start-ProcessPlus -FilePath ping.exe -ArgumentList "127.0.0.1 -n 60" -ConsoleOut -Timeout 60
The process will exit with an error if it doesn't finish within 60 sec
.EXAMPLE
PS C:\> Start-ProcessPlus -FilePath ping.exe -ArgumentList "127.0.0.1 -n 60" -FileOut
The sta and error output of the process will be saved to files. If files are not specified with the parameters -OutputFile and -ErrorFile tmp files will automatically be created
.INPUTS
Inputs (if any)
.OUTPUTS
Output (if any)
.NOTES
General notes
#>
[CmdletBinding(DefaultParameterSetName = 'default',
SupportsShouldProcess = $true,
PositionalBinding = $false,
HelpUri = 'http://www.microsoft.com/',
ConfirmImpact = 'Medium')]
[Alias()]
[OutputType([PsCustomObject])]
Param (
# Specifies the optional path and filename of the program that runs in the process
[Parameter(Mandatory = $true,
Position = 0,
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
ValueFromRemainingArguments = $false)]
[ValidateNotNullOrEmpty()]
[String]$FilePath,
# Specifies parameters or parameter values to use when this cmdlet starts the process. Arguments can be accepted as a single string with the arguments separated by spaces, or as an array of strings separated by commas.
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[String[]]$ArgumentList,
# StdOut and ErrorOut will be printed to the console
[Parameter(Mandatory = $false)]
[Switch]$ConsoleOut,
# Output will be saved as a file. If you don't provide an optional file(s) a temp will be created
[Parameter(Mandatory = $true,
ParameterSetName = 'Fileout')]
[Switch]$FileOut,
# Optional output file. If not defined a tmp file will be created to save the stdOut
[Parameter(Mandatory = $false,
ParameterSetName = 'Fileout')]
[String]$OutputFile,
# Optional output file. If not defined a tmp file will be created to save the errOut
[Parameter(Mandatory = $false,
ParameterSetName = 'Fileout')]
[String]$ErrorFile,
# Timeout in seconds before process execution will be aborted
[Parameter(Mandatory = $false)]
[int]$Timeout,
# Working dir for the process
[Parameter(Mandatory = $false)]
[String]$workingDirectory,
[Parameter(Mandatory = $false)]
[pscredential]$Credential,
[Parameter(Mandatory = $false)]
[Switch]$WithProfile
)
begin {}
Process {
$stdOutput = $null
$errorOut = $null
#region setup process
$Arguments = $ArgumentList -join " "
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = $FilePath
$pinfo.RedirectStandardError = $true
$pinfo.RedirectStandardOutput = $true
$pinfo.UseShellExecute = $false
$pinfo.Arguments = $Arguments
if ($Credential) {
$uname = $Credential.UserName -split '\\'
if ($uname[1]) {
$pinfo.Domain = $uname[0] # AD domain
$pinfo.UserName = $uname[1]
}else{
$pinfo.UserName = $uname[0]
}
$pinfo.Password = $Credential.Password
if ($Withprofile.IsPresent) {
$pinfo.LoadUserProfile = $true
}
}
# I was to lazy to set parametersetname.
if ($Withprofile.IsPresent -and -not $credential) {
Write-Host "-WhithProfile only can be used in combination with -Credential" -ForegroundColor Yellow
}
if(-Not [string]::IsNullOrEmpty($workingDirectory)) {
$pinfo.WorkingDirectory = $workingDirectory
}
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $pinfo
#endregion setup process
#region Events
# all registered events will be stored in this variable and unregistered at the end
$events = [System.Collections.ArrayList]::new()
# setup optional console output
if ($ConsoleOut.IsPresent) {
$stdOutConsole = { Write-Host $Event.SourceEventArgs.Data }
$errOutConsole = { Write-Host $Event.SourceEventArgs.Data -ForegroundColor Red }
$events += Register-ObjectEvent -InputObject $p -EventName OutputDataReceived -Action $stdOutConsole
$events += Register-ObjectEvent -InputObject $p -EventName ErrorDataReceived -Action $errOutConsole
}
if ($pscmdlet.ParameterSetName -eq 'Fileout') {
# was out and err files defined?
if ($OutputFile) {
$OutputFile = New-Item $OutputFile -ItemType File -ErrorAction Stop
}
else {
$OutputFile = New-TemporaryFile
}
if ($ErrorFile) {
$ErrorFile = New-Item $ErrorFile -ItemType File -ErrorAction Stop
}
else {
$ErrorFile = New-TemporaryFile
}
# create Streambuilder and actions for the Events
$outFileSW = [System.IO.StreamWriter]::new($OutputFile)
$outFileSW.AutoFlush = $true
$ErrorFileSW = [System.IO.StreamWriter]::new($ErrorFile)
$ErrorFileSW.AutoFlush = $true
$FileAction = { if (-not [String]::IsNullOrEmpty($EventArgs.Data)) {
$Event.MessageData.WriteLine($Event.SourceEventArgs.Data)
} }
$events += Register-ObjectEvent -InputObject $p -EventName OutputDataReceived -Action $FileAction -MessageData $outFileSW
$events += Register-ObjectEvent -InputObject $p -EventName ErrorDataReceived -Action $FileAction -MessageData $ErrorFileSW
$stdOutput = $OutputFile
$errorOut = $ErrorFile
}
else {
$stdSb = [System.Text.StringBuilder]::new()
$errorSb = [System.Text.StringBuilder]::new()
$OutAction = { if (-not [String]::IsNullOrEmpty($EventArgs.Data)) {
$Event.MessageData.AppendLine($Event.SourceEventArgs.Data)
} }
$events += Register-ObjectEvent -InputObject $p -EventName OutputDataReceived -Action $OutAction -MessageData $stdSb
$events += Register-ObjectEvent -InputObject $p -EventName ErrorDataReceived -Action $OutAction -MessageData $errorSb
$stdOutput = $stdSb
$errorOut = $errorSb
}
#$exitAction =
#{
# $myEventVar = $true
#}
#$events += Register-ObjectEvent -InputObject $p -EventName Exited -Action $exitAction -SourceIdentifier exitedEvent
$exitAction = {$myEventVar = $true}
$exitedEvent = Register-ObjectEvent -InputObject $p -EventName Exited -Action $exitAction
$events += $exitedEvent
#endregion Events
# start a stopwatch, even id not needed
$sw = [system.Diagnostics.stopwatch]::StartNew()
try {
# start process
$p.Start() | Out-Null
$p.BeginOutputReadLine()
$p.BeginErrorReadLine()
# This will simulate $p.waitforexit([int]timeout) but will not block the console output
while ((& $exitedEvent.Module { $myEventVar }) -ne $true) {
if ($Timeout) {
if ($sw.Elapsed.TotalSeconds -ge $Timeout) {
Write-Verbose "Timeout reached. Will Kill the process"
$p.Kill()
$p.Dispose()
Write-Verbose "Process killed"
Throw "Process did not end within $timeout seconds"
}
}
Start-Sleep 1
}
# output is ugly. consider implementing Format.ps1xml
[PSCustomObject]@{
ExitCode = $p.ExitCode
Output = $stdOutput.ToString()
Error = $errorOut.ToString()
}
}
catch {
Throw $_
}
finally {
$events | ForEach-Object { Unregister-Event -SourceIdentifier $_.name }
if ($ErrorFileSW) {
$ErrorFileSW.close()
}
if ($outFileSW) {
$outFileSW.close()
}
}
}
end {}
}
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Scope='Function')]
Param()
BeforeAll {
. $PSCommandPath.Replace('.Tests.ps1','.ps1')
}
Describe Start-ProcessPlus {
Context 'Basic tests' {
It "Invoking ping on localhost. This should always work" {
$result = Start-ProcessPlus -FilePath ping.exe -ArgumentList 'localhost'
$result.exitcode | Should -Be 0
}
It "Invoking non existing file. This should always Fail" {
{ Start-ProcessPlus -FilePath asdfasfdf.exe } | Should -Throw
}
It "Invoking non ping on non existent target. This should always return exitcode 1" {
$result = Start-ProcessPlus -FilePath ping.exe -ArgumentList "Host_that_doesn't exist"
$result.exitcode | Should -Be 1
}
It "Invoking non ping on non existent target. This should always return exitcode 1" {
{Start-ProcessPlus -FilePath ping -ArgumentList '-t localhost' -Timeout 3} | Should -Throw 'Process did not end within 3 seconds'
}
}
Context 'Complex tests' {
BeforeAll{
$Name = "PU{0}" -f (get-date).ToFileTime()
$passWord = ($Name.ToCharArray() | Sort-Object {Get-Random}) -join '!'
$securePassWord = $passWord | ConvertTo-SecureString -AsPlainText -Force
$creds = [pscredential]::new($name,$securePassWord)
New-LocalUser -Name $Name -Password $securePassWord -ErrorAction SilentlyContinue
}
AfterAll{
Remove-LocalUser $Name -ErrorAction SilentlyContinue
}
It "Testing with other user"{
$output = Start-ProcessPlus -FilePath powershell.exe -ArgumentList '-NoProfile', '-command', '$env:username' -Credential $creds
$output.Output.Trim() | Should -Be $Name
}
It "Testing working directory" {
$output = Start-ProcessPlus -FilePath powershell.exe -ArgumentList '-NoProfile', '-command', '$pwd.path' -workingDirectory $env:TMP
$output.Output.Trim() | Should -Be ([System.IO.DirectoryInfo]::new($env:TMP).fullname)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment