PSModuleUtils.psm1


<#
.SYNOPSIS
Builds a PowerShell module formatted like the ones located at github.com/thisjustin816.
 
.DESCRIPTION
Builds a PowerShell module formatted like the ones located at github.com/thisjustin816.
- Moves all public functions to a single .psm1 file and all private functions to a private folder.
- Removes any init blocks outside of the function.
- Formats the private function dot sources for the expected folder structure.
- Creates a module manifest.
 
.PARAMETER Name
The name of the module.
 
.PARAMETER Version
The version of the module.
 
.PARAMETER Description
The description of the module.
 
.PARAMETER Tags
The tags for the module.
 
.PARAMETER SourceDirectory
The source directory of the module. Should be a nested directory that doesn't contain and build scripts.
 
.PARAMETER OutputDirectory
The directory to output the .psm1 module and .psd1 manifest.
 
.PARAMETER FixScriptAnalyzer
Whether to fix the ScriptAnalyzer issues.
 
.EXAMPLE
$BuildPSModule = @{
    Name = 'MyModule'
    Version = '1.0.0'
    Description = 'A PowerShell module.'
    Tags = ('PSEdition_Desktop', 'PSEdition_Core')
}
Build-PSModule @BuildPSModule
 
.NOTES
N/A
#>

function Build-PSModule {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')]
    [CmdletBinding()]
    param (
        [String]$Name = 'PSModule',
        [String]$Version = '0.0.1',
        [String]$Description = 'A PowerShell module.',
        [String[]]$Tags = @('PSEdition_Desktop', 'PSEdition_Core', 'Windows'),
        [String]$SourceDirectory = "$PWD/src",
        [String]$OutputDirectory = "$PWD/out",
        [Switch]$FixScriptAnalyzer
    )

    Write-Host -Object 'Building with the following parameters:'
    Write-Host -Object (
        [PSCustomObject]@{
            Name              = $Name
            Version           = $Version
            Description       = $Description
            Tags              = $Tags
            SourceDirectory   = $SourceDirectory
            OutputDirectory   = $OutputDirectory
            FixScriptAnalyzer = $FixScriptAnalyzer
        } | Format-List | Out-String
    )

    Remove-Item -Path $OutputDirectory -Recurse -Force -ErrorAction SilentlyContinue
    $ModuleOutputDirectory = "$OutputDirectory/$Name/$Version"

    $null = New-Item -Path "$ModuleOutputDirectory/$name.psm1" -ItemType File -Force
    $functionNames = @()
    $moduleContent = @()
    Get-ChildItem -Path "$SourceDirectory/public" -Filter '*.ps1' -Exclude '*.Tests.ps1' -File -Recurse |
        ForEach-Object -Process {
            $functionName = $_.BaseName
            Write-Host -Object "Building function $functionName..."

            $functionNames += $functionName
            $functionContent = Get-Content -Path $_.FullName
            $originalFunctionContent = $functionContent

            # Remove any init blocks outside of the function
            $startIndex = (
                $functionContent.IndexOf('<#'),
                $functionContent.IndexOf($functionContent -match "function $functionName")[0]
            ) | Where-Object -FilterScript { $_ -ge 0 } | Sort-Object | Select-Object -First 1
            $functionContent = $functionContent[$startIndex..($functionContent.Length - 1)]

            # Format the private function dot sources for the expected folder structure
            $functionContent = $functionContent -replace '\$PSScriptRoot/\.\./(\.\./)?private', '$PSScriptRoot/private'

            Write-Host -Object (
                Compare-Object -ReferenceObject $functionContent -DifferenceObject $originalFunctionContent |
                    Format-Table |
                    Out-String
            )
            $moduleContent += ''
            $moduleContent += $functionContent
        }

    $moduleContent | Set-Content -Path "$ModuleOutputDirectory/$name.psm1" -Force
    $null = New-Item -Path "$ModuleOutputDirectory/private" -ItemType Directory -Force
    Get-ChildItem -Path "$SourceDirectory/private" -Exclude '*.Tests.ps1' |
        Copy-Item -Destination "$ModuleOutputDirectory/private" -Recurse -Force

    $manifestPath = "$ModuleOutputDirectory/$Name.psd1"
    $repoUrl = ( & git config --get remote.origin.url ).Replace('.git', '')
    $companyName = if ($repoUrl -match 'github') {
        $repoUrl.Split('/')[3]
    }
    else {
        $env:USERDOMAIN
    }
    $existingGuid = Find-Module -Name $Name -Repository PSGallery -ErrorAction SilentlyContinue |
        Select-Object -ExpandProperty AdditionalMetadata |
        Select-Object -ExpandProperty GUID
    $guid = if ($existingGuid) {
        $existingGuid
    }
    else {
        ( New-Guid ).Guid
    }
    $requiredModulesStatement = Get-Content -Path "$SourceDirectory\$Name.psm1" |
        Where-Object -FilterScript { $_ -match '#Requires' }
    $requiredModules = (($requiredModulesStatement -split '-Modules ')[1] -split ',').Trim() |
        ForEach-Object {
            if ($_ -match '@{') {
                Invoke-Expression -Command $_
            }
            else {
                $_
            }
        }
    $moduleVersion, $modulePrerelease = $Version -split '-', 2
    $newModuleManifest = @{
        Path = $manifestPath
        Author = ( & git log --format='%aN' | Sort-Object -Unique )
        CompanyName = $companyName
        Copyright = "(c) $( Get-Date -Format yyyy ) $companyName. All rights reserved."
        RootModule = "$Name.psm1"
        ModuleVersion = $moduleVersion
        Guid = $guid
        Description = $Description
        PowerShellVersion = 5.1
        FunctionsToExport = $functionNames
        CompatiblePSEditions = ('Desktop', 'Core')
        Tags = $Tags
        ProjectUri = $repoUrl
        LicenseUri = 'https://opensource.org/licenses/MIT'
        ReleaseNotes = ( git log -1 --pretty=%B )[0]
    }
    if ($requiredModules) {
        $newModuleManifest['RequiredModules'] = $requiredModules
    }
    if ($modulePrerelease) {
        $newModuleManifest['Prerelease'] = $modulePrerelease
    }
    New-ModuleManifest @newModuleManifest
    Get-Item -Path $manifestPath

    Get-Module -Name $Name -All | Remove-Module -Force -ErrorAction SilentlyContinue
    Import-Module -Name $manifestPath -Force -PassThru
}

