How do I pass a class object in a argument list to a another computer and call a function on it?
Asked Answered
E

2

3

I am attempting to create a class object and use Invoke-Command to call a function on the class on a remote machine. When I use Invoke-Command with no computer name this works fine but when I attempt to do this on a remote computer I get an error saying the that the type does not contain my method. Here is the script I am using for testing this.

$ComputerName = "<computer name>"

[TestClass]$obj = [TestClass]::new("1", "2")

Get-Member -InputObject $obj

$credentials = Get-Credential

Invoke-Command -ComputerName $ComputerName -Credential $credentials -Authentication Credssp -ArgumentList ([TestClass]$obj) -ScriptBlock {
    $obj = $args[0]

    Get-Member -InputObject $obj

    $obj.DoWork()
    $obj.String3
}

class TestClass {
    [string]$String1
    [string]$String2
    [string]$String3

    [void]DoWork(){
        $this.String3 = $this.String1 + $this.String2
    }

    TestClass([string]$string1, [string]$string2) {
        $this.String1 = $string1
        $this.String2 = $string2
    }
}

Here is the output I get.

PS > .\Test-Command.ps1

cmdlet Get-Credential at command pipeline position 1
Supply values for the following parameters:
User: <my user>
Password for user <my user>: *

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}


   TypeName: Deserialized.TestClass

Name     MemberType Definition
----     ---------- ----------
GetType  Method     type GetType()
ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatProvider)
String1  Property   System.String {get;set;}
String2  Property   System.String {get;set;}
String3  Property    {get;set;}
Method invocation failed because [Deserialized.TestClass] does not contain a method named 'DoWork'.
    + CategoryInfo          : InvalidOperation: (DoWork:String) [], RuntimeException
    + FullyQualifiedErrorId : MethodNotFound
    + PSComputerName        : <computer name>

I can see that the type changes from TestClass to Deserialized.TestClass and I am wondering if there is a way around this? My goal is to be able to ship the functions I need to each of the machines I am running a script on so that I don't have to rewrite the functions in the context of the Invoke-Command script block.

Ellipticity answered 24/1, 2020 at 15:25 Comment(2)
I think you need to serialize the object. See S.O. Is there a way to pass serializable objects to a PowerShell script with start-process?Stuppy
just include class definition in the script block and pass constructor arguments as parameter. Since 2 computers do not share memory they can only exchange with serialized (key/value pair) objectsNogas
H
2

It's an older question, but it was relevant to me. I found another way for my purposes:

  1. To make TestClass known in the remote environment, it can be included in the abstract syntax tree (AST) of the script before processing this. The same is also very useful for using statements (which must be declared on top of the file only) or functions (which can be used local and in a remote script without double declaration). The Edit-RemoteScript function is used for this purpose. (The solution was inspired by this answer in another forum. This very useful tool can help exploring the AST.)
  2. In order to get an object of the self-defined class as a 'living' object remotely or after it has been returned from the remote environment, it can be casted from Deserialized.TestClass to TestClass. The new constructor, which accepts a PSObject, serves this purpose. Alternatively, an op_Implicit or op_Explicit operator also accepting a PSObject can do the same. Inside this operator a class constructor must be invoked. Both operators seem to work identically in PowerShell.

This sample code illustrates the functionality:

using namespace Microsoft.PowerShell.Commands
using namespace System.Collections
using namespace System.Diagnostics.CodeAnalysis
using namespace System.Management.Automation
using namespace System.Management.Automation.Language

Set-StrictMode -Version ([Version]::new(3, 0))

class TestClass {
    [string]$String1
    [string]$String2
    [string]$String3

    [void]DoWork() {
        $this.String3 = $this.String1 + $this.String2
    }

    TestClass([string]$string1, [string]$string2) {
        $this.String1 = $string1
        $this.String2 = $string2
    }
    TestClass([PSObject]$ClassObject) {
        $this.String1 = $ClassObject.String1
        $this.String2 = $ClassObject.String2
        $this.String3 = $ClassObject.String3
    }
}

