How To Compose A PowerShell Module With Multiple Script Files

The internet's missing how-to guide!

[5 minute read]

Introduction

All I wanted was something I take for granted as a C# developer: the ability to create a PowerShell module that’s composed of multiple script files - one for each function - instead of putting every function in a single .psm1 file. It’s actually a simple thing to accomplish, and yet it’s unbelievably difficult to find a tutorial that explains how to do it. This article seeks to fill that void and save future googlers all the time I sunk into research, trial, and error.

My Requirements

  1. Create a PowerShell module that users can install in a single command.
  2. Each of the module’s functions should be written in its own .ps1 file.
  3. The module’s commands should automatically import into the user’s PowerShell session. Users should never have to call Import-Module manually.

The Solution

The first requirement is easily solved by creating a Chocolatey package. There’s tons of information already online that describes how to do this, so I won’t go into too many details here. In short, you create a basic nuspec manifest file in the directory containing your PowerShell scripts, call choco pack <path_to_nuspec>, and then upload the resulting package to a nuget feed somewhere. Your users can then simply run choco install <your_package_name>. Done. This is what my nuspec looks like:

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
  <metadata>
    <id>posh-mssp</id>
    <version>$version$</version>
    <title>posh-mssp</title>
    <authors>Nick Spreitzer</authors>
    <owners>Company Name, Inc</owners>
    <description>A collection of PowerShell functions.</description>
    <projectUrl>https://source-control-url.com/posh-mssp</projectUrl>
  </metadata>
</package>

Note: Notice that I used the token $version$ for the version element. That’s so you can set the package’s version number dynamically like this: choco pack <path_to_nuspec> --version 1.2.3. This is not required; you are free to hardcode the version if you so desire. See the nuspec reference documentation for more information.

Two things are necessary in order to satisfy the final requirement. First, you need to create a PowerShell Module Manifest (.psd1) file. Second, you need to install the module to a location that’s included in the $PSModulePath environment variable.

> The Module Manifest

Click here for more information on PowerShell Module Manifests than you could ever want to know. Here’s the manifest I created for my module, posh-mssp:

@{
  ModuleVersion = '1.0'
  GUID = '0409a829-54da-4c23-a071-8e66570eb84c'
  NestedModules = @(
    '.\functions\Get-PoshMsspCommands.ps1', 
    '.\functions\Get-RepoStatuses.ps1', 
    '.\functions\Reset-Repos.ps1', 
    '.\functions\Set-BuildScriptVersions.ps1',
    '.\functions\Start-MsspService.ps1',
    '.\functions\Stop-MsspService.ps1',
    '.\functions\Update-MsspNugetPackages.ps1'
  )
  FunctionsToExport = @('*')
}

For the purposes of composing a module from any number of script files, all the magic lies in the NestedModules and FunctionsToExport elements. The former is a list of all the script files you want to include and the latter describes which functions from the scripts to export. In this case, I’m exporting all functions - hence the wildcard. The script file paths can be either relative to the root directory or absolute. I’m not sure whether ModuleVersion and GUID are strictly required elements, but I included them anyway because I want to version my module and I figured a unique identifier might come in handy for reasons unknown. (Developer’s intuition?) One last important note: the base name of your Module Manifest file must match the name of its parent directory. So in my case, I have a directory structure like this:

RootDirectory
|   .gitignore
|   readme.md
|
+---posh-mssp
|   |   chocolateyinstall.ps1
|   |   chocolateyuninstall.ps1
|   |   posh-mssp.nuspec
|   |   posh-mssp.psd1
|   |
|   \---functions
|           Get-PoshMsspCommands.ps1
|           Get-RepoStatuses.ps1
|           Reset-Repos.ps1
|           Set-BuildScriptVersions.ps1
|           Start-MsspService.ps1
|           Stop-MsspService.ps1
|           Update-NugetPackages.ps1

You can see that the name of my module manifest (posh-mssp.psd1) matches the name of its parent directory. That’s important if you want your module to import automatically.

> $PSModulePath

$PSModulePath is just an environment variable that contains a list of semicolon delimited paths where PowerShell will look for modules when new sessions are created. (More info here.) When PowerShell starts up, it automatically imports any modules it finds in one of these locations - provided they follow a few rules, such as the naming convention described above. You can either install your module in one of the default module locations, or you can modify the value of $PSModulePath. For posh-mssp, I chose the latter because it simplified my Chocolatey installation scripts. All they do is append my package’s path to $PSModulePath on installation, and remove it from $PSModulePath on uninstallation.

For the uninitiated, chocolatey un/install scripts are not required for all packages. However, for the purposes of of this example, they are necessary. Even if I opted to install my module to a default $PSModulePath location, I’d still need scripts to copy the module files to a default location on installation and delete them on uninstallation. That seemed pretty silly, which is why I decided to add a new location to $PSModulePath instead.

chocolateyinstall.ps1

$p = [Environment]::GetEnvironmentVariable("PSModulePath", [System.EnvironmentVariableTarget]::Machine)
if ($p.Contains($($env:ChocolateyPackageFolder))) { return }
$p += ";$($env:ChocolateyPackageFolder)"
[Environment]::SetEnvironmentVariable("PSModulePath", $p, [System.EnvironmentVariableTarget]::Machine)

chocolateyuninstall.ps1

$p = [Environment]::GetEnvironmentVariable("PSModulePath", [System.EnvironmentVariableTarget]::Machine)
$p = $p.Replace(";$($env:ChocolateyPackageFolder)", "")
[Environment]::SetEnvironmentVariable("PSModulePath", $p, [System.EnvironmentVariableTarget]::Machine)

Summary

  • Create a nuspec file.
  • Create a module manifest file that lists the paths to your script files and indicates which functions to export.
  • Ensure the manifest file has the same name as its parent directory.
  • Add chocolatey un/install scripts to modify the $PSModulePath variable.
  • Pack it up and upload to a nuget feed.
  • You’re done.

That’s really all there is to it. Users will then be able to install the module with a single command. The only other thing to be aware of is that after installation, users will have to open a new PowerShell session before the installed module is automatically imported.

Trade-offs and Other Options

The main disadvantage to the approach I just described is that you have to explicitly list out all the scripts you want to include in your module manifest. I really wish the NestedModules element accepted wildcards so I could include any number of script files with a single statement like .\functions\*.ps1. I tried; didn’t work.

That said, there is another alternative. If you’re willing to put all your functions in a single .psm1 file, you can completely forgo the module manifest file and still get automatic, implicit function importing. Literally, the only thing you have to do differently from what I described above is put all your functions in a single .psm1 file and omit the .psd1 file. The Chocolately un/install scripts remain unchanged.

Personally, I don’t think putting potentially thousands of lines of code in a single file makes any structural sense whatsoever, but that’s just my opinion.