Alternative to $MyInvocation

Viewed 270

When deploying PowerShell scripts from my RMM (NinjaOne), the scripts are called from a .bat (batch file).

Example:

@powershell -ExecutionPolicy Bypass -File "test.ps1" -stringParam "testing" -switchParam > "output.txt" 2>&1

The script I am calling requires PowerShell 7+, so I need to restart the script by calling pwsh with the current parameters. I planned to accomplish this via the following:

Invoke-Command { & pwsh -Command $MyInvocation.Line } -NoNewScope

Unfortunately, $MyInvocation.Line does not return the correct result when a PowerShell script is called from a batch file. What alternatives exist that would work in this scenario?

Notes:

  • I am unable to make changes to the .bat file.
  • $PSBoundParameters also does not return the expected result.

Testing Script (called from batch):

Param(
  [string]$string,
  [switch]$switch
)

if (!($PSVersionTable.PSVersion.Major -ge 7)) {
  Write-Output "`n"
  Write-Output 'Attempting to restart in PowerShell 7'
  if(!$MyInvocation.Line) {
    Write-Output 'Parameters not carried over - cannot restart' 
    Exit 
  } else { Write-Output $MyInvocation.Line }
  Invoke-Command { & pwsh -Command $MyInvocation.Line } -NoNewScope # PowerShell 7
  Exit
}

Write-Output 'Parameters carried over:'
Write-Output $PSBoundParameters

Write-Output "`nSuccessfully restarted"

Edit: I've discovered the reason for $MyInvocation / $PSBoundParameters not being set properly is due to the use of -File instead of -Command when my RMM provider calls the PowerShell script from the .bat file. I've suggested they implement this change to resolve the issue. Until they do, I am still looking for alternatives.

3 Answers

Leaving RMM providers out of the picture (whose involvement may or may not matter), the following test.ps1 content should work:

# Note: Place [CmdletBinding()] above param(...) to make
#       the script an *advanced* one, which then prevents passing
#       extra arguments that don't bind to declared parameters.
param(
  [string] $stringParam,
  [switch] $switchParam
)

# If invoked via powershell.exe, re-invoke via pwsh.exe
if ((Get-Process -Id $PID).Name -eq 'powershell') {
   # $PSCommandPath is the current script's full file path,
   # and @PSBoundParameters uses splatting to pass all 
   # arguments that were bound to declared parameters through.
   # Any extra arguments, if present, are passed through with @args
   pwsh -ExecutionPolicy Bypass -File $PSCommandPath @PSBoundParameters @args
   exit $LASTEXITCODE
}

# Getting here means that the file is being executed by pwsh.exe

# Print the arguments received:

if ($PSBoundParameters.Count) {
  "-- Bound parameters and their values:`n"
  # !! Because $PSBoundParameters causes table-formatted
  # !! output, synchronous output must be forced to work around a bug.
  # !! See notes below.  
  $PSBoundParameters | Out-Host
}

if ($args) {
  "`n-- Unbound (positional) arguments:`n"
  $args
}

exit 0

As suggested in the code comments, place [CmdletBinding()] above the param(...) block in order to make the script an advanced one, in which case passing extra arguments (ones that don't bind to formally declared parameters) is actively prevented (and in which case $args isn't defined).

Caveat:

  • Note the need for piping $PSBoundParameters to Out-Host in order to force synchronous output of the (default) table-formatted representation of its value. The same would apply to outputting any other values that would result in implicit Format-Table formatting not based on predefined formatting data ($args, as an array whose elements are strings isn't affected). (Note that While Out-Host output normally isn't suitable for data output from inside a PowerShell session, it does write to an outside caller's stdout.)

  • This need stems from a very unfortunate output-timing bug, present in both Windows PowerShell and the current PowerShell (Core) version, 7.2.1; it is a variant manifestation of the behavior in detail in this answer and reported in GitHub issue #13985 and applies here because exit is called within 300 msecs. of initiating the implicitly table-formatted output; if you were to omit the final exit 0 statement, the problem wouldn't arise.

See also:

  • The automatic $PSCommandPath variable, reflecting the running script's full (absolute) path.

  • The automatic $PSBoundParameters variable, a dictionary containing all bound parameters and their values (arguments), and the automatic $args variable, containing all (remaining) positional arguments not bound to formally declared parameters.

  • Parameter splatting, in which referencing a hashtable (dictionary) or array variable with sigil @ instead of $, passes the hashtable's entries / array's elements as individual arguments.

  • powershell.exe, the Windows PowerShell CLI; pwsh, the PowerShell (Core) 7+ CLI.

  • Other automatic variables used above, namely $PID (the current process' ID) and $LASTEXITCODE (the most recently executed external program's exit code).


As for what you tried:

  • $MyInvocation.Line isn't defined when a script is called via PowerShell's CLI; however, the automatic $PSCommandPath variable, reflecting the running script's full file path, is defined.

    • Even if $MyInvocation.Line were defined, it wouldn't enable robust pass-through of the original arguments, due to potential quoting issues and - when called from inside PowerShell - due to reflecting unexpanded arguments. (Also, the value would start with the executable / script name / path, which would have to be removed.)

    • While [Environment]::CommandLine does reflect the process command line of the CLI call (also starting with the executable name / path), the quoting issues apply there too.

  • Also, it is virtually pointless to use Invoke-Command for local invocations - see this answer.

I'd try putting a file called powershell.bat early on your path (at least, in a directory earlier on the path than the C:\Windows\System32\WindowsPowerShell\v1.0\; entry) and assemble the appropriate parameters (I've no idea of your required structure for $myinvocationline - no doubt it could be derived from the parameters delivered to powershell.bat).

My thinking is that this should override powershell, re-assemble the bits and deliver them to pwsh.

I put this line in test.ps1:

$MyInvocation | Format-List -Property *

Found this content in output.txt:



MyCommand             : test.ps1
BoundParameters       : {}
UnboundArguments      : {-stringParam, testing, -switchParam}
ScriptLineNumber      : 0
OffsetInLine          : 0
HistoryId             : 1
ScriptName            : 
Line                  : 
PositionMessage       : 
PSScriptRoot          : 
PSCommandPath         : 
InvocationName        : D:\Temp\StackOverflow\71087897\test.ps1
PipelineLength        : 2
PipelinePosition      : 1
ExpectingInput        : False
CommandOrigin         : Runspace
DisplayScriptPosition : 



Then tried this in test.ps1:

[string]$MySelf = $MyInvocation.InvocationName
Write-Host "###$MySelf###"
[string[]]$Params = $MyInvocation.UnboundArguments
foreach ($Param in $Params) {
    Write-Host "Param: '$Param'"
}

And found this in output.txt:

###D:\Temp\StackOverflow\71087897\test.ps1###
Param: '-stringParam'
Param: 'testing'
Param: '-switchParam'

Then tried this in test.ps1:

[string]$Line = "$($MyInvocation.InvocationName) $($MyInvocation.UnboundArguments)"
Write-Host "$Line"

And found this in output.txt:

D:\Temp\StackOverflow\71087897\test.ps1 -stringParam testing -switchParam

Does this get you to where you need to be?

Related