<#
    .DESCRIPTION
        This function adds using statements, functions, filters and types to ScriptBlocks to be used for remote access.

    .PARAMETER ScriptBlock
        The ScriptBlock to be processed. Mandatory.

    .PARAMETER Namespace
        The list of namespaces to add. 'default' adds any namespaces listed in the root script's using statements. Alternatively or additionally, 
        any other namespaces can be added. These have to be fully qualified. The statement 'using namespace' must not be prefixed. 
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.

    .PARAMETER Module
        The list of PowerShell modules to add. 'default' adds any module listed in the root script's using statements. Alternatively or additionally, 
        any other module can be added. The value of the argument can be a module name, a full module specification, or a path to a module file.
        When it is a path, the path can be fully qualified or relative. A relative path is resolved relative to the script that contains the using statement. 
        The modules referenced by path must be located identically in the file systems of the calling site and the remote site.
        The statement 'using namespace' must not be prefixed. 
        When it is a name or module specification, PowerShell searches the PSModulePath for the specified module.
        A module specification is a hashtable that has the following keys:
            - ModuleName - Required, specifies the module name.
            - GUID - Optional, specifies the GUID of the module.
            - It's also required to specify at least one of the three below keys.
            - ModuleVersion - Specifies a minimum acceptable version of the module.
            - MaximumVersion - Specifies the maximum acceptable version of the module.
            - RequiredVersion - Specifies an exact, required version of the module. This can't be used with the other Version keys.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.
    
    .PARAMETER Assembly
        The list of .NET assemblies to add. 'default' adds any assembly listed in the root script's using statements. Alternatively or additionally, 
        any other assembly can be added. The value can be a fully qualified or relative path. A relative path is resolved relative to the script that 
        contains the using statement. The assemblies referenced must be located identically in the file systems of the calling site and the remote site.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.

    .PARAMETER Type
        The list of names from types defined by the root script to add to the processed script.  
        The type definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedTypes comment.
        Defaut is an empty list.

    .PARAMETER Function
        The list of names from functions or filters defined by the root script to add to the processed script.  
        The function definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedFunctions comment.
        Defaut is an empty list.

    .PARAMETER SearchNestedScriptBlocks
        If this parameter is set, ScriptBlocks contained in the root script are also searched for functions, filters and types, otherwise only the root 
        script itself.

    .EXAMPLE
        In this example the namespaces used by the root script and two additional using namespace statements are added to $myScriptBlock.
        One type and two functions, defined by the root script, are also added:

        $myScriptBlock | Edit-RemoteScript `
            -Namespace 'default', 'System.Collections', 'System.Collections.Generic' `
            -Type 'MyType' `
            -Function 'ConvertTo-MyType', 'ConvertFrom-MyType'

    .NOTES
        Because the using statements must come before any other statement in a module and no uncommented statement can precede them, including parameters, 
        one cannot define any using statement in a nested ScriptBlock. Therefore, the only alternative to post-inserting the using statements into a 
        previously defined ScriptBlock, as is done in this function, is to define $myScript as a string and create the ScriptBlock using [ScriptBlock]::Create($myScript). 
        But then you lose syntax highlighting and other functionality of the IDE used.

        An alternative way of including functions, filters and types that are used in both, the root script and the remote script, in the latter is shown in 
        the links below. An alternative to post-insertion would be to redefine these functions, filters, and types in the remote script. However, the downside 
        is that changes to the code have to be kept in sync in different places, which reduces its maintainability. 

    .LINK 
        this function:
        https://mcmap.net/q/15989/-how-do-i-pass-a-class-object-in-a-argument-list-to-a-another-computer-and-call-a-function-on-it
    
    .LINK 
        alternative for types:
        https://mcmap.net/q/15989/-how-do-i-pass-a-class-object-in-a-argument-list-to-a-another-computer-and-call-a-function-on-it

    .LINK 
        alternative for functions:
        https://mcmap.net/q/16217/-powershell-invoke-command-argumentlist-with-optional-argument
#>
function Edit-RemoteScript {

    [CmdletBinding()]
    [OutputType([ScriptBlock])]
    [SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', 'functionText', Justification = "Doesn't apply")]

    param(
        [Parameter(Mandatory, ValueFromPipeline)] [ScriptBlock]$ScriptBlock,
        [Parameter()] [AllowEmptyCollection()] [String[]]$Namespace = @(),
        [Parameter()] [AllowEmptyCollection()] [ModuleSpecification[]]$Module = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Assembly = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Type = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Function = @(),
        [Parameter()] [Switch]$SearchNestedScriptBlocks
    )

    begin {
        [Ast]$cmdletAst = $MyInvocation.MyCommand.ScriptBlock.Ast
        do {
            [Ast]$tempAst = $cmdletAst.Parent
        } while ($null -ne $tempAst -and ($cmdletAst = $tempAst))
        [String[]]$remoteUsings = @()
        [String[]]$remoteTypes = @()
        [String[]]$remoteFunctions = @()
    } process {
        if (($Namespace -or $Module -or $Assembly) -and -not $remoteUsings) {
            if ('default' -iin $Namespace -or
                'default' -iin $Assembly -or (
                    $Module | Where-Object -Property 'Name' -EQ -Value 'default' | Select-Object -First 1
                )
            ) {
                [UsingStatementAst[]]$allUsings = @($cmdletAst.FindAll({ $args[0] -is [UsingStatementAst] }, $false))
            }
            $remoteUsings = @(
                @(
                    @{
                        Kind  = [UsingStatementKind]::Namespace
                        Names = $Namespace
                    },
                    @{
                        Kind  = [UsingStatementKind]::Module
                        Names = $Module
                    },
                    @{
                        Kind  = [UsingStatementKind]::Assembly
                        Names = $Assembly
                    }
                ) | ForEach-Object -Process { 
                    [UsingStatementKind]$kind = $_.Kind
                    $_.Names | ForEach-Object -Process {
                        if (($kind -eq [UsingStatementKind]::Module -and $_.Name -ieq 'default') -or ($kind -ne [UsingStatementKind]::Module -and $_ -ieq 'default')) {
                            @($allUsings | Where-Object -Property 'UsingStatementKind' -EQ -Value $kind | ForEach-Object -Process { $_.ToString() })
                        } else {
                            if ($kind -eq [UsingStatementKind]::Assembly) {
                                "using $( $kind.ToString().ToLowerInvariant() ) '$_'"
                            } else {
                                "using $( $kind.ToString().ToLowerInvariant() ) $_"
                            }
                        }
                    }
                }
            )
        }
        if ($Type -and -not $remoteTypes) {
            $remoteTypes = @(
                $cmdletAst.FindAll({ $args[0] -is [TypeDefinitionAst] }, $SearchNestedScriptBlocks) | 
                    Where-Object -Property 'Name' -In $Type | 
                    ForEach-Object -Process { $_.ToString() }
            )
        }
        if ($Function -and -not $remoteFunctions) {
            $remoteFunctions = @(
                if ($SearchNestedScriptBlocks) {
                    # this is slower
                    $cmdletAst.FindAll({
                            param(
                                [Parameter()] [Ast]$Ast
                            )
                            <#
                                Class methods have a FunctionDefinitionAst under them as well, but we don't want them.
                                from: https://mcmap.net/q/16218/-get-all-functions-in-a-powershell-script
                            #>
                            $Ast -is [FunctionDefinitionAst] -and $Ast.Parent -isnot [FunctionMemberAst]
                        },
                        $true) |
                        Where-Object -FilterScript {
                            $_.Name -iin $Function
                        } |
                        ForEach-Object -Process { $_.ToString() }
                } else {
                    # this is faster
                    Get-ChildItem -Path 'Function:' |
                        Where-Object -Property 'Name' -In $Function |
                        ForEach-Object -Process {
                            if ($_.CommandType -eq [CommandTypes]::Filter) {
                                "filter $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            } else {
                                "function $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            }
                        }
                }
            )
        }
        [ScriptBlock]::Create($ScriptBlock.ToString(). `
                Replace('#ImportedUsings', $remoteUsings -join "`n"). `
                Replace('#ImportedTypes', $remoteTypes -join "`n"). `
                Replace('#ImportedFunctions', $remoteFunctions -join "`n"))
    } end {
    }
}

