PowerShell

Making PowerShellGallery modules Portable

I'm a big fan of PowerShellGallery. It's easy to use, Microsoft owned, a place to host your PowerShell modules. Every time I release a new PowerShell module, it's readily available for me or anyone with a single command Install-Module. No need to host it yourself, no need to prepare anything – plug & play. Additionally, if your PowerShell module has any dependencies, it will download and install them, so it directly works out of the box. But what if you can't use PowerShellGallery? What if you don't want to use Install-Module on 100 computers, but you prefer to do it in a more controlled way? What if your servers do not have internet connectivity?

Making PowerShellGallery modules Portable

I usually would use GitHub sources and build it from that. This seems relatively easy till you find out your PowerShell module has seven other dependencies that you need to download and use. Additionally, if you download my modules from GitHub, they don't have any optimization that I usually do when publishing them to PowerShellGallery. Fortunately, there's a way to make PowerShellGallery modules portable. Below is a simple function that when given Module Name will download the module we requested with all it's dependencies to the specified folder. After that, it will import all those modules to memory, making it available to use.

function Initialize-ModulePortable {
    [CmdletBinding()]
    param(
        [alias('ModuleName')][string] $Name,
        [string] $Path = $PSScriptRoot,
        [switch] $Download,
        [switch] $Import
    )
    function Get-RequiredModule {
        param(
            [string] $Path,
            [string] $Name
        )
        $PrimaryModule = Get-ChildItem -LiteralPath "$Path\$Name" -Filter '*.psd1' -Recurse -ErrorAction SilentlyContinue -Depth 1
        if ($PrimaryModule) {
            $Module = Get-Module -ListAvailable $PrimaryModule.FullName -ErrorAction SilentlyContinue -Verbose:$false
            if ($Module) {
                [Array] $RequiredModules = $Module.RequiredModules.Name
                if ($null -ne $RequiredModules) {
                    $null
                }
                $RequiredModules
                foreach ($_ in $RequiredModules) {
                    Get-RequiredModule -Path $Path -Name $_
                }
            }
        } else {
            Write-Warning "Initialize-ModulePortable - Modules to load not found in $Path"
        }
    }

    if (-not $Name) {
        Write-Warning "Initialize-ModulePortable - Module name not given. Terminating."
        return
    }
    if (-not $Download -and -not $Import) {
        Write-Warning "Initialize-ModulePortable - Please choose Download/Import switch. Terminating."
        return
    }

    if ($Download) {
        try {
            if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
                $null = New-Item -ItemType Directory -Path $Path -Force
            }
            Save-Module -Name $Name -LiteralPath $Path -WarningVariable WarningData -WarningAction SilentlyContinue -ErrorAction Stop
        } catch {
            $ErrorMessage = $_.Exception.Message

            if ($WarningData) {
                Write-Warning "Initialize-ModulePortable - $WarningData"
            }
            Write-Warning "Initialize-ModulePortable - Error $ErrorMessage"
            return
        }
    }

    if ($Download -or $Import) {
        [Array] $Modules = Get-RequiredModule -Path $Path -Name $Name | Where-Object { $null -ne $_ }
        if ($null -ne $Modules) {
            [array]::Reverse($Modules)
        }
        $CleanedModules = [System.Collections.Generic.List[string]]::new()

        foreach ($_ in $Modules) {
            if ($CleanedModules -notcontains $_) {
                $CleanedModules.Add($_)
            }
        }
        $CleanedModules.Add($Name)

        $Items = foreach ($_ in $CleanedModules) {
            Get-ChildItem -LiteralPath "$Path\$_" -Filter '*.psd1' -Recurse -ErrorAction SilentlyContinue -Depth 1
        }
        [Array] $PSD1Files = $Items.FullName
    }
    if ($Download) {
        $ListFiles = foreach ($PSD1 in $PSD1Files) {
            $PSD1.Replace("$Path", '$PSScriptRoot')
        }
        # Build File
        $Content = @(
            '$Modules = @('
            foreach ($_ in $ListFiles) {
                "   `"$_`""
            }
            ')'
            "foreach (`$_ in `$Modules) {"
            "   Import-Module `$_ -Verbose:`$false -Force"
            "}"
        )
        $Content | Set-Content -Path $Path\$Name.ps1 -Force
    }
    if ($Import) {
        $ListFiles = foreach ($PSD1 in $PSD1Files) {
            $PSD1
        }
        foreach ($_ in $ListFiles) {
            Import-Module $_ -Verbose:$false -Force
        }
    }
}

It's effortless but at the same time very useful. Updating your disconnected environment is much easier this way. You can quickly test something and remove a folder from your machine without having it installed.

Initialize-ModulePortable -Name 'Testimo' -Download -Path $PSScriptRoot

As you can see on the screenshot above it looks like the command is Installing the module, but it's using Save-Module to download module to a given directory. After that, it creates a new PS1 file and puts it into the primary folder. The file name is identical to the module name. The file has logic to read module PSD1 file, check for RequiredModules section and import them in the order given in PSD1. Finally, script imports requested module.

If we check directory, we can see it downloaded nine modules to that folder. You can also see the file named Testimo.ps1 which, when executed, will Import all those modules in hopefully proper order. That means you can then copy that folder anywhere, execute with PS1 file that does all the hard work for you. After that all commands become available and you can use them as you would do after standard installation. Keep in mind that I've tested this on limited number of modules. It's possible I've skipped something when writting this. Current development version is located on GitHUB as part of PSSharedGoods module. Feel free to submit bugs if you find one.

This post was last modified on September 17, 2019 06:39

Przemyslaw Klys

System Architect with over 14 years of experience in the IT field. Skilled, among others, in Active Directory, Microsoft Exchange and Office 365. Profoundly interested in PowerShell. Software geek.

Share
Published by
Przemyslaw Klys

Recent Posts

Active Directory Replication Summary to your Email or Microsoft Teams

Active Directory replication is a critical process that ensures the consistent and up-to-date state of…

2 weeks ago

Syncing Global Address List (GAL) to personal contacts and between Office 365 tenants with PowerShell

Hey there! Today, I wanted to introduce you to one of the small but excellent…

5 months ago

Active Directory Health Check using Microsoft Entra Connect Health Service

Active Directory (AD) is crucial in managing identities and resources within an organization. Ensuring its…

7 months ago

Seamless HTML Report Creation: Harness the Power of Markdown with PSWriteHTML PowerShell Module

In today's digital age, the ability to create compelling and informative HTML reports and documents…

8 months ago

How to Efficiently Remove Comments from Your PowerShell Script

As part of my daily development, I create lots of code that I subsequently comment…

9 months ago

Unlocking PowerShell Magic: Different Approach to Creating ‘Empty’ PSCustomObjects

Today I saw an article from Christian Ritter, "PowerShell: Creating an "empty" PSCustomObject" on X…

9 months ago