Creating a PowerShell Module

In this post, I walk you through the process I follow when creating a PowerShell module and the reasoning behind doing so.
Creating a PowerShell Module
In: PowerShell, Code

What is a PowerShell Module?

about Modules - PowerShell
Explains how to install, import, and use PowerShell modules.

Put briefly, a PowerShell module — similar to a Python module — is a way to organize a collection of related code, variables, aliases, etc. in a contained workspace.

There are two types of PowerShell modules:

  • Script modules (PowerShell scripts)
  • Binary modules (compiled DLLs)
ℹ️
You can intermingle both scripts and compiled DLLs in a single module if the need arises, as there are performance benefits and other enhancements to running compiled code.





Module Loading Paths

If you inspect this PowerShell environment variable, you can see from which folders your PowerShell modules will be loaded from.

$env:PSModulePath

PSModulePath environment variable

If you install a module from PowerShell Gallery, you can use the Scope parameter to determine which folder in $env:PSModulePath the module will be downloaded.

# Local User
# Install the module to C:\Users\user.name\Documents\WindowsPowerShell\Modules\ on Windows
# Install the module to /home/user.name/.local/share/powershell/Modules on Linux
Install-Module -Name <module-name> -Scope CurrentUser

# Global Module
# Install the module to C:\Program Files\WindowsPowerShell\Modules on Windows
# Install the module to /usr/local/share/powershell/Modules on Linux
Install-Module -Name <module-name>

Different ways to install modules from a network repository

Why mention this?

Because, you can place your module on the operating system using this same hierarchy. If you want the module to be available globally to all users on the system, use the appropriate directory; and vice-versa for local user scope only.





When to Create a Module

In past experiences — for me at least — modules have usually been created due to the following conditions:

  1. Create a script for a distinct purpose or project
  2. Additional scripts continue to be added
    • Adding helper scripts
    • Or, extending the scope of the project
  3. The project scope continues to grow, we need a place to keep things organized
💡
A PowerShell module — like a function — should be fine-tuned to a specific purpose. In other words, if you see your module becoming too monolithic and trying to be a one-size-fits-all tool, you might consider breaking it up into several individual modules.





Scaffolding the Module

Please note that this is the way that I create PowerShell modules, and should not be construed as the only way to create PS Modules.

Module Directory Structure

ModuleName
|___ModuleName.psd1
|___ModuleName.psm1
|___Private
|   |___ps1
|       |___Verb-Noun.ps1
|       |___Verb-Noun.ps1
|
|___Public
    |___ps1
        |___Verb-Noun.ps1
        |___Verb-Noun.ps1

  • ModuleName — Name the module after the project or its intended purpose
  • ModuleName.psd1 — This is the module manifest, we'll cover this later
  • ModuleName.psm1 — This is your script module, we'll cover this later as well
  • Private — This is the directory to store your private functions and variables (eg. helper functions)
    • ps1 — The PowerShell functions are stored here and are not directly referenced by the user
      • Verb-Noun.ps1 — the script file containing the private PowerShell function.
  • Public — This is directory to store your public functions and variables
    • ps1 — The PowerShell function code is stored here and these are the cmdlets the user will be directly running
      • Verb-Noun.ps1 — the script file containing the public PowerShell function





Commands to Create the Module Structure

# Variables
$year = (Get-Date).Year
$moduleName = 'TestModule'
$templateString = 'Test Module'
$version = '1.0'

# Create the "TestModule" top-level directory
New-Item -ItemType Directory -Name $moduleName

# Create subdirectories
#    TestModule
#    |___ ...
#    |___ ...
#    |___Private
#    |   |___ps1
#    |___ ...

New-Item -Path "$PWD\$moduleName\Private\ps1" -ItemType Directory -Force

# Create subdirectories
#    TestModule
#    |___ ...
#    |___ ...
#    |___ ...
#    |___Public
#        |___ps1

New-Item -Path "$PWD\$moduleName\Public\ps1" -ItemType Directory -Force

# Create the script module
#    TestModule
#    |___ ...
#    |___ TestModule.psm1

New-Item -Path "$PWD\$moduleName\$moduleName.psm1" -ItemType File