function TestFunction {
    42
}

$ComputerName = 'Server1'
[TestClass]$obj = [TestClass]::new('1', '2')
[ScriptBlock]$testScript = {
    #ImportedUsings # the imported using statements will be inserted here

    Set-StrictMode -Version ([Version]::new(3, 0))

    #ImportedTypes # the imported types will be inserted here
    #ImportedFunctions # the imported functions will be inserted here

    $obj = $args[0]
    [ArrayList]$results = @() # using statements are working remotely
    [TestClass]$castedObj = [TestClass]$obj # the type is known remotely

    [void]$results.Add('')
    [void]$results.Add('* * * remote * * *')
    [void]$results.Add((TestFunction)) # the function is known remotely
    $castedObj.DoWork() # the type has his functionality remotely
    [void]$results.Add($castedObj.String3)
    [void]$results.Add((Get-Member -InputObject $obj))
    [void]$results.Add((Get-Member -InputObject $castedObj))
    [void]$results.Add('')
    [void]$results.Add($castedObj)
    [void]$results.Add([TestClass]::new('3', '4'))
    $results
}
$testScript = $testScript | Edit-RemoteScript -Namespace 'default' -Type 'TestClass' -Function 'TestFunction'
$credentials = Get-Credential

'* * * local * * *'
TestFunction
Get-Member -InputObject $obj

$results = Invoke-Command -ComputerName $ComputerName -Credential $credentials -ArgumentList ([TestClass]$obj) -ScriptBlock $testScript
foreach ($ctr in 0..6) {
    $results[$ctr]
}
[TestClass]$resultObj = $results[7] # also returned objects can be casted back to the original type
"this is the original instance, DoWork() is already done, String3 = '$( $resultObj.String3 )'"
$resultObj = $results[8]
"this is a new instance, DoWork() isn't done yet, String3 = '$( $resultObj.String3 )'"
$resultObj.DoWork()
"... but now, String3 = '$( $resultObj.String3 )'"

