API for executing a PowerShell script file from C#?
See original GitHub issueI’m writing a cmdlet that takes in paths of scripts and then executes them from the filesystem.
In PowerShell script this would be trivial — & $path
— but from a .NET cmdlet, I can only find hacky ways of doing this rather than a proper API for it.
Current methods
For the sake of documentation here are the methods I’ve got so far:
Escape the path as a PowerShell string and execute it as a command
Perhaps the most straightforward way to do this is to just use PowerShell to invoke it:
string wrapperScript = $"& '{Path.Replace("'", "''")}'";
InvokeCommand.InvokeScript(wrapperScript);
This works, but:
- We now need to generate PowerShell script on the fly and execute it. This could be especially problematic in scenarios where security and trust are in play
- If an error occurs, our script stack trace is polluted
- We’re forced to run a whole parse/execute flow just to get to the file, so it’s not just hacky but also very inefficient
Override PSRemotingCmdlet
Trying to work out the “right” way to do this, I looked at Invoke-Command
, since that’s a cmdlet we ship that can invoke script files directly from the filesystem.
This inherits from PSRemotingCmdlet
, which contains a lot of code for remote execution, but which also exposes this protected method:
In Invoke-Command
, this is used to execute a script file like this:
As far as I can tell, this is as close as we get to having a public API for executing a PowerShell script file directly. But there are several issues:
PSRemotingCmdlet
implements things likeBeginProcessing()
, so to safely reuse it we’re forced to override all its virtual cmdlet methods, and any parameters it defines we’re stuck with – however we could possibly just create a new instance of the cmdlet internally and call this method on it without giving it to the PS runtime…PSRemotingCmdlet
is also just a lot of code that we shouldn’t have to touch or think about to execute a script file, and inheriting from it means we can’t inherit from other cmdlet implementations in our code baseInvokeUsingCmdlet()
is internal and has a number of internally typed parameters, so it’s hard to call. This means we must choose another invocation method for the scriptblock. Executing it is still very possible, but naturally this deviates from how PowerShell does it natively.
Use reflection to create an ExternalScriptInfo
object and call that
Looking through things, I think this is basically the “right” way to do things, but a number of steps have no public way to perform and we are required to resort to reflection on APIs that aren’t guaranteed not to break (even though they’re pretty core APIs that probably aren’t going to change any time soon — but never say never).
The steps are essentially:
- Create an
ExternalScriptInfo
object. This is a public type, but has no public constructors and I wasn’t able to find another way to construct one - Pass that to
PowerShell.Create().AddCommand()
, which accepts aCommandInfo
object - Execute that
PowerShell
object
The issues here are that:
ExternalScriptInfo
has no public constructor, and the correct constructor requires the internal typeExecutionContext
https://github.com/PowerShell/PowerShell/blob/c5955a5c0de50295e509dbc10928147b33d5cbea/src/System.Management.Automation/engine/ExternalScriptInfo.cs#L24-L55- So we also need to use reflection to (1) get the
ExecutionContext
instance from our cmdlet and (2) efficiently pass it into the invocation of the constructor (by which I mean that compiling aFunc<ExternalScriptInfo>
around this is made harder because one of the inputs must be cast to a statically-unknown type) - Again, the way PowerShell itself uses this is to run
InvokeUsingCmdlet()
, which is also still internal. The silliest part here is that we just used anExecutionContext
to construct theExternalScriptInfo
and now invoking it from a cmdlet will just replace thatExecutionContext
. Instead the simplest way to go is going to beScriptBlock.Invoke()
or thePowerShell.Create(RunspaceMode.CurrentRunspace).AddCommand(externalFileInfo).Invoke()
method, both of which use more implicit magic around runspace and context. - While we can use a
CommandInfo
object forPowerShell.AddCommand()
, we can’t use it forPSCommand.AddCommand()
safely with older PowerShell versions because of https://github.com/PowerShell/PowerShell/issues/12297.
A better way?
So first off, others might have a better way to do this today, and if so we should definitely discuss it here. Ideally we can get scenarios like this documented nicely in some developer-oriented documentation.
But also, my ideal for this scenario would be new methods in particular places that allow a filepath to be passed in:
CommandInvocationIntrinsics.InvokeScriptFile(string filePath, ...)
, which would allow anything with aCommandInvocationIntrinsics
object to just run a scriptfilePowerShell.AddFile(string filePath, ...)
/PSCommand.AddFile(string filePath, ...)
, to allow the same using the usualPowerShell
API- A public
ExternalScriptInfo
constructor orCreate()
method that allows for the creation of aCommandInfo
object that directly describes a scriptfile, for parity withCmdletInfo
.FunctionInfo
might also be worth doing this for, althoughScriptBlock.Create()
essentially fills this role today.
Issue Analytics
- State:
- Created 3 years ago
- Comments:8 (5 by maintainers)
Top GitHub Comments
Note that the SDK’s
.AddCommand()
method already accepts a file path string directly:The name
.AddCommand()
doesn’t make that capability obvious, however, and the.AddScript()
method, which accepts a script block’s text, i.e. a source-code string rather than a file path, adds to the confusion.Yeah… the name makes sense in the context of implementation detail, but boy is it hard to explain outside of it.
The tl;dr of it is: if you can run
Get-Command X
and get a result,X
probably works inAddCommand
. If you can’t, then it probably needs to be parsed and compiled/interpreted.