Public/Invoke-PsExec.ps1

function Invoke-PsExec {
    [CmdletBinding(DefaultParameterSetName = 'Command')]
    param(
        [Parameter(ParameterSetName = 'Command',
                   Mandatory = $true,
                   Position = 0)]
        [ValidateNotNullOrEmpty()]
        [String] $Command,

        [Parameter(ParameterSetName = 'PSScriptFile',
                   Mandatory = $true)]
        [String] $ScriptFile,

        [Parameter(Mandatory = $false,
                   Position = 1,
                   ValueFromPipeline = $true,
                   ValueFromPipelineByPropertyName = $true,
                   ValueFromRemainingArguments = $true)]
        [String[]] $ComputerName = $env:COMPUTERNAME,

        # Working directory to use on the remote system
        [String] $WorkingDirectory,

        # Copy file to remote system (-c). Note that if this is specified, a full path in the Command paramer will be interpreted by PSExec as a local path (to a file that should be copied) rather than a path on the remote device.
        [Parameter(Mandatory = $false)]
        [Switch] $Copy,

        # Allow the process to interact with the logged-in user (note that this can prevent console output from being returned correctly)
        [Parameter(Mandatory = $false)]
        [Switch] $Interactive,

        # Invoke the command as the SYSTEM account
        [Parameter(Mandatory = $false)]
        [Switch] $System,

        # Load the user's profile
        [Parameter(Mandatory = $false)]
        [Switch] $LoadProfile,

        # Wait for the process to complete
        [Parameter(Mandatory = $false)]
        [Switch] $Wait,

        # Credentials to use for the remote command. Recent versions of PSExec will encrypt credentials sent over the network.
        [Parameter(Mandatory = $false)]
        [pscredential] $Credential,

        # Seconds to wait for PSExec before timing out. Default is 1800 (30 minutes).
        [Parameter(Mandatory = $false)]
        [Int] $TimeoutSeconds = 1800,

        # Path to PSExec.exe
        [Parameter(Mandatory = $false)]
        [ValidateScript({(Test-Path $_)})]
        [String] $PsExecPath
    )

    begin {
        $oldDebugPreference = $null
        if (-not $PSBoundParameters.ContainsKey('Debug')) {
            if ($DebugPreference -eq 'Inquire') {
                $oldDebugPreference = $DebugPreference
                $DebugPreference = 'Continue'
            }
        }

        if (-not $PsExecPath) {
            $PsExecPath = Get-Command -Name 'psexec' -CommandType Application -ErrorAction SilentlyContinue |
                Select-Object -First 1 -ExpandProperty Definition -ErrorAction SilentlyContinue

            if ([String]::IsNullOrEmpty($PsExecPath)) {
                Write-Debug "[Invoke-PsExec] PSExec.exe was not found. Throwing exception."
                throw "Unable to locate PSExec.exe. Place this file in the working directory [$PWD] or in the PATH variable, or specify it via the -PsExecPath parameter."
            }
            Write-Debug "[Invoke-PsExec] Using PSExec at path $PsExecPath"
        }

        # ProcessStartInfo uses ms instead of seconds
        $timeoutMilliseconds = $TimeoutSeconds * 1000

        $args = New-Object -TypeName System.Text.StringBuilder -ArgumentList '-accepteula '
        # [void] $args.Append("-accepteula \\{0}") # Computer name will go here
        if ($ComputerName) {
            [void] $args.Append('\\{0} ')
        }

        # Timeout after 30 seconds. This shouldn't be an issue since we're already using Test-Connection, but it's a good safeguard.
        # Also note the space at the end. This leads into the switch statement below.
        [void] $args.Append('-n 30 ')

        if ($Credential -and $Credential -ne [System.Management.Automation.PSCredential]::Empty) {
            Write-Debug "[Invoke-PsExec] Adding credentials for $($Credential.UserName)"
            [void] $args.AppendFormat('-u {0} -p {1} ', $Credential.UserName, $Credential.GetNetworkCredential().Password)
        }
        if ($WorkingDirectory) {
            [void] $args.Append("-w ""$WorkingDirectory"" ")
        }
        if ($Copy) {
            [void] $args.Append('-c -f ')
        }
        if (-not $Wait) {
            [void] $args.Append('-d ')
        }
        if (-not $LoadProfile) {
            [void] $args.Append('-e ')
        }
        if ($Interactive) {
            [void] $args.Append('-i ')
        }
        if ($System) {
            [void] $args.Append('-s ')
        }


        switch ($PSCmdlet.ParameterSetName) {
            'Command' {
                # Escape any quotes in the command
                # $commandEscaped = $Command.Replace('"', '""')
                # [void] $args.Append("cmd /c `"$commandEscaped`"")
                if (-not $Copy) {
                    [void] $args.Append("cmd /c $Command")
                }
                else {
                    # If we're copying the file, we can't use the cmd /c prefix, because the file path will be a path to a local file instead of a remote one.
                    [void] $args.Append("$Command")
                }
            }
            'PSScriptFile' {
                $remoteTempFilename = 'psexecTemp.ps1'
                # Apparently we need to pipe some data into STDIN in order to get PSExec to exit properly
                # More details here: http://www.leeholmes.com/blog/2007/10/02/using-powershell-and-psexec-to-invoke-expressions-on-remote-computers/
                [void] $args.Append("cmd /c `"echo . | powershell.exe -NoProfile -ExecutionPolicy Bypass -File $env:SystemRoot\$remoteTempFilename`"")
            }
            default {
                Write-Warning "Invoke-PsExec error: Unhandled parameter set name $($PSCmdlet.ParameterSetName)"
            }
        }

        $psExecArguments = $args.ToString()
    }

    process {
        foreach ($c in $ComputerName) {

            $fixedComputerName = $null

            if ($c -eq $env:COMPUTERNAME) {
                # Remove computer name argument for running on localhost
                $thisArgs = $psExecArguments.Replace(' \\{0}', '')
            }
            else {
                # Check for FQDN or CN. Apparently, PSExec returns output differently between the two, but you can "trick" it into redirecting normally if you add a dot (.) to the end of a CN.
                #
                # See this question for details: http://superuser.com/a/1038787
                if ($c -notmatch '\.') {
                    $fixedComputerName = "$c."
                }
                else {
                    $fixedComputerName = $c
                }
                Write-Debug "[Invoke-PsExec] Processing computer name: [$fixedComputerName]"
                $thisArgs = $psExecArguments.Replace('{0}', $fixedComputerName)
            }

            if ($PSVersionTable.PSVersion.Major -gt 2) {
                $props = [Ordered] @{
                    ComputerName  = $c
                    Ping          = $false
                    Success       = $false
                    ExitCode      = -1
                    StandardOut   = ""
                    StandardError = ""
                }
            }
            else {
                $props = @{
                    ComputerName  = $c
                    Ping          = $false
                    Success       = $false
                    ExitCode      = -1
                    StandardOut   = ""
                    StandardError = ""
                }
            }

            if ((-not $fixedComputerName) -or (Test-Connection -ComputerName $fixedComputerName -BufferSize 16 -Count 2 -Quiet)) {
                $props.Ping = $true
                Write-Debug "[Invoke-PsExec] Arguments for PSExec on this computer: [$thisArgs]"

                if ($PSCmdlet.ParameterSetName -eq 'PSScriptFile') {
                    $remoteScriptFile = Join-Path -Path "\\$c\admin`$" -ChildPath $remoteTempFilename
                    Write-Debug "[Invoke-PsExec] Copying script file to path [$remoteScriptFile]"
                    Copy-Item -Path $ScriptFile -Destination $remoteScriptFile -Force
                }
                elseif ($Copy) {

                }

                # We're using System.Diagnostics.Process instead of Start-Process due to the way Start-Process handles output redirection. (Spoilers: It doesn't.)
                $pInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo
                # $pInfo.FileName = 'cmd.exe'
                $pInfo.FileName = $PsExecPath
                $pInfo.RedirectStandardOutput = $true
                $pInfo.RedirectStandardError = $true
                $pInfo.UseShellExecute = $false
                # $pInfo.Arguments = "/c $PsExecPath $thisArgs"
                $pInfo.Arguments = $thisArgs

                $p = New-Object -TypeName System.Diagnostics.Process
                $p.StartInfo = $pInfo

                # Write-Verbose "Invoking PSExec on computer $c"
                Write-Debug "[Invoke-PsExec] Starting PSExec"
                try {
                    [void] $p.Start()

                    $stdOut = $p.StandardOutput.ReadToEndAsync()
                    $stdErr = $p.StandardError.ReadToEndAsync()

                    $hasExited = $p.WaitForExit($timeoutMilliseconds)
                    if (-not $hasExited) {
                        Write-Debug "[Invoke-PsExec] PsExec did not terminate in [$TimeoutSeconds] seconds; forcibly closing process"
                        $p.Kill()
                    }
                    # [void] $stdOut.Wait()
                    # [void] $stdErr.Wait()

                    $exitCode = $p.ExitCode
                    Write-Debug "PSExec exited with code $exitCode"
                    $props.ExitCode = $exitCode

                    if ($p.ExitCode -eq 0) {
                        $props.Success = $true
                    }

                    $props.StandardOut = "$($stdOut.Result)".Trim()
                    $props.StandardError = "$($stdErr.Result)".Trim()
                }
                catch {
                    Write-Verbose "PSExec encountered an exception: $_"
                    $props.StandardError = "$_"
                }
                finally {
                    if ($PSCmdlet.ParameterSetName -eq 'PSScriptFile') {
                        Write-Debug "[Invoke-PSExec] Removing temporary script file"
                        Remove-Item -Path $remoteScriptFile -Force
                    }
                }
            }
            else {
                Write-Verbose "Unable to connect to computer [$fixedComputerName]"
            }

            $obj = New-Object -TypeName PSCustomObject -Property $props
            $obj.PSTypeNames.Insert(0, 'Invoke-PsExec.Result')
            Write-Output $obj
        }
    }

    end {
        if ($oldDebugPreference) {
            $DebugPreference = $oldDebugPreference
        }
    }
}