Output:

* * * local * * *
42

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}

* * * remote * * *
42
12

   TypeName: Deserialized.TestClass

Name     MemberType Definition
----     ---------- ----------
GetType  Method     type GetType()
ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatPro… 
String1  Property   System.String {get;set;}
String2  Property   System.String {get;set;}
String3  Property    {get;set;}

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}

this is the original instance, DoWork() is already done, String3 = '12'
this is a new instance, DoWork() isn't done yet, String3 = ''
... but now, String3 = '34'

In this case it is certainly a big overhead and it would actually be easier to re-define TestClass. In larger projects with complex classes, however, the procedure may worthwhile. Another advantage: there is no longer any need to synchronize functions and classes that have been declared multiple times when changes are made.

If you are working with a PSSession in which several remote calls are passed one after the other, it may even be worthwhile to have a script executed remotely first that is used exclusively for the declarations. Then a specific typed parameter type TestClass can be used instead of Object or PSObject because type TestClass is already known when the script is invoked. A casting of the parameter can be ommitted in this case:

[ScriptBlock]$TestScript = {
    param([Parameter()] [TestClass]$Obj)
    ....
    $Obj.DoWork() # the type has his functionality remotely
    [void]$results.Add($Obj.String3)
    ...
}

Edit 1: a small correction of the function code and inserted usefull links

Edit 2: suggested by @mklement0 's answer: making the function more universal; a comment-based help has also been added

Edit 3: clarification and small correction regarding casting operators

Hypotaxis answered 15/7, 2023 at 18:52 Comment(9)
This solution is incredible, thank you for adding this. I wish I could mark both of these as answers. If I were doing this again today, I would use the solution you used for sure. Having the class definition as a string, like in the previous answer, was horrible to maintain and made me very reluctant to add things to the class definition.Ellipticity
@MaxYoung: Thank you for your enthusiastic response! I have supplemented my post with two useful sources. I'm surprised I couldn't find anything similar online. But maybe I just googled the wrong keywords ;-)Hypotaxis
Kudos for an attempt at a comprehensive solution. While it works for using namespace statements, it isn't guaranteed to work for using assembly and using module statements - and adding robust support for these is all but impossible. As an aside: you don't need a constructor with a [PSObject] parameter; a parameter-less constructor will do, relying on PowerShell's built-in object-initialization features.Lumumba
@MaxYoung, I agree that the Invoke-Expression solution wasn't great. I've updated my answer to show a solution using a script block - for simple, self-contained class definitions this should do (a similar approach works for functions). If you need support for class-external using namespace statements, use Olli's answer.Lumumba
@mklement0: Many thanks for the helpful comment. I have no personal experience with using module and using assembly. Nevertheless, I tried to create a workaround. In addition, the function is now more versatile.Hypotaxis
@mklemen0: As for the suggestion of using a parameterless constructor, I think that might obscure functionality a bit.Hypotaxis
@mklement0: Regarding the alternative ways of inserting types and functions into scripts: with the function I suggested, the entire functionality is encapsulated in one place and, in my opinion, the application is significantly simplified as a result. Another disadvantage of the alternative way shown: If Type1 and Type2 are used in the remote script, but not Type3, and furthermore Type2 is used in Type1 and Type3, Type3 must also be defined in the dot-sourced ScriptBlock in order to work in the calling script. This makes the code less transparent and more complicated.Hypotaxis
To be clear: Your solution is undoubtedly more complete and encapsulated (you already had my +1), so it comes down to a tradeoff between implementation effort and scope of the required functionality. I was wrong about the parameterless constructor: since your example class already has an explicit constructor in your example, you need another explicit constructor to support initialization by another object. This makes the solution a bit less modular, as you then have to design your classes with that in mind, but that cannot be helped.Lumumba
The added support for using assembly and using module is commendable, but note that there's no guarantee that the targeted assemblies and modules will be available in the remote session. As an aside that may be of interest: In script-block literals you aren't actually permitted to use using statements, but [scriptblock]::Create() allows it - see GitHub issue #19985. Finally, thanks for the tip re Show-Ast - very useful; worth mentioning that it works on Windows only (it is WinForms-based).Lumumba
L
2

In short: The XML-based serialization / deserialization that PowerShell employs behind the scenes during remoting and in background jobs only handles a handful of known types with type fidelity.

Instances of custom classes such as yours are emulated with method-less "property bags" in the form of [pscustomobject] instances, which is why the emulated instances of your class instances have no methods on the remote machine.

