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 LicenseUri The URL for the repo's license. .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]$LicenseUri = 'https://opensource.org/licenses/MIT', [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 LicenseUri = $LicenseUri 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 } $srcModuleContent = Get-Content -Path "$SourceDirectory\$Name.psm1" -Raw $startIndex = $srcModuleContent.IndexOf('Get-ChildItem') $subString = $srcModuleContent.Substring($startIndex) $braceIndex = $subString.IndexOf('}') $moduleScriptContent = $subString.Substring($braceIndex + 1) if ($moduleScriptContent) { $moduleContent += $moduleScriptContent } $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 = $srcModuleContent.Split("`n") | 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 ) -join ', ') 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 = $LicenseUri 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 $maxRetries = 5 $attempt = 0 $delayIntervals = 1, 2, 3, 5, 8 do { try { $publishedModule = Find-PSResource ` -Name $Name ` -Version $versionedFolder.BaseName ` -Prerelease ` -Repository $Repository break } catch { Write-Verbose -Message ( "Couldn't find published module. Retrying after $($delayInterval[$attempt]) seconds." ) Start-Sleep -Seconds $delayIntervals[$attempt] $attempt++ if ($attempt -ge $maxRetries) { throw $_ } } } while (-not $publishedModule -and $attempt -lt $maxRetries) } 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 } |