5

I have a PS-Module completely written in C#. It contains about 20 Cmdlets that are already in production. Some of these "share code". Take this example:

I have a Cmdlet called InvokeCommitCommand that produces a "changeset". This Cmdlet also publishes metadata of this changeset. I would now like to create a new Cmdlet called PublishCommitCommand that can be called independantly to execute the "publishing" of an already existing changeset. I would therefore like to refactor InvokeCommitCommand to make use of the new Cmdlet PublishCommitCommand and avoid code duplication.

More generally speaking ... I am trying to invoke a cmdlet CommandB from cmdlet CommandA. They are defined as follows

public CommandA : PSCmdlet
{
  ...
}

public CommandB : PSCmdlet
{
  ...
}

I have a few options here. But none of them work.

1. Option

Invoke CommandB by creating an instance of it. That would've been my first guess. Like so:

var cmd = new CommandB();
cmd.Invoke();

Unfortunately that does not work. I get the exception:

Cmdlets derived from PSCmdlet cannot be invoked directly ...

So ... next option.

2. Option

Create an instance of PowerShell and run the command. Like so:

var ps = PowerShell.Create();
ps.AddCommand("CommandB");
ps.Invoke();

Unfortunately that doesn't work either. This causes a new PowerShell instance to be created and therefore I loose all stream redirections I may have attached to the current PowerShell instance I am running in.

I know I can reuse the runspace. But using the same runspace does NOT save me from losing my redirections. If CommandB would call Write-Verbose "Huzzah!", I would not see that 'Huzzah!' anywhere.

In short: I need to run the CommandB in the same PS instance as CommandA

3. Option

Use a ScriptBlock. Like so:

var sb = ScriptBlock.Create("CommandB");
sb.Invoke();

That's pretty nice. But the problem here is, that I have no means to pass any complex class arguments to the script block. If CommandB has a parameter of type ... let's say PSCredential, I have no easy way to pass that parameter to the script. If I had a PowerShell object, I could easily do

PowerShell ps
ps.AddCommand("CommandB");
ps.AddArgument("Credential", someCredentialObject);
ps.AddArgument("TargetUri", new Uri("www.google.de"));

But I can not that with a ScriptBlock. True, I could use InvokeWithContext which allows me to pass variables to the scriptblock, but I would need to "wrap" each complex argument in a variable first... rather cumbersome.

Conclusion

Any ideas? The best thing would be if I somehow could - from inside CommandA get access to the current instance of PowerShell I am running in. I could then leverage option 2 without the issue of creating a new instance. But I do not know if that is even possible...

Hemisphera
  • 816
  • 6
  • 23
  • Your option 3 assumption is incorrect. You can still pass arguments to script blocks just like you can to a function or script. – Maximilian Burszley Oct 26 '17 at 16:06
  • `using(var sp = ScriptBlock.Create("CommandB").GetSteppablePipeline(MyInvocation.CommandOrigin)) { sp.Begin(this); sp.Process(); sp.End() }` – user4003407 Oct 26 '17 at 18:27
  • @PetSerAl I fail to understand why a `SteppablePipeline` would help me here. – Hemisphera Oct 26 '17 at 18:39
  • @TheIncorrigible1 I have edited Option 3 to show an example of why I'd prefer `PowerShell` over `ScriptBlock`. – Hemisphera Oct 26 '17 at 18:42
  • 1
    Ultimately, what are you trying to accomplish and at that point, why are you forcing yourself to use C# instead of just utilizing PowerShell? – Maximilian Burszley Oct 26 '17 at 18:44
  • I'm with @TheIncorrigible1, executing a cmdlet within a cmdlet is fairly easy within powershell. You can define helper functions in the begin section of a cmdlet that can then be utilized during the process section. You can also reference cmdlets from within another cmdlet by loading modules or calling the ps1 file directly using Invoke-Command. But knowing overall what you are trying to accomplish will help us help you. – Paolis Oct 26 '17 at 18:52
  • I have edited in an introduction giving some background. But I was generally interested in how one would accomplish such a task, regardless of my use case. – Hemisphera Oct 26 '17 at 19:11
  • 1
    As for C#: it is written in C# because it is easier. It makes heavy use of dependencies and LINQ and such. Plus: it already is C#, rather complex and large and I don't want to rewrite it. Furthermore: the module is not only used in PS, but it's used inside a WPF-Application aswell. – Hemisphera Oct 26 '17 at 19:12
  • BTW, passing parameters to script block is not much harder, than to `PowerShell` object: `ScriptBlock.Create("param($Command, $Parameters) & $Command @Parameters").Invoke("CommandB", new Hashtable { { "Credential", someCredentialObject }, { "TargetUri", new Uri("www.google.de") } })` – user4003407 Oct 26 '17 at 19:35
  • Nice. I like your approach. Tried it out a minute ago and works perfectly. I'd take that as an answer ... – Hemisphera Oct 27 '17 at 06:41

3 Answers3