For a more detailed overview of PowerShell's serialization/deserialization, see the bottom section of this answer.


As Mike Twc suggests, you can work around the problem by passing your class definition along to your remote command as well, allowing you to redefine the class there and then recreate instances of your custom class in the remote session.

While you cannot dynamically obtain a custom class definition's source code directly, you can work around this by placing it inside a helper script block, which allows you to:

  • Define the class locally by dot-sourcing the helper script block (. { ... })

  • Recreate it remotely, via the script block's source code, using Invoke-Expression

    • A script block's verbatim source code (excluding the { and }) is obtained simply by stringifying it, i.e., by calling .ToString() on it. In fact, in the context of PowerShell remoting a script block implicitly becomes its string representation - surprisingly so; see GitHub issue #11698.

    • Note that while Invoke-Expression should generally be avoided, its use is safe here, given that the string being evaluated as PowerShell code is fully under your control.

A simplified example, which uses the $using: scope rather than parameters (via -ArgumentList) to include values from the caller's scope.

# Define your custom class in a helper script block
# and dot-source the script block to define the class locally.
# The script block's string representation is then the class definition.
. (
  $classDef = {
    class TestClass {
      [string] $String1
      [string] $String2
      [string] $String3

      DoWork() {
        $this.String3 = $this.String1 + $this.String2
      }

      TestClass([string] $string1, [string] $string2) {
        $this.String1 = $string1
        $this.String2 = $string2
      }

      # IMPORTANT:
      # Also define a parameter-less constructor, for convenient
      # construction by a hashtable of properties.
      TestClass() {}

    }
  }
)

# Construct an instance locally.
$obj = [TestClass]::new("1", "2")

# Invoke a command remotely, passing both the class definition and the input object. 
Invoke-Command -ComputerName . -ScriptBlock {
  # Define the class in the remote session too, via its source code.
  # NOTE: This particular use of Invoke-Expression is safe, because you control the input,
  #       but it should generally be avoided.
  #       See https://blogs.msdn.microsoft.com/powershell/2011/06/03/invoke-expression-considered-harmful/
  Invoke-Expression $using:classDef
  # Now you can cast the emulated original object to the recreated class.
  $recreatedObject = [TestClass] $using:obj
  # Now you can call the method...
  $recreatedObject.DoWork()
  # ... and output the modified property
  $recreatedObject.String3
}

See also:

  • For an analogous approach to using locally defined functions remotely, see this answer.
Lumumba answered 26/1, 2020 at 22:31 Comment(1)
Okay, you are welcome!! I figured you'd maybe know if it wasn't maybe something close enough you'd see it, etc. I love your answers with all the technical breakdown, keeps me on my learning toes sir!!Filiation
H
2

It's an older question, but it was relevant to me. I found another way for my purposes:

  1. To make TestClass known in the remote environment, it can be included in the abstract syntax tree (AST) of the script before processing this. The same is also very useful for using statements (which must be declared on top of the file only) or functions (which can be used local and in a remote script without double declaration). The Edit-RemoteScript function is used for this purpose. (The solution was inspired by this answer in another forum. This very useful tool can help exploring the AST.)
  2. In order to get an object of the self-defined class as a 'living' object remotely or after it has been returned from the remote environment, it can be casted from Deserialized.TestClass to TestClass. The new constructor, which accepts a PSObject, serves this purpose. Alternatively, an op_Implicit or op_Explicit operator also accepting a PSObject can do the same. Inside this operator a class constructor must be invoked. Both operators seem to work identically in PowerShell.

This sample code illustrates the functionality:

using namespace Microsoft.PowerShell.Commands
using namespace System.Collections
using namespace System.Diagnostics.CodeAnalysis
using namespace System.Management.Automation
using namespace System.Management.Automation.Language

Set-StrictMode -Version ([Version]::new(3, 0))

class TestClass {
    [string]$String1
    [string]$String2
    [string]$String3

    [void]DoWork() {
        $this.String3 = $this.String1 + $this.String2
    }

    TestClass([string]$string1, [string]$string2) {
        $this.String1 = $string1
        $this.String2 = $string2
    }
    TestClass([PSObject]$ClassObject) {
        $this.String1 = $ClassObject.String1
        $this.String2 = $ClassObject.String2
        $this.String3 = $ClassObject.String3
    }
}

