Creating standalone PowerShell scripts – Automatically merging module components into scripts

The Problem
Organizing code in modules and re-using the same functions in different scripts is a common and recommended practice when working with PowerShell. I do have a lot of different modules created and grown over time that cover different aspects and that I use regularly in my daily work.
However on the other side, I also often need standalone scripts. What I mean by this are scripts that don’t reference any module or at least only modules available on default. If the scripts are used for automation purposes outside of my local computer, I would otherwise need to make sure that the module is available on all the machines as well. Using PowerShell within e.g. System Center Orchestrator or SMA runbook or as a step in a task sequence in SCCM/ConfigMgr, it often is a benefit to NOT have a reference to one or even several custom modules. Or if you need to share your script with someone, you would also need to share all the modules. Not to mention that it’s enough for most people to “just” call the sript. They don’t want to mess around with copying the modules to their profile etc.
Due to this, I often ended up copying the referenced functions from the modules into the script before I could make them available. Which isn’t a good practice at all, as now you have to maintain several copies of the same function.
The Solution
As we are talking about PowerShell, we are also talking about automation. So there must be an automated way on solving this. And yes there is. Now ๐
I wrote a script that will analyse a given script for all function calls. Those will be compared to the functions from a supplied list of modules. Theoretically it could simply use a list of all imported modules, but as that could have negative sideeffects I preferred to define what modules to use. The functions from those modules that are called from the script will be copied to the script, typically to the beginning of either the script or the Begin block. On script based modules this will even include “hidden” functions, meaning functions that aren’t explicitly exported. Then the script will be analyzed again as there might now be additional functions being called from the functions that have been copied, etc. This will be executed recursively until all functions have been copied (or the maximum iteration level has been reached).
If you are a bit impatient, you can find the full script that I published on GitHub: https://github.com/MaikKoster/Common/blob/master/Tools/Create-StandaloneScript.ps1
For the rest, I’ll give you some more details on how to use it first and then some more details on what it is doing under the hoods.
How to use it
The script comes with proper (well, at least some) documentation. So calling the default
1 |
Get-Help .\Create-StandaloneScript.ps1 -detailed |
should give you a start.
It has two mandatory parameters
- Path : which takes the path to the script that shall be converted. You can also supply an array of scripts.
- Module : which takes a list of PowerShell modules. I preferred to explicitly specify the modules that are integrated into the script, rather than integrating all imported modules. If there is a need to this functionality, please feel free to update the script and/or get back to me.
So the call for a script that I’m currently working on is as easy as
1 |
.\Create-StandaloneScript.ps1 -Path "C:\Working\ConfigMgr\TaskSequence\Import-TaskSequence.ps1" -Module "ConfigMgr" |
On default, it creates a copy of the script in a subfolder called “Standalone“. If you want to change the name of that folder, use the “Subfolder” parameter. There is no option to use a different name for the script like adding a suffix or similar, as I preferred to have them exchangeable. You know what to do if you feel the urge to get that changed.
1 |
.\Create-StandaloneScript.ps1 -Path "C:\Working\ConfigMgr\TaskSequence\Import-TaskSequence.ps1" -Module "ConfigMgr" -Subfolder "Export" |
The functions from the module(s) will be added to either the beginning of the script right after the parameter definition (if there is any), or the beginning of the “Begin” block of the script (see Advanced Functions). As they have to be parsed first before they can be used, that was best place to put them without knowing any details about the script itself. I personally prefer to use the “Begin” block in a script to define my functions and then use the “Process” block for the, well, processing. Using the “Block” parameter, you can specify a different block like “Process” or “End“, but that’s mainly for sake of completeness. ๐
Finally the “MaxIterations” parameter allows you to define how many recursive calls shall be processed at a maximum. The default is 10 and I personally haven’t had a script with more than 5 iterations yet.
On default, the script won’t show any message or progress. It will raise Errors if something fails, but as it’s meant for automation, there is no need for text output (Stop using Write-Host!). If you want to see a result, use the “PassThru” switch and it will return the path to the new script. Or use the “Verbose” switch to enable verbose logging.
1 2 3 |
.\Create-StandaloneScript.ps1 -Path "C:\Working\ConfigMgr\TaskSequence\Import-TaskSequence.ps1" -Module "ConfigMgr" -PassThru C:\Working\ConfigMgr\TaskSequence\Standalone\Import-TaskSequence.ps1 |
Under the hoods
The script uses the System.Management.Automation.Language.Parser class to parse the script into an Abstract Syntax Tree. This AST is then used to get a list of all functions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
$AST = [System.Management.Automation.Language.Parser]::ParseFile($OriginalScript, [ref]$null, [ref]$null) $ASTCommands = $AST.FindAll({$args[0] -is [System.Management.Automation.Language.CommandAst]}, $true) Foreach ($ASTCommand in $ASTCommands) { $Command = $ASTCommand.CommandElements[0] if ($Command.Value -ne $null) { if (!($ScriptCommands.ContainsKey($Command.Value))) { # Check if it's a Module command if ($ModuleCommands | Where-Object {$_.Name -eq $Command}) { Write-Verbose "Found new command '$Command'." $ScriptCommands.Add($Command.Value, $false) } } } } |
For further details on how to use this class check Bartek Bielawskis article on the Scripting Guy blog.
Getting the definition of basically any function is pretty easy. Simply use the Get-Command CmdLet from PowerShell. The object that is returned from this has a property called “Definition“, which contains the function definition. This is primarily useful for script based modules, but does work on some built-in modules as well. e.g. the Get-IseSnippet from the “ISE” module is one of the shortest I could find for demonstration purposes:
1 2 3 4 5 6 7 8 9 10 |
(Get-Command -Name Get-IseSnippet).Definition [CmdletBinding()] [OutputType([System.IO.FileInfo])] param() $snippetPath = Join-Path (Split-Path $profile.CurrentUserCurrentHost) "Snippets" if (Test-Path $snippetPath) { dir $snippetPath } |
As you can see, you just need to put around the function header and footer and have a fully working function:
1 2 3 4 5 6 7 8 9 10 |
function Get-IseSnippet { [CmdletBinding()] [OutputType([System.IO.FileInfo])] param() $snippetPath = Join-Path (Split-Path $profile.CurrentUserCurrentHost) "Snippets" if (Test-Path $snippetPath) { dir $snippetPath } } |
As mentioned, this won’t work for most built-in modules. Also Get-Command will actually not return anything on “hidden” functions. These are internal functions of the module, that are not exported and are not supposed to be called outside of the module. However as the functions that we copy might call those internal functions, we simply have to copy them as well. To be able to get access to those internal functions, I’m using a small hack and call the Get-Command inside of the the module context:
1 2 3 4 5 6 7 |
$TempModule = Import-Module $Module -Force -PassThru if ($TempModule.ModuleType -eq "Script") { $GetCmd= [scriptblock]::Create("Get-Command -Module $Module") $ModuleCommands += & $TempModule $GetCmd } else { $ModuleCommands += Get-Command -Module $Module } |
The trick is, to use the PassThru switch on the Import-Module CmdLet, which returns an object that represents the imported module. Then we prepare a Get-Command statement as ScriptBlock and execute it “inside” of the module object.
That’s already most of the heavy lifting. For more details, I encourage you to have a look into the script itself at https://github.com/MaikKoster/Common/blob/master/Tools/Create-StandaloneScript.ps1. It’s (at least partly) commented and documented ๐
If you like the script or have any feedback, please feel free to comment or get back to me.
Recent Comments