<#
.SYNOPSIS
Invokes PSScriptAnalyzer on a directory using a more strict set of rules than default.
 
.DESCRIPTION
Invokes PSScriptAnalyzer on a directory using a more strict set of rules than default.
 
.PARAMETER SourceDirectory
The directory to analyze.
 
.PARAMETER Settings
The settings file to use. Defaults to internal custom file.
 
.PARAMETER Fix
Whether to fix the issues found.
 
.EXAMPLE
Invoke-PSModuleAnalyzer -SourceDirectory $PWD/src -Fix
 
.NOTES
N/A
#>

function Invoke-PSModuleAnalyzer {
    [CmdletBinding()]
    param (
        [String]$SourceDirectory = "$PWD/src",
        [String]$Settings = "$PSScriptRoot/private/PSScriptAnalyzerSettings.psd1",
        [Switch]$Fix
    )

    Invoke-ScriptAnalyzer `
        -Path $SourceDirectory `
        -Settings $Settings `
        -Recurse `
        -Severity Information `
        -Fix:$Fix `
        -EnableExit:(!$Fix) `
        -ReportSummary
}

<#
.SYNOPSIS
Publishes a PowerShell module to a repository.
 
.DESCRIPTION
Publishes a PowerShell module to a repository. Defaults to PSGallery.
 
.PARAMETER Name
The name of the module.
 
.PARAMETER OutputDirectory
The build output directory used in Build-PSModule.
 
.PARAMETER Repository
The repository to publish to. Defaults to PSGallery.
 
.PARAMETER ApiKey
The API key to use for publishing. Defaults to $env:PSGALLERYAPIKEY.
 
.EXAMPLE
Publish-PSModule -Name 'MyModule' -OutputDirectory "$PWD/out" -Repository 'PSGallery'
 
.NOTES
N/A
#>

function Publish-PSModule {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param (
        [String]$Name = 'PSModule',
        [String]$OutputDirectory = "$PWD/out",
        [String]$Repository = 'PSGallery',
        [String]$ApiKey = $env:PSGALLERYAPIKEY
    )

    Get-Module -Name $Name -All | Remove-Module -Force -Confirm:$false -ErrorAction SilentlyContinue

    $versionedFolder = Get-ChildItem -Path "$OutputDirectory/$Name" | Select-Object -Last 1
    if ($versionedFolder) {
        Import-Module -Name "$($versionedFolder.FullName)/$Name.psd1" -Force -PassThru
        Publish-PSResource `
            -Path $versionedFolder.FullName `
            -ApiKey $ApiKey `
            -Repository $Repository

        Find-PSResource `
            -Name $Name `
            -Version $versionedFolder.BaseName `
            -Prerelease `
            -Repository $Repository
    }
    else {
        Write-Warning -Message "No module named $Name found to publish."
    }
}

<#
.SYNOPSIS
Tests a PowerShell module using Pester.
 
.DESCRIPTION
Tests a PowerShell module using Pester. The function installs Pester, removes any existing module with the same name,
and runs Pester with a configuration optimized for running in a CI pipeline.
 
.PARAMETER Name
The name of the module.
 
.PARAMETER SourceDirectory
The source directory of the module. Should be a nested directory that doesn't contain and build scripts.
 
.PARAMETER Exclude
The directories to exclude from testing and code coverage.
 
.PARAMETER Tag
The tag to filter tests by.
 
.EXAMPLE
Test-PSModule -Name 'MyModule' -SourceDirectory "$PWD/src" -Tag 'Unit'
 
.NOTES
N/A
#>

function Test-PSModule {
    [CmdletBinding()]
    param (
        [String]$Name = 'PSModule',
        [String]$SourceDirectory = "$PWD/src",
        [String[]]$Exclude,
        [String[]]$Tag
    )

    $testFiles = Get-ChildItem -Path $SourceDirectory -Filter '*.Tests.ps1' -Recurse
    if (-not $testFiles) {
        Write-Warning -Message "No test files found in $SourceDirectory"
        return
    }
    Get-Module -Name $Name -All | Remove-Module -Force -ErrorAction SilentlyContinue
    $config = New-PesterConfiguration @{
        Run          = @{
            Path        = $SourceDirectory
            ExcludePath = $Exclude
        }
        CodeCoverage = @{
            Enabled    = $true
            OutputPath = 'tests/coverage.xml'
        }
        TestResult   = @{
            Enabled    = $true
            OutputPath = 'tests/testResults.xml'
        }
        Output       = @{
            Verbosity = 'Detailed'
        }
    }
    if ($Tag) {
        $config.Filter.Tag = 'Unit'
    }

    # TODO: Remove after implementing test result publishing
    $config.Run.Exit = $true
    $config.Run.Throw = $true

    Write-Verbose -Message 'Running Pester tests with the following configuration:'
    Write-Verbose -Message ( $config | ConvertTo-Json -Depth 5 )
    Invoke-Pester -Configuration $config
}