<#
    .DESCRIPTION
        This function adds using statements, functions, filters and types to ScriptBlocks to be used for remote access.

    .PARAMETER ScriptBlock
        The ScriptBlock to be processed. Mandatory.

    .PARAMETER Namespace
        The list of namespaces to add. 'default' adds any namespaces listed in the root script's using statements. Alternatively or additionally, 
        any other namespaces can be added. These have to be fully qualified. The statement 'using namespace' must not be prefixed. 
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.

    .PARAMETER Module
        The list of PowerShell modules to add. 'default' adds any module listed in the root script's using statements. Alternatively or additionally, 
        any other module can be added. The value of the argument can be a module name, a full module specification, or a path to a module file.
        When it is a path, the path can be fully qualified or relative. A relative path is resolved relative to the script that contains the using statement. 
        The modules referenced by path must be located identically in the file systems of the calling site and the remote site.
        The statement 'using namespace' must not be prefixed. 
        When it is a name or module specification, PowerShell searches the PSModulePath for the specified module.
        A module specification is a hashtable that has the following keys:
            - ModuleName - Required, specifies the module name.
            - GUID - Optional, specifies the GUID of the module.
            - It's also required to specify at least one of the three below keys.
            - ModuleVersion - Specifies a minimum acceptable version of the module.
            - MaximumVersion - Specifies the maximum acceptable version of the module.
            - RequiredVersion - Specifies an exact, required version of the module. This can't be used with the other Version keys.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.
    
    .PARAMETER Assembly
        The list of .NET assemblies to add. 'default' adds any assembly listed in the root script's using statements. Alternatively or additionally, 
        any other assembly can be added. The value can be a fully qualified or relative path. A relative path is resolved relative to the script that 
        contains the using statement. The assemblies referenced must be located identically in the file systems of the calling site and the remote site.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.

    .PARAMETER Type
        The list of names from types defined by the root script to add to the processed script.  
        The type definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedTypes comment.
        Defaut is an empty list.

    .PARAMETER Function
        The list of names from functions or filters defined by the root script to add to the processed script.  
        The function definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedFunctions comment.
        Defaut is an empty list.

    .PARAMETER SearchNestedScriptBlocks
        If this parameter is set, ScriptBlocks contained in the root script are also searched for functions, filters and types, otherwise only the root 
        script itself.

    .EXAMPLE
        In this example the namespaces used by the root script and two additional using namespace statements are added to $myScriptBlock.
        One type and two functions, defined by the root script, are also added:

        $myScriptBlock | Edit-RemoteScript `
            -Namespace 'default', 'System.Collections', 'System.Collections.Generic' `
            -Type 'MyType' `
            -Function 'ConvertTo-MyType', 'ConvertFrom-MyType'

    .NOTES
        Because the using statements must come before any other statement in a module and no uncommented statement can precede them, including parameters, 
        one cannot define any using statement in a nested ScriptBlock. Therefore, the only alternative to post-inserting the using statements into a 
        previously defined ScriptBlock, as is done in this function, is to define $myScript as a string and create the ScriptBlock using [ScriptBlock]::Create($myScript). 
        But then you lose syntax highlighting and other functionality of the IDE used.

        An alternative way of including functions, filters and types that are used in both, the root script and the remote script, in the latter is shown in 
        the links below. An alternative to post-insertion would be to redefine these functions, filters, and types in the remote script. However, the downside 
        is that changes to the code have to be kept in sync in different places, which reduces its maintainability. 

    .LINK 
        this function:
        https://mcmap.net/q/15989/-how-do-i-pass-a-class-object-in-a-argument-list-to-a-another-computer-and-call-a-function-on-it
    
    .LINK 
        alternative for types:
        https://mcmap.net/q/15989/-how-do-i-pass-a-class-object-in-a-argument-list-to-a-another-computer-and-call-a-function-on-it

    .LINK 
        alternative for functions:
        https://mcmap.net/q/16217/-powershell-invoke-command-argumentlist-with-optional-argument
#>
function Edit-RemoteScript {

    [CmdletBinding()]
    [OutputType([ScriptBlock])]
    [SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', 'functionText', Justification = "Doesn't apply")]

    param(
        [Parameter(Mandatory, ValueFromPipeline)] [ScriptBlock]$ScriptBlock,
        [Parameter()] [AllowEmptyCollection()] [String[]]$Namespace = @(),
        [Parameter()] [AllowEmptyCollection()] [ModuleSpecification[]]$Module = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Assembly = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Type = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Function = @(),
        [Parameter()] [Switch]$SearchNestedScriptBlocks
    )

    begin {
        [Ast]$cmdletAst = $MyInvocation.MyCommand.ScriptBlock.Ast
        do {
            [Ast]$tempAst = $cmdletAst.Parent
        } while ($null -ne $tempAst -and ($cmdletAst = $tempAst))
        [String[]]$remoteUsings = @()
        [String[]]$remoteTypes = @()
        [String[]]$remoteFunctions = @()
    } process {
        if (($Namespace -or $Module -or $Assembly) -and -not $remoteUsings) {
            if ('default' -iin $Namespace -or
                'default' -iin $Assembly -or (
                    $Module | Where-Object -Property 'Name' -EQ -Value 'default' | Select-Object -First 1
                )
            ) {
                [UsingStatementAst[]]$allUsings = @($cmdletAst.FindAll({ $args[0] -is [UsingStatementAst] }, $false))
            }
            $remoteUsings = @(
                @(
                    @{
                        Kind  = [UsingStatementKind]::Namespace
                        Names = $Namespace
                    },
                    @{
                        Kind  = [UsingStatementKind]::Module
                        Names = $Module
                    },
                    @{
                        Kind  = [UsingStatementKind]::Assembly
                        Names = $Assembly
                    }
                ) | ForEach-Object -Process { 
                    [UsingStatementKind]$kind = $_.Kind
                    $_.Names | ForEach-Object -Process {
                        if (($kind -eq [UsingStatementKind]::Module -and $_.Name -ieq 'default') -or ($kind -ne [UsingStatementKind]::Module -and $_ -ieq 'default')) {
                            @($allUsings | Where-Object -Property 'UsingStatementKind' -EQ -Value $kind | ForEach-Object -Process { $_.ToString() })
                        } else {
                            if ($kind -eq [UsingStatementKind]::Assembly) {
                                "using $( $kind.ToString().ToLowerInvariant() ) '$_'"
                            } else {
                                "using $( $kind.ToString().ToLowerInvariant() ) $_"
                            }
                        }
                    }
                }
            )
        }
        if ($Type -and -not $remoteTypes) {
            $remoteTypes = @(
                $cmdletAst.FindAll({ $args[0] -is [TypeDefinitionAst] }, $SearchNestedScriptBlocks) | 
                    Where-Object -Property 'Name' -In $Type | 
                    ForEach-Object -Process { $_.ToString() }
            )
        }
        if ($Function -and -not $remoteFunctions) {
            $remoteFunctions = @(
                if ($SearchNestedScriptBlocks) {
                    # this is slower
                    $cmdletAst.FindAll({
                            param(
                                [Parameter()] [Ast]$Ast
                            )
                            <#
                                Class methods have a FunctionDefinitionAst under them as well, but we don't want them.
                                from: https://mcmap.net/q/16218/-get-all-functions-in-a-powershell-script
                            #>
                            $Ast -is [FunctionDefinitionAst] -and $Ast.Parent -isnot [FunctionMemberAst]
                        },
                        $true) |
                        Where-Object -FilterScript {
                            $_.Name -iin $Function
                        } |
                        ForEach-Object -Process { $_.ToString() }
                } else {
                    # this is faster
                    Get-ChildItem -Path 'Function:' |
                        Where-Object -Property 'Name' -In $Function |
                        ForEach-Object -Process {
                            if ($_.CommandType -eq [CommandTypes]::Filter) {
                                "filter $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            } else {
                                "function $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            }
                        }
                }
            )
        }
        [ScriptBlock]::Create($ScriptBlock.ToString(). `
                Replace('#ImportedUsings', $remoteUsings -join "`n"). `
                Replace('#ImportedTypes', $remoteTypes -join "`n"). `
                Replace('#ImportedFunctions', $remoteFunctions -join "`n"))
    } end {
    }
}

