diff --git a/scripts/Helpers/Helpers.psm1 b/scripts/Helpers/Helpers.psm1 index ae6cf25..c5228c1 100644 --- a/scripts/Helpers/Helpers.psm1 +++ b/scripts/Helpers/Helpers.psm1 @@ -323,3 +323,788 @@ function Install-PSModule { return $codePath } } + +function Get-ModuleManifest { + <# + .SYNOPSIS + Get the module manifest. + + .DESCRIPTION + Get the module manifest as a path, file info, content, or hashtable. + + .EXAMPLE + Get-PSModuleManifest -Path 'src/PSModule/PSModule.psd1' -As Hashtable + #> + [OutputType([string], [System.IO.FileInfo], [System.Collections.Hashtable], [System.Collections.Specialized.OrderedDictionary])] + [CmdletBinding()] + param( + # Path to the module manifest file. + [Parameter(Mandatory)] + [string] $Path, + + # The format of the output. + [Parameter()] + [ValidateSet('FileInfo', 'Content', 'Hashtable')] + [string] $As = 'Hashtable' + ) + + if (-not (Test-Path -Path $Path)) { + Write-Warning 'No manifest file found.' + return $null + } + Write-Verbose "Found manifest file [$Path]" + + switch ($As) { + 'FileInfo' { + return Get-Item -Path $Path + } + 'Content' { + return Get-Content -Path $Path + } + 'Hashtable' { + $manifest = [System.Collections.Specialized.OrderedDictionary]@{} + $psData = [System.Collections.Specialized.OrderedDictionary]@{} + $privateData = [System.Collections.Specialized.OrderedDictionary]@{} + $tempManifest = Import-PowerShellDataFile -Path $Path + if ($tempManifest.ContainsKey('PrivateData')) { + $tempPrivateData = $tempManifest.PrivateData + if ($tempPrivateData.ContainsKey('PSData')) { + $tempPSData = $tempPrivateData.PSData + $tempPrivateData.Remove('PSData') + } + } + + $psdataOrder = @( + 'Tags' + 'LicenseUri' + 'ProjectUri' + 'IconUri' + 'ReleaseNotes' + 'Prerelease' + 'RequireLicenseAcceptance' + 'ExternalModuleDependencies' + ) + foreach ($key in $psdataOrder) { + if (($null -ne $tempPSData) -and ($tempPSData.ContainsKey($key))) { + $psData.$key = $tempPSData.$key + } + } + if ($psData.Count -gt 0) { + $privateData.PSData = $psData + } else { + $privateData.Remove('PSData') + } + foreach ($key in $tempPrivateData.Keys) { + $privateData.$key = $tempPrivateData.$key + } + + $manifestOrder = @( + 'RootModule' + 'ModuleVersion' + 'CompatiblePSEditions' + 'GUID' + 'Author' + 'CompanyName' + 'Copyright' + 'Description' + 'PowerShellVersion' + 'PowerShellHostName' + 'PowerShellHostVersion' + 'DotNetFrameworkVersion' + 'ClrVersion' + 'ProcessorArchitecture' + 'RequiredModules' + 'RequiredAssemblies' + 'ScriptsToProcess' + 'TypesToProcess' + 'FormatsToProcess' + 'NestedModules' + 'FunctionsToExport' + 'CmdletsToExport' + 'VariablesToExport' + 'AliasesToExport' + 'DscResourcesToExport' + 'ModuleList' + 'FileList' + 'HelpInfoURI' + 'DefaultCommandPrefix' + 'PrivateData' + ) + foreach ($key in $manifestOrder) { + if ($tempManifest.ContainsKey($key)) { + $manifest.$key = $tempManifest.$key + } + } + if ($privateData.Count -gt 0) { + $manifest.PrivateData = $privateData + } else { + $manifest.Remove('PrivateData') + } + + return $manifest + } + } +} + +filter Set-ModuleManifest { + <# + .SYNOPSIS + Sets the values of a module manifest file. + + .DESCRIPTION + This function sets the values of a module manifest file. + Very much like the Update-ModuleManifest function, but allows values to be missing. + + .EXAMPLE + Set-ModuleManifest -Path 'C:\MyModule\MyModule.psd1' -ModuleVersion '1.0.0' + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Function does not change state.' + )] + [CmdletBinding()] + param( + # Path to the module manifest file. + [Parameter( + Mandatory, + ValueFromPipeline, + ValueFromPipelineByPropertyName + )] + [string] $Path, + + #Script module or binary module file associated with this manifest. + [Parameter()] + [AllowNull()] + [string] $RootModule, + + #Version number of this module. + [Parameter()] + [AllowNull()] + [Version] $ModuleVersion, + + # Supported PSEditions. + [Parameter()] + [AllowNull()] + [string[]] $CompatiblePSEditions, + + # ID used to uniquely identify this module. + [Parameter()] + [AllowNull()] + [guid] $GUID, + + # Author of this module. + [Parameter()] + [AllowNull()] + [string] $Author, + + # Company or vendor of this module. + [Parameter()] + [AllowNull()] + [string] $CompanyName, + + # Copyright statement for this module. + [Parameter()] + [AllowNull()] + [string] $Copyright, + + # Description of the functionality provided by this module. + [Parameter()] + [AllowNull()] + [string] $Description, + + # Minimum version of the PowerShell engine required by this module. + [Parameter()] + [AllowNull()] + [Version] $PowerShellVersion, + + # Name of the PowerShell host required by this module. + [Parameter()] + [AllowNull()] + [string] $PowerShellHostName, + + # Minimum version of the PowerShell host required by this module. + [Parameter()] + [AllowNull()] + [version] $PowerShellHostVersion, + + # Minimum version of Microsoft .NET Framework required by this module. + # This prerequisite is valid for the PowerShell Desktop edition only. + [Parameter()] + [AllowNull()] + [Version] $DotNetFrameworkVersion, + + # Minimum version of the common language runtime (CLR) required by this module. + # This prerequisite is valid for the PowerShell Desktop edition only. + [Parameter()] + [AllowNull()] + [Version] $ClrVersion, + + # Processor architecture (None,X86, Amd64) required by this module + [Parameter()] + [AllowNull()] + [System.Reflection.ProcessorArchitecture] $ProcessorArchitecture, + + # Modules that must be imported into the global environment prior to importing this module. + [Parameter()] + [AllowNull()] + [Object[]] $RequiredModules, + + # Assemblies that must be loaded prior to importing this module. + [Parameter()] + [AllowNull()] + [string[]] $RequiredAssemblies, + + # Script files (.ps1) that are run in the caller's environment prior to importing this module. + [Parameter()] + [AllowNull()] + [string[]] $ScriptsToProcess, + + # Type files (.ps1xml) to be loaded when importing this module. + [Parameter()] + [AllowNull()] + [string[]] $TypesToProcess, + + # Format files (.ps1xml) to be loaded when importing this module. + [Parameter()] + [AllowNull()] + [string[]] $FormatsToProcess, + + # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess. + [Parameter()] + [AllowNull()] + [Object[]] $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. + [Parameter()] + [AllowNull()] + [string[]] $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. + [Parameter()] + [AllowNull()] + [string[]] $CmdletsToExport, + + # Variables to export from this module. + [Parameter()] + [AllowNull()] + [string[]] $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. + [Parameter()] + [AllowNull()] + [string[]] $AliasesToExport, + + # DSC resources to export from this module. + [Parameter()] + [AllowNull()] + [string[]] $DscResourcesToExport, + + # List of all modules packaged with this module. + [Parameter()] + [AllowNull()] + [Object[]] $ModuleList, + + # List of all files packaged with this module. + [Parameter()] + [AllowNull()] + [string[]] $FileList, + + # Tags applied to this module. These help with module discovery in online galleries. + [Parameter()] + [AllowNull()] + [string[]] $Tags, + + # A URL to the license for this module. + [Parameter()] + [AllowNull()] + [uri] $LicenseUri, + + # A URL to the main site for this project. + [Parameter()] + [AllowNull()] + [uri] $ProjectUri, + + # A URL to an icon representing this module. + [Parameter()] + [AllowNull()] + [uri] $IconUri, + + # ReleaseNotes of this module. + [Parameter()] + [AllowNull()] + [string] $ReleaseNotes, + + # Prerelease string of this module. + [Parameter()] + [AllowNull()] + [string] $Prerelease, + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save. + [Parameter()] + [AllowNull()] + [bool] $RequireLicenseAcceptance, + + # External dependent modules of this module. + [Parameter()] + [AllowNull()] + [string[]] $ExternalModuleDependencies, + + # HelpInfo URI of this module. + [Parameter()] + [AllowNull()] + [String] $HelpInfoURI, + + # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. + [Parameter()] + [AllowNull()] + [string] $DefaultCommandPrefix, + + # 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. + [Parameter()] + [AllowNull()] + [object] $PrivateData + ) + + $outManifest = [ordered]@{} + $outPSData = [ordered]@{} + $outPrivateData = [ordered]@{} + + $tempManifest = Get-ModuleManifest -Path $Path + if ($tempManifest.Keys.Contains('PrivateData')) { + $tempPrivateData = $tempManifest.PrivateData + if ($tempPrivateData.Keys.Contains('PSData')) { + $tempPSData = $tempPrivateData.PSData + $tempPrivateData.Remove('PSData') + } + } + + $psdataOrder = @( + 'Tags' + 'LicenseUri' + 'ProjectUri' + 'IconUri' + 'ReleaseNotes' + 'Prerelease' + 'RequireLicenseAcceptance' + 'ExternalModuleDependencies' + ) + foreach ($key in $psdataOrder) { + if (($null -ne $tempPSData) -and $tempPSData.Keys.Contains($key)) { + $outPSData[$key] = $tempPSData[$key] + } + if ($PSBoundParameters.Keys.Contains($key)) { + if ($null -eq $PSBoundParameters[$key]) { + $outPSData.Remove($key) + } else { + $outPSData[$key] = $PSBoundParameters[$key] + } + } + } + + if ($outPSData.Count -gt 0) { + $outPrivateData.PSData = $outPSData + } else { + $outPrivateData.Remove('PSData') + } + foreach ($key in $tempPrivateData.Keys) { + $outPrivateData[$key] = $tempPrivateData[$key] + } + foreach ($key in $PrivateData.Keys) { + $outPrivateData[$key] = $PrivateData[$key] + } + + $manifestOrder = @( + 'RootModule' + 'ModuleVersion' + 'CompatiblePSEditions' + 'GUID' + 'Author' + 'CompanyName' + 'Copyright' + 'Description' + 'PowerShellVersion' + 'PowerShellHostName' + 'PowerShellHostVersion' + 'DotNetFrameworkVersion' + 'ClrVersion' + 'ProcessorArchitecture' + 'RequiredModules' + 'RequiredAssemblies' + 'ScriptsToProcess' + 'TypesToProcess' + 'FormatsToProcess' + 'NestedModules' + 'FunctionsToExport' + 'CmdletsToExport' + 'VariablesToExport' + 'AliasesToExport' + 'DscResourcesToExport' + 'ModuleList' + 'FileList' + 'HelpInfoURI' + 'DefaultCommandPrefix' + 'PrivateData' + ) + foreach ($key in $manifestOrder) { + if ($tempManifest.Keys.Contains($key)) { + $outManifest[$key] = $tempManifest[$key] + } + if ($PSBoundParameters.Keys.Contains($key)) { + if ($null -eq $PSBoundParameters[$key]) { + $outManifest.Remove($key) + } else { + $outManifest[$key] = $PSBoundParameters[$key] + } + } + } + if ($outPrivateData.Count -gt 0) { + $outManifest['PrivateData'] = $outPrivateData + } else { + $outManifest.Remove('PrivateData') + } + + $sectionsToSort = @( + 'CompatiblePSEditions', + 'RequiredAssemblies', + 'ScriptsToProcess', + 'TypesToProcess', + 'FormatsToProcess', + 'FunctionsToExport', + 'CmdletsToExport', + 'VariablesToExport', + 'AliasesToExport', + 'DscResourcesToExport', + 'ModuleList', + 'FileList' + ) + + foreach ($section in $sectionsToSort) { + if ($outManifest.Contains($section) -and $null -ne $outManifest[$section]) { + $outManifest[$section] = @($outManifest[$section] | Sort-Object) + } + } + + $objectSectionsToSort = @('RequiredModules', 'NestedModules') + foreach ($section in $objectSectionsToSort) { + if ($outManifest.Contains($section) -and $null -ne $outManifest[$section]) { + $sortedObjects = $outManifest[$section] | Sort-Object -Property { + if ($_ -is [hashtable]) { + $_['ModuleName'] + } elseif ($_ -is [Microsoft.PowerShell.Commands.ModuleSpecification]) { + $_.Name + } elseif ($_ -is [string]) { + $_ + } else { + throw "Unsupported type '$($_.GetType().Name)' in module manifest." + } + } + + $formattedModules = foreach ($item in $sortedObjects) { + if ($item -is [Microsoft.PowerShell.Commands.ModuleSpecification]) { + $hash = [ordered]@{} + $hash['ModuleName'] = $item.Name + if ($item.RequiredVersion) { + $hash['RequiredVersion'] = $item.RequiredVersion.ToString() + } elseif ($item.Version) { + $hash['ModuleVersion'] = $item.Version.ToString() + } elseif ($item.MaximumVersion) { + $hash['MaximumVersion'] = $item.MaximumVersion.ToString() + } + + if ($hash.Count -eq 1) { + # Simplify if only ModuleName + $hash.ModuleName + } else { + $hash + } + } elseif ($item -is [hashtable]) { + # Recreate as ordered hashtable explicitly + $orderedItem = [ordered]@{} + if ($item.ContainsKey('ModuleName')) { + $orderedItem['ModuleName'] = $item['ModuleName'] + } + if ($item.RequiredVersion) { + $orderedItem['RequiredVersion'] = $item.RequiredVersion + } + if ($item.ModuleVersion) { + $orderedItem['ModuleVersion'] = $item.ModuleVersion + } + if ($item.MaximumVersion) { + $orderedItem['MaximumVersion'] = $item.MaximumVersion + } + $orderedItem + } elseif ($item -is [string]) { + $item + } + } + + $outManifest[$section] = @($formattedModules) + } + } + + + + + if ($outPrivateData.Contains('PSData')) { + if ($outPrivateData.PSData.Contains('ExternalModuleDependencies') -and $null -ne $outPrivateData.PSData.ExternalModuleDependencies) { + $outPrivateData.PSData.ExternalModuleDependencies = @($outPrivateData.PSData.ExternalModuleDependencies | Sort-Object) + } + if ($outPrivateData.PSData.Contains('Tags') -and $null -ne $outPrivateData.PSData.Tags) { + $outPrivateData.PSData.Tags = @($outPrivateData.PSData.Tags | Sort-Object) + } + } + + Remove-Item -Path $Path -Force + Export-PowerShellDataFile -Hashtable $outManifest -Path $Path +} + +function Export-PowerShellDataFile { + <# + .SYNOPSIS + Export a hashtable to a .psd1 file. + + .DESCRIPTION + This function exports a hashtable to a .psd1 file. It also formats the .psd1 file using the Format-ModuleManifest cmdlet. + + .EXAMPLE + Export-PowerShellDataFile -Hashtable @{ Name = 'MyModule'; ModuleVersion = '1.0.0' } -Path 'MyModule.psd1' + #> + [CmdletBinding()] + param ( + # The hashtable to export to a .psd1 file. + [Parameter(Mandatory)] + [object] $Hashtable, + + # The path of the .psd1 file to export. + [Parameter(Mandatory)] + [string] $Path, + + # Force the export, even if the file already exists. + [Parameter()] + [switch] $Force + ) + + $content = Format-Hashtable -Hashtable $Hashtable + $content | Out-File -FilePath $Path -Force:$Force + Format-ModuleManifest -Path $Path +} + +function Format-ModuleManifest { + <# + .SYNOPSIS + Formats a module manifest file. + + .DESCRIPTION + This function formats a module manifest file, by removing comments and empty lines, + and then formatting the file using the `Invoke-Formatter` function. + + .EXAMPLE + Format-ModuleManifest -Path 'C:\MyModule\MyModule.psd1' + #> + [CmdletBinding()] + param( + # Path to the module manifest file. + [Parameter(Mandatory)] + [string] $Path + ) + + $Utf8BomEncoding = New-Object System.Text.UTF8Encoding $true + + $manifestContent = Get-Content -Path $Path + $manifestContent = $manifestContent | ForEach-Object { $_ -replace '#.*' } + $manifestContent = $manifestContent | ForEach-Object { $_.TrimEnd() } + $manifestContent = $manifestContent | Where-Object { -not [string]::IsNullOrEmpty($_) } + [System.IO.File]::WriteAllLines($Path, $manifestContent, $Utf8BomEncoding) + $manifestContent = Get-Content -Path $Path -Raw + + $content = Invoke-Formatter -ScriptDefinition $manifestContent + + # Ensure exactly one empty line at the end + $content = $content.TrimEnd([System.Environment]::NewLine) + [System.Environment]::NewLine + + [System.IO.File]::WriteAllText($Path, $content, $Utf8BomEncoding) +} + +filter Format-Hashtable { + <# + .SYNOPSIS + Converts a hashtable to its PowerShell code representation. + + .DESCRIPTION + Recursively converts a hashtable to its PowerShell code representation. + This function is useful for exporting hashtables to `.psd1` files, + making it easier to store and retrieve structured data. + + .EXAMPLE + $hashtable = @{ + Key1 = 'Value1' + Key2 = @{ + NestedKey1 = 'NestedValue1' + NestedKey2 = 'NestedValue2' + } + Key3 = @(1, 2, 3) + Key4 = $true + } + Format-Hashtable -Hashtable $hashtable + + Output: + ```powershell + @{ + Key1 = 'Value1' + Key2 = @{ + NestedKey1 = 'NestedValue1' + NestedKey2 = 'NestedValue2' + } + Key3 = @( + 1 + 2 + 3 + ) + Key4 = $true + } + ``` + + .OUTPUTS + string + + .NOTES + A string representation of the given hashtable. + Useful for serialization and exporting hashtables to files. + + .LINK + https://psmodule.io/Hashtable/Functions/Format-Hashtable + #> + [OutputType([string])] + [CmdletBinding()] + param ( + # The hashtable to convert to a PowerShell code representation. + [Parameter( + Mandatory, + ValueFromPipeline, + ValueFromPipelineByPropertyName + )] + [System.Collections.IDictionary] $Hashtable, + + # The indentation level for formatting nested structures. + [Parameter()] + [int] $IndentLevel = 1 + ) + + # If the hashtable is empty, return '@{}' immediately. + if ($Hashtable.Count -eq 0) { + return '@{}' + } + + $indent = ' ' + $lines = @() + $lines += '@{' + $levelIndent = $indent * $IndentLevel + + # Compute maximum key length at this level to align the '=' characters + $maxKeyLength = ($Hashtable.Keys | ForEach-Object { $_.ToString().Length } | Measure-Object -Maximum).Maximum + + foreach ($key in $Hashtable.Keys) { + # Pad each key to the maximum length so the '=' lines up. + $paddedKey = $key.ToString().PadRight($maxKeyLength) + Write-Verbose "Processing key: [$key]" + $value = $Hashtable[$key] + Write-Verbose "Processing value: [$value]" + if ($null -eq $value) { + Write-Verbose "Value type: `$null" + $lines += "$levelIndent$paddedKey = `$null" + continue + } + Write-Verbose "Value type: [$($value.GetType().Name)]" + if ($value -is [System.Collections.IDictionary]) { + # Nested hashtable + $nestedString = Format-Hashtable -Hashtable $value -IndentLevel ($IndentLevel + 1) + $lines += "$levelIndent$paddedKey = $nestedString" + } elseif ($value -is [System.Management.Automation.PSCustomObject]) { + # PSCustomObject => Convert to hashtable & recurse + $nestedString = $value | ConvertTo-Hashtable | Format-Hashtable -IndentLevel ($IndentLevel + 1) + $lines += "$levelIndent$paddedKey = $nestedString" + } elseif ( $value -is [bool] -or $value -is [System.Management.Automation.SwitchParameter] ) { + $boolValue = [bool]$value + $lines += "$levelIndent$paddedKey = `$$($boolValue.ToString().ToLower())" + } elseif ($value -is [int] -or $value -is [long] -or $value -is [double] -or $value -is [decimal]) { + $lines += "$levelIndent$paddedKey = $value" + } elseif ($value -is [System.Collections.IList]) { + # This covers normal arrays, ArrayList, List, etc. + if ($value.Count -eq 0) { + $lines += "$levelIndent$paddedKey = @()" + } else { + $lines += "$levelIndent$paddedKey = @(" + $arrayIndent = $levelIndent + $indent + + foreach ($nestedValue in $value) { + Write-Verbose "Processing array element: [$nestedValue]" + Write-Verbose "Element type: [$($nestedValue.GetType().Name)]" + + if (($nestedValue -is [System.Collections.IDictionary])) { + # Nested hashtable + $nestedString = Format-Hashtable -Hashtable $nestedValue -IndentLevel ($IndentLevel + 2) + $lines += "$arrayIndent$nestedString" + } elseif ($nestedValue -is [System.Management.Automation.PSCustomObject]) { + # PSCustomObject => Convert to hashtable & recurse + $nestedString = $nestedValue | ConvertTo-Hashtable | Format-Hashtable -IndentLevel ($IndentLevel + 2) + $lines += "$arrayIndent$nestedString" + } elseif ( $nestedValue -is [bool] -or $nestedValue -is [System.Management.Automation.SwitchParameter] ) { + $boolValue = [bool]$nestedValue + $lines += "$arrayIndent`$$($boolValue.ToString().ToLower())" + } elseif ($nestedValue -is [int] -or $nestedValue -is [long] -or $nestedValue -is [double] -or $nestedValue -is [decimal]) { + $lines += "$arrayIndent$nestedValue" + } else { + # Fallback => treat as string (escape single-quotes) + $escapedElement = $nestedValue -replace "('+)", "''" + $lines += "$arrayIndent'$escapedElement'" + } + } + + $lines += ($levelIndent + ')') + } + } else { + # Fallback: treat as string (escaping single-quotes) + $escapedValue = $value -replace "('+)", "''" + $lines += "$levelIndent$paddedKey = '$escapedValue'" + } + } + + $levelIndent = $indent * ($IndentLevel - 1) + $lines += "$levelIndent}" + + return $lines -join [Environment]::NewLine +} + +function Add-PSModulePath { + <# + .SYNOPSIS + Adds a path to the PSModulePath environment variable. + + .DESCRIPTION + Adds a path to the PSModulePath environment variable. + For Linux and macOS, the path delimiter is ':' and for Windows it is ';'. + + .EXAMPLE + Add-PSModulePath -Path 'C:\Users\user\Documents\WindowsPowerShell\Modules' + + Adds the path 'C:\Users\user\Documents\WindowsPowerShell\Modules' to the PSModulePath environment variable. + #> + [CmdletBinding()] + param( + # Path to the folder where the module source code is located. + [Parameter(Mandatory)] + [string] $Path + ) + $PSModulePathSeparator = [System.IO.Path]::PathSeparator + + $env:PSModulePath += "$PSModulePathSeparator$Path" + + Write-Verbose 'PSModulePath:' + $env:PSModulePath.Split($PSModulePathSeparator) | ForEach-Object { + Write-Verbose " - [$_]" + } +}