2

The solution I came up with in the end is a helper class that implements the method suggested by PetSerAl. I use a ScriptBlock like in my 3rd option above, but with a few changes to make passing parameters less tedious.

So here is my helper class that does the job quite nicely:

  public class PsInvoker
  {

    public static PSObject[] InvokeCommand(string commandName, Hashtable parameters)
    {
      var sb = ScriptBlock.Create("param($Command, $Params) & $Command @Params");
      return sb.Invoke(commandName, parameters).ToArray();
    }

    public static PSObject[] InvokeCommand<T>(Hashtable parameters) where T : Cmdlet
    {
      return InvokeCommand(Extensions.GetCmdletName<T>(), parameters);
    }

    public static PsInvoker Create(string cmdletName)
    {
      return new PsInvoker(cmdletName);
    }

    public static PsInvoker Create<T>() where T : Cmdlet
    {
      return new PsInvoker(Extensions.GetCmdletName<T>());
    }


    private Hashtable Parameters { get; set; }


    public string CmdletName { get; }

    public bool Invoked { get; private set; }

    public PSObject[] Result { get; private set; }


    private PsInvoker(string cmdletName)
    {
      CmdletName = cmdletName;
      Parameters = new Hashtable();
    }


    public void AddArgument(string name, object value)
    {
      Parameters.Add(name, value);
    }

    public void AddArgument(string name)
    {
      Parameters.Add(name, null);
    }

    public PSObject[] Invoke()
    {
      if (Invoked)
        throw new InvalidOperationException("This instance has already been invoked.");
      var sb = ScriptBlock.Create("param($Command, $Params) & $Command @Params");
      Result = sb.Invoke(CmdletName, Parameters).ToArray();
      Invoked = true;
      return Result;
    }

  }

This class basically provides two methods of invoking a cmdlet:

  1. You can use its static methods InvokeCommand and pass the name of the cmdlet plus any parameters as a Hashtable.
  2. Create an instance of the PsInvoker class and use AddArgument to add parameters and then use Invoke to run the cmdlet.

Thanks again to PetSerAl.

Hemisphera
  • 816
  • 6
  • 23
  • What is the fully qualified name of `Extensions.GetCmdletName()`? When I copy-paste your class into my test project, the `Extensions` class is the only one that Visual Studio can't automatically figure out. – deadlydog Dec 27 '19 at 01:25
  • When attempting to use your class and calling the `InvokeCommand` function, I get this error message: System.Management.Automation.PSInvalidOperationException : There is no Runspace available to run scripts in this thread. You can provide one in the DefaultRunspace property of the System.Management.Automation.Runspaces.Runspace type. The script block you attempted to invoke was: param($Command, $Pa\u2026 & $Command @Params Do you have an example of how it should be used? Thanks! – deadlydog Dec 27 '19 at 02:39
  • `Extensions` is just an extension class that I wrote, GetCmdletName returns the name of the Cmdlet, reading it from the `Cmdlet` attribute, so I don't have to hardcode it. You can just hardcode the name of the Cmdlet instead. – Hemisphera Dec 27 '19 at 07:13
  • Also: make sure you run this from **inside** an active PS runspace. If yiu don't have a runspace, you need tivset one up. And: use it from within the main thread. PS and multithread don't work very well together. – Hemisphera Dec 27 '19 at 07:16
2

I wonder if it would be better to re-think the problem/design you are trying to resolve. To expand upon a much better answer from another question, build smaller units and connect them together. For you, this could mean one of the following:

  • refactor your cmdlets so that the output of one matches the pipeline input of another (aka, Publish-Commit | Invoke-Commit)
  • refactor your cmdlets so that the output of one matches the parameter input of another (aka, Invoke-Commit -Param $(Publish-Commit))
  • add a parameter with type ScriptBlock so that you can call the higher context (aka, Invoke-Commit -ScriptBlock {Publish-Commit -Param value})

All this is to say: write what you want in C#, but it is better "glued" together in PowerShell. While there are merits to calling PowerShell from C#, if you are tempted to do so likely you should re-think your approach because you are likely following an anti-pattern.

carrvo
  • 511
  • 5
  • 11
1

If you have a PSCmdlet you can use use PSCmdlet.InvokeCommand.InvokeScript() which allows you to specify the SessionState, whether to use a new scope or not and also allows you to pass properties to it.

cogumel0
  • 2,430
  • 5
  • 29
  • 45
  • This appears the right way to do this, but I can't get it to work. I want to invoke `Out-String` and use the resultant string. This returns an empty string: `this.InvokeCommand.InvokeScript(@"Microsoft.PowerShell.Utility\Out-String", false, System.Management.Automation.Runspaces.PipelineResultTypes.None, _psObjects, null)`. `_psObjects` has 10 items in it... – tig Mar 15 '20 at 18:50
  • Open a question and provide some more context as that's clearly not enough and I'll try to help. – cogumel0 Mar 17 '20 at 09:10