function TestFunction {
    42
}

$ComputerName = 'Server1'
[TestClass]$obj = [TestClass]::new('1', '2')
[ScriptBlock]$testScript = {
    #ImportedUsings # the imported using statements will be inserted here

    Set-StrictMode -Version ([Version]::new(3, 0))

    #ImportedTypes # the imported types will be inserted here
    #ImportedFunctions # the imported functions will be inserted here

    $obj = $args[0]
    [ArrayList]$results = @() # using statements are working remotely
    [TestClass]$castedObj = [TestClass]$obj # the type is known remotely

    [void]$results.Add('')
    [void]$results.Add('* * * remote * * *')
    [void]$results.Add((TestFunction)) # the function is known remotely
    $castedObj.DoWork() # the type has his functionality remotely
    [void]$results.Add($castedObj.String3)
    [void]$results.Add((Get-Member -InputObject $obj))
    [void]$results.Add((Get-Member -InputObject $castedObj))
    [void]$results.Add('')
    [void]$results.Add($castedObj)
    [void]$results.Add([TestClass]::new('3', '4'))
    $results
}
$testScript = $testScript | Edit-RemoteScript -Namespace 'default' -Type 'TestClass' -Function 'TestFunction'
$credentials = Get-Credential

'* * * local * * *'
TestFunction
Get-Member -InputObject $obj