# Create the module manifest
#    TestModule
#    |___TestModule.psd1
#    |___ ...

$moduleManifestParameters = @{
    Path = "$PWD\$moduleName\$moduleName.psd1"
    Author = $templateString
    CompanyName = $templateString
    Copyright = "$year $templateString by Benjamin Heater"
    ModuleVersion = $version
    Description = $templateString
    RootModule = "$moduleName.psm1"
}
New-ModuleManifest @moduleManifestParameters





Inspecting the Manifest

ModuleName
|___ModuleName.psd1
|___ ...
|___ ...

The module manifest in relation to the directory structure

Get-Content "$PWD\TestModule\TestModule.psd1"`

Output the contents of the moudle manifest

Example Module Manifest

#
# Module manifest for module 'TestModule'
#
# Generated by: Test Module
#
# Generated on: 8/17/2023
#

@{

# Script module or binary module file associated with this manifest.
RootModule = 'TestModule.psm1'

# Version number of this module.
ModuleVersion = '1.0'

# Supported PSEditions
# CompatiblePSEditions = @()

# ID used to uniquely identify this module
GUID = '147cfba3-3c6d-4260-aeca-f99aa900cc0c'

# Author of this module
Author = 'Test Module'

# Company or vendor of this module
CompanyName = 'Test Module'

# Copyright statement for this module
Copyright = '2023 Test Module by Benjamin Heater'

# Description of the functionality provided by this module
Description = 'Test Module'

# Minimum version of the Windows PowerShell engine required by this module
# PowerShellVersion = ''

# Name of the Windows PowerShell host required by this module
# PowerShellHostName = ''

# Minimum version of the Windows PowerShell host required by this module
# PowerShellHostVersion = ''

# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# DotNetFrameworkVersion = ''

# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# CLRVersion = ''

# Processor architecture (None, X86, Amd64) required by this module
# ProcessorArchitecture = ''

# Modules that must be imported into the global environment prior to importing this module
# RequiredModules = @()

# Assemblies that must be loaded prior to importing this module
# RequiredAssemblies = @()

# Script files (.ps1) that are run in the caller's environment prior to importing this module.
# ScriptsToProcess = @()

# Type files (.ps1xml) to be loaded when importing this module
# TypesToProcess = @()

# Format files (.ps1xml) to be loaded when importing this module
# FormatsToProcess = @()

# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
# NestedModules = @()

# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
FunctionsToExport = '*'

# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
CmdletsToExport = '*'

# Variables to export from this module
VariablesToExport = '*'

# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
AliasesToExport = '*'

# DSC resources to export from this module
# DscResourcesToExport = @()

# List of all modules packaged with this module
# ModuleList = @()

# List of all files packaged with this module
# FileList = @()

# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
PrivateData = @{

    PSData = @{

        # Tags applied to this module. These help with module discovery in online galleries.
        # Tags = @()

        # A URL to the license for this module.
        # LicenseUri = ''

        # A URL to the main website for this project.
        # ProjectUri = ''

        # A URL to an icon representing this module.
        # IconUri = ''

        # ReleaseNotes of this module
        # ReleaseNotes = ''

    } # End of PSData hashtable

} # End of PrivateData hashtable

# HelpInfo URI of this module
# HelpInfoURI = ''

# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
# DefaultCommandPrefix = ''

}

When we ran the New-ModuleManifest command before, PowerShell added our inputs to a .psd1 file with the above template.

The module manifest is responsible for declaring some important things about our module, including:

  • The RootModule script (eg. TestModule.psm1)
  • The GUID to uniquely identify the module
  • FunctionsToExport for function names that should be executable by the user
  • CmdletsToExport for compiled cmdlets that should be executable by the user
  • VariablesToExport for any module-specific variables
  • AliasesToExport for any aliases defined in your functions or cmdlets
  • And, many more configurations
about Module Manifests - PowerShell
Describes the settings and practices for writing module manifest files.





Inspecting the Script Module

ModuleName
|___ ...
|___ModuleName.psm1
|___ ...

The script module in relation to the directory structure

Get-Content "$PWD\TestModule\TestModule.psm1"

Output the contents of the script module

Script Module I've Created for All My Modules

$directorySeparator = [System.IO.Path]::DirectorySeparatorChar
$moduleName = $PSScriptRoot.Split($directorySeparator)[-1]
$moduleManifest = $PSScriptRoot + $directorySeparator + $moduleName + '.psd1'
$publicFunctionsPath = $PSScriptRoot + $directorySeparator + 'Public' + $directorySeparator + 'ps1'
$privateFunctionsPath = $PSScriptRoot + $directorySeparator + 'Private' + $directorySeparator + 'ps1'
$currentManifest = Test-ModuleManifest $moduleManifest

$aliases = @()
$publicFunctions = Get-ChildItem -Path $publicFunctionsPath | Where-Object {$_.Extension -eq '.ps1'}
$privateFunctions = Get-ChildItem -Path $privateFunctionsPath | Where-Object {$_.Extension -eq '.ps1'}
$publicFunctions | ForEach-Object { . $_.FullName }
$privateFunctions | ForEach-Object { . $_.FullName }

$publicFunctions | ForEach-Object { # Export all of the public functions from this module

    # The command has already been sourced in above. Query any defined aliases.
    $alias = Get-Alias -Definition $_.BaseName -ErrorAction SilentlyContinue
    if ($alias) {
        $aliases += $alias
        Export-ModuleMember -Function $_.BaseName -Alias $alias
    }
    else {
        Export-ModuleMember -Function $_.BaseName
    }

}

$functionsAdded = $publicFunctions | Where-Object {$_.BaseName -notin $currentManifest.ExportedFunctions.Keys}
$functionsRemoved = $currentManifest.ExportedFunctions.Keys | Where-Object {$_ -notin $publicFunctions.BaseName}
$aliasesAdded = $aliases | Where-Object {$_ -notin $currentManifest.ExportedAliases.Keys}
$aliasesRemoved = $currentManifest.ExportedAliases.Keys | Where-Object {$_ -notin $aliases}

if ($functionsAdded -or $functionsRemoved -or $aliasesAdded -or $aliasesRemoved) {

    try {

        $updateModuleManifestParams = @{}
        $updateModuleManifestParams.Add('Path', $moduleManifest)
        $updateModuleManifestParams.Add('ErrorAction', 'Stop')
        if ($aliases.Count -gt 0) { $updateModuleManifestParams.Add('AliasesToExport', $aliases) }
        if ($publicFunctions.Count -gt 0) { $updateModuleManifestParams.Add('FunctionsToExport', $publicFunctions.BaseName) }

        Update-ModuleManifest @updateModuleManifestParams

    }
    catch {

        $_ | Write-Error

    }

}

I use the script module as an initialization script for when the module is first imported. Looking at the code from top to bottom, I'll briefly summarize what the script module is doing.

  1. Dynamically determine the module name using $PSScriptRoot
  2. Assume the .psd1 file shares the same name as the parent directory
    (the .psd1 is invoking this .psm1 script module)
  3. Pull the current module manifest using Test-ModuleManifest, in order to compare and update things as necessary
  4. Discover all .ps1 files in private and public directories and source them in
  5. Loop over each public function and determine if any aliases are defined and export them as module members
  6. Do some comparisons against the current module manifest to see if any functions or aliases have been added or removed, and if so, update the module manifest
💡
You could add additional logic to the .psm1 script module if there are things you'd like your module to do when it's first imported.





Adding Code to the Module

ModuleName
|___ ...
|___ ...
|___Private <--- May not be referenced by user
|   |___ps1 <--- Script files
|       |___Verb-Noun.ps1 <--- Private function 1
|       |___Verb-Noun.ps1 <--- Private function 2
|       |___etc.
|
|___Public  <--- May be referenced by user
    |___ps1 <--- Script files
        |___Verb-Noun.ps1 <--- Public function 1
        |___Verb-Noun.ps1 <--- Public function 2
        |___etc.

The PowerShell code in relation to the directory structure

The primary reason for breaking the code up into Private and Public folders is to keep specific things immutable to the user. We may have private variables and functions that aid in the overall execution of specific tasks, and should not be referenced, changed, or executed by the user.

ℹ️
The examples here are going to be a bit contrived. The intent — however — is to keep this blog post as concise as possible while still providing clear examples of functionality.



Adding a Private Function

We'll add the private function first for clarity's sake. Keep in mind that private functions are generally just helpers, and not directly called by a user.

ModuleName
|___ ...
|___ ...
|___Private 
|   |___ps1 
|       |___Confirm-CoffeeTime.ps1
|       |___ ...
|
|___ ...

Private function in relation to the directory structure

function Confirm-CoffeeTime {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Regular', 'Decaf')]
        [String]$CoffeeType
    )
    
    begin {
        $currentDateTime = Get-Date
        $regularCoffeeHours = 4..20
        $decafCoffeeHours = 0..3 + 21..23
    }
    process {

        if ($CoffeeType -eq 'Regular') {
            
            if ($currentDateTime.Hour -in $regularCoffeeHours) {
                $result = $true
            }
            else {
                $result = $false
            }

        }
        else {
        
            if ($currentDateTime.Hour -in $decafCoffeeHours) {
                $result = $true
            }
            else {
                $result = $false
            }

        }

    }
    end {

        if ($result -eq $true) {
            return "Enjoy your cup of $CoffeeType coffee!"
        }
        else {
            throw "You may not drink $CoffeeType right now!"
        }

    }

}

Note the function name matches the file name 'Confirm-CoffeeTime'

We don't want to expose the Confirm-CoffeeTime function to the user. We simply want the user to run Get-Coffee (see below) and let this function validate the results.





Adding a Public Function

ModuleName
|___ ...
|___ ...
|___ ...
|
|___Public
    |___ps1
        |___Get-Coffee.ps1
        |___ ...

Public function in relation to the directory structure

function Get-Coffee {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateSet('Regular', 'Decaf')]
        [String]$CoffeeType
    )
    
    begin {}
    process {

        try {
            # Call the private function to confirm coffee time
            $confirmation = Confirm-CoffeeTime -CoffeeType $CoffeeType
            Write-Host $confirmation -ForegroundColor Green
        }
        catch {
            throw $_
        }
        
    }
    end {}

}

Note the function name matches the file name 'Get-Coffee'





Importing and Using the Module

What we should expect to happen:

  • Import the module
  • Run Get-Command -Module <module_name>
  • Observe that we have only one user-executable command
Import-Module .\TestModule

Importing our test module

Get-Command -Module TestModule

See which commands are provided by the module

As expected, the only available command is the 'Get-Coffee' command

Perfect! Our public Get-Coffee function is performing as expected — calling the private Confirm-CoffeeTime function and returning stdout or stderr.





Applying this to Real Projects

As mentioned before, the examples above are contrived, and not great at demonstrating how a PowerShell module could be used for real projects. Some examples of how you might create a PowerShell module:

  • API clients
  • CTF tools
  • Hacking tools
  • Forensic tools
  • Diagnostic tools
  • Etc.
⚠️
Don't create more work for yourself!

Creating a duplicate PowerShell module as a learning experience is absolutely fine. However, if there's a module on GitHub or the PS Gallery that meets your needs, use that and consider contributing to the source code if it's open source.

If you see an existing module, but aren't satisfied with it for any reason, that would absolutely be a perfect opportunity to create your own solution.



Some Modules I've Written

GitHub - 0xBEN/PSProxmox: PowerShell module for interfacing with Proxmox API
PowerShell module for interfacing with Proxmox API - GitHub - 0xBEN/PSProxmox: PowerShell module for interfacing with Proxmox API

Good reference for my usage of public/private functions

GitHub - 0xBEN/PSToolbox: PowerShell module containing a set of generally useful tools.
PowerShell module containing a set of generally useful tools. - GitHub - 0xBEN/PSToolbox: PowerShell module containing a set of generally useful tools.

No usage of private functions, just bundling tools





Wrapping Up

I want to emphasize that this post is a demonstration of the way that I create PowerShell modules. It is not the only way to create PowerShell modules.

That said, I do hope that I gave you some ideas on how to get started with creating your own modules, and how you can keep your related code organized.

More from 0xBEN
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to 0xBEN.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.