$results = Invoke-Command -ComputerName $ComputerName -Credential $credentials -ArgumentList ([TestClass]$obj) -ScriptBlock $testScript
foreach ($ctr in 0..6) {
    $results[$ctr]
}
[TestClass]$resultObj = $results[7] # also returned objects can be casted back to the original type
"this is the original instance, DoWork() is already done, String3 = '$( $resultObj.String3 )'"
$resultObj = $results[8]
"this is a new instance, DoWork() isn't done yet, String3 = '$( $resultObj.String3 )'"
$resultObj.DoWork()
"... but now, String3 = '$( $resultObj.String3 )'"

Output:

* * * local * * *
42

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}

* * * remote * * *
42
12

   TypeName: Deserialized.TestClass

Name     MemberType Definition
----     ---------- ----------
GetType  Method     type GetType()
ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatPro… 
String1  Property   System.String {get;set;}
String2  Property   System.String {get;set;}
String3  Property    {get;set;}

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}

this is the original instance, DoWork() is already done, String3 = '12'
this is a new instance, DoWork() isn't done yet, String3 = ''
... but now, String3 = '34'

In this case it is certainly a big overhead and it would actually be easier to re-define TestClass. In larger projects with complex classes, however, the procedure may worthwhile. Another advantage: there is no longer any need to synchronize functions and classes that have been declared multiple times when changes are made.

If you are working with a PSSession in which several remote calls are passed one after the other, it may even be worthwhile to have a script executed remotely first that is used exclusively for the declarations. Then a specific typed parameter type TestClass can be used instead of Object or PSObject because type TestClass is already known when the script is invoked. A casting of the parameter can be ommitted in this case:

[ScriptBlock]$TestScript = {
    param([Parameter()] [TestClass]$Obj)
    ....
    $Obj.DoWork() # the type has his functionality remotely
    [void]$results.Add($Obj.String3)
    ...
}

Edit 1: a small correction of the function code and inserted usefull links

Edit 2: suggested by @mklement0 's answer: making the function more universal; a comment-based help has also been added

Edit 3: clarification and small correction regarding casting operators

Hypotaxis answered 15/7, 2023 at 18:52 Comment(9)
This solution is incredible, thank you for adding this. I wish I could mark both of these as answers. If I were doing this again today, I would use the solution you used for sure. Having the class definition as a string, like in the previous answer, was horrible to maintain and made me very reluctant to add things to the class definition.Ellipticity
@MaxYoung: Thank you for your enthusiastic response! I have supplemented my post with two useful sources. I'm surprised I couldn't find anything similar online. But maybe I just googled the wrong keywords ;-)Hypotaxis
Kudos for an attempt at a comprehensive solution. While it works for using namespace statements, it isn't guaranteed to work for using assembly and using module statements - and adding robust support for these is all but impossible. As an aside: you don't need a constructor with a [PSObject] parameter; a parameter-less constructor will do, relying on PowerShell's built-in object-initialization features.Lumumba
@MaxYoung, I agree that the Invoke-Expression solution wasn't great. I've updated my answer to show a solution using a script block - for simple, self-contained class definitions this should do (a similar approach works for functions). If you need support for class-external using namespace statements, use Olli's answer.Lumumba
@mklement0: Many thanks for the helpful comment. I have no personal experience with using module and using assembly. Nevertheless, I tried to create a workaround. In addition, the function is now more versatile.Hypotaxis
@mklemen0: As for the suggestion of using a parameterless constructor, I think that might obscure functionality a bit.Hypotaxis
@mklement0: Regarding the alternative ways of inserting types and functions into scripts: with the function I suggested, the entire functionality is encapsulated in one place and, in my opinion, the application is significantly simplified as a result. Another disadvantage of the alternative way shown: If Type1 and Type2 are used in the remote script, but not Type3, and furthermore Type2 is used in Type1 and Type3, Type3 must also be defined in the dot-sourced ScriptBlock in order to work in the calling script. This makes the code less transparent and more complicated.Hypotaxis
To be clear: Your solution is undoubtedly more complete and encapsulated (you already had my +1), so it comes down to a tradeoff between implementation effort and scope of the required functionality. I was wrong about the parameterless constructor: since your example class already has an explicit constructor in your example, you need another explicit constructor to support initialization by another object. This makes the solution a bit less modular, as you then have to design your classes with that in mind, but that cannot be helped.Lumumba
The added support for using assembly and using module is commendable, but note that there's no guarantee that the targeted assemblies and modules will be available in the remote session. As an aside that may be of interest: In script-block literals you aren't actually permitted to use using statements, but [scriptblock]::Create() allows it - see GitHub issue #19985. Finally, thanks for the tip re Show-Ast - very useful; worth mentioning that it works on Windows only (it is WinForms-based).Lumumba

© 2022 - 2024 — McMap. All rights reserved.