Includes/PwSh.Fw.Build.Powershell.psm1

<#
.SYNOPSIS
Convert a generic hashtable into useful ModuleSettings metadata
 
.DESCRIPTION
Extract from an object useful properties to use as a module manifest settings
 
.PARAMETER Metadata
object filled with various properties
 
.EXAMPLE
$project = gc ./project.yml -raw | convertfrom-yaml
$project | ConvertTo-PowershellModuleSettings
 
This example will convert a project definition file into a useable hashtable to inject into Update-ModuleManifest
 
.NOTES
General notes
#>

function ConvertTo-PowershellModuleSettings {
    [CmdletBinding()][OutputType([hashtable])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$Metadata
    )
    Begin {
    }

    Process {
        $ModuleSettings = @{}
        if ($Metadata) {
            # if ($Metadata.name) { $ModuleSettings.Name = $Metadata.name }
            if ($Metadata.Version) { $ModuleSettings.ModuleVersion = $Metadata.Version }
            if ($Metadata.ModuleVersion) { $ModuleSettings.ModuleVersion = $Metadata.ModuleVersion }
            if ($Metadata.GUID) { $ModuleSettings.GUID = $Metadata.GUID }
            if ($Metadata.NestedModules) { $ModuleSettings.NestedModules = $Metadata.NestedModules }
            if ($Metadata.Author) { $ModuleSettings.Author = $Metadata.Author }
            if ($Metadata.owner) { $ModuleSettings.Author = $Metadata.owner }
            if ($Metadata.CompanyName) { $ModuleSettings.CompanyName = $Metadata.CompanyName }
            if ($Metadata.Copyright) { $ModuleSettings.Copyright = $Metadata.Copyright }
            if ($Metadata.Description) { $ModuleSettings.Description = $Metadata.Description }
            if ($Metadata.ProcessorArchitecture) { $ModuleSettings.ProcessorArchitecture = $Metadata.ProcessorArchitecture }
            if ($Metadata.Architecture) { $ModuleSettings.ProcessorArchitecture = $Metadata.Architecture }
            if ($Metadata.Arch) { $ModuleSettings.ProcessorArchitecture = $Metadata.Arch }
            # translate Arch to correct values @see @url https://docs.microsoft.com/fr-fr/powershell/scripting/developer/module/how-to-write-a-powershell-module-manifest
            switch ($ModuleSettings.ProcessorArchitecture) {
                'all' { $ModuleSettings.ProcessorArchitecture = 'None' }
                'x64' { $ModuleSettings.ProcessorArchitecture = 'AMD64' }
                'arm32' { $ModuleSettings.ProcessorArchitecture = 'ARM' }
                'arm64' { $ModuleSettings.ProcessorArchitecture = 'ARM' }
            }
            if ($Metadata.RequiredModules) { $ModuleSettings.RequiredModules = $Metadata.RequiredModules }
            if ($Metadata.Depends) {
                # to be a valid RequiredModule, the module must be installed on the system
                $Metadata.Depends | ForEach-Object { Install-Module -Name $_ }
                $ModuleSettings.RequiredModules = $Metadata.Depends
            }
            if ($Metadata.PrivateData) { $ModuleSettings.PrivateData = $Metadata.PrivateData }
            if ($Metadata.Tags) { $ModuleSettings.Tags = $Metadata.Tags }
            if ($Metadata.ProjectUri) { $ModuleSettings.ProjectUri = $Metadata.ProjectUri }
            if ($Metadata.ProjectUrl) { $ModuleSettings.ProjectUri = $Metadata.ProjectUrl }
            if ($Metadata.LicenseUri) { $ModuleSettings.LicenseUri = $Metadata.LicenseUri }
            if ($Metadata.LicenseUrl) { $ModuleSettings.LicenseUri = $Metadata.LicenseUrl }
            if ($Metadata.IconUri) { $ModuleSettings.IconUri = $Metadata.IconUri }
            if ($Metadata.IconUrl) { $ModuleSettings.IconUri = $Metadata.IconUrl }
            if ($Metadata.ReleaseNotes) { $ModuleSettings.ReleaseNotes = $Metadata.ReleaseNotes }
            # Powershell < 6 does not know -Prerelease argument
            if ($PSVersionTable.PSVersion.Major -ge 6) {
                if ($Metadata.Prerelease) { $ModuleSettings.Prerelease = $Metadata.Prerelease }
            } else {
                $ModuleSettings.Remove('Prerelease')
            }
            if ($Metadata.HelpInfoUri) { $ModuleSettings.HelpInfoUri = $Metadata.HelpInfoUri }
        }

        return $ModuleSettings
    }

    End {
    }
}

<#
.SYNOPSIS
Create / Update module manifest. It is a wrapper of Microsoft's Update-ModuleManifest
 
.DESCRIPTION
The Update-ModuleManifestEx extends the Microsoft cmdlet Update-ModuleManifest.
Actually, Update-ModuleManifestEx is a wrapper of Update-ModuleManifest (and New-ModuleManifest). It :
* checks if files exists
* update / create module manifest
* export exact list of functions and aliases, not '*'
* trims spaces at end of lines of module psm1 and psd1 files
 
.PARAMETER FullyQualifiedName
Full path to module file. Either an already existing manifest (.psd1), or a module content (.psm1)
 
.PARAMETER Metadata
Metadata of manifest to use. See lists @url https://docs.microsoft.com/en-us/powershell/module/powershellget/update-modulemanifest
 
.PARAMETER PreserveMetadata
If specified, instruct Update-ModuleManifestEx to not override Metadata already present in Module Manifest.
It only refresh functions to export and aliases to export.
 
.PARAMETER PassThru
If specified, return the module object. If not specified, return the path of the module manifest file.
 
.PARAMETER CreateOnly
Only create new module manifest.
If specified, ignore existing module manifest. They will not be updated.
NOTE: if neither CreateOnly or UpdateOnly parameter are specified, then all module manifest will be created/updated, as if both -CreateOnly and -UpdateOnly would have been used.
 
.PARAMETER UpdateOnly
Only update existing manifest.
If specified, ignore missing module manifest. They will not be created.
NOTE: if neither CreateOnly or UpdateOnly parameter are specified, then all module manifest will be created/updated, as if both -CreateOnly and -UpdateOnly would have been used.
 
.EXAMPLE
Update-ModuleManifestEx -FullyQualifiedName /path/to/my/module.psm1 -Metadata $Project
 
.OUTPUTS
System.Management.Automation.PSModuleInfo
 
This cmdlet returns objects that represent modules.
 
.NOTES
General notes
#>


function Update-ModuleManifestEx {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    [OutputType([Boolean], [String], [PSModuleInfo])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$FullyQualifiedName,
        [AllowNull()]
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Metadata,
        [switch]$PreserveMetadata,
        [switch]$CreateOnly,
        [switch]$UpdateOnly,
        [switch]$PassThru
    )
    Begin {
        # eenter($MyInvocation.MyCommand)
        if ($CreateOnly -eq $UpdateOnly) { $CreateOnly = $UpdateOnly = $true }
    }

    Process {
        # test and inspect FullyQualidiedName
        $rc = Test-Path -Path $FullyQualifiedName -PathType Leaf -ErrorAction SilentlyContinue
        if ($rc -eq $false) {
            Write-Host -ForegroundColor Red "'$FullyQualifiedName' does not exist."
            return $false
        }
        $file = Get-Item $FullyQualifiedName
        if (!($file)) {
            Write-Host -ForegroundColor Red "cannot get '$FullyQualifiedName' item."
            return $false
        }

        # Merge global metadata (if specified) with module metadata (if exists)
        if (Test-FileExist "$($file.DirectoryName)/../$($file.Basename)") {
            $yaml = Get-Content "$($file.DirectoryName)/../$($file.Basename)" -Raw | ConvertFrom-Yaml
            if ($Metadata) {
                $Metadata = Merge-Hashtables $Metadata $yaml
            } else {
                $Metadata = $yaml
            }
        }
        # fill / translate metadata to module settings
        if ($Metadata) {
            $ModuleSettings = ConvertTo-PowershellModuleSettings -Metadata $Metadata
        }

        # load dependencies before loading module itself
        if ($Metadata.depends) {
            # $Metadata.depends | Import-Module
            # Import-Module seems to no longer honor PSModulePath
            # Get-Module -ListAvailable still do tho, so try to import latest one
            $Metadata.depends | ForEach-Object { Get-Module -ListAvailable $_ | Sort-Object -Property Version | Select-Object -Last 1 }
        }
        # in case we import a module psm1 without psd1, we need to first import-it before getting-it
        # if we don't do that, $module will be empty
        $module = Import-Module -FullyQualifiedName "$FullyQualifiedName" -Force:$true -DisableNameChecking -PassThru
        if (!(Test-ModuleObject -Module $module)) { return $null }
        $rc = $?
        # Write-Information "Updating module $($module.Name)"
        if ($rc -eq $true) {
            switch ($file.Extension) {
                '.psd1' {
                    $ACTION = "update"
                }
                '.psm1' {
                    if (Test-Path "$($module.ModuleBase)/$($module.Name).psd1" -Type Leaf) {
                        $ACTION = "update"
                    } else {
                        $ACTION = "create"
                    }
                    break
                }
                default {
                    Throw "The file extension $($file.Extension) is not a Powershell module extension."
                }
            }
            # edevel("ACTION = $ACTION")
            Write-Debug "ACTION = $ACTION"
            Write-Output "$ACTION '$($module.ModuleBase)/$($module.Name).psd1'"
            # edevel ("Functions list :")
            $functionsList = Get-Command -Module $module.Name
            # $functionsList.Name | ForEach-Object { edevel $_}
            # edevel ("Aliases list :")
            $aliasesList = Get-Alias | Where-Object { $_.ModuleName -eq $module.Name }
            # $aliasesList.Name | ForEach-Object { edevel $_}
            if ($aliasesList.count -eq 0) { $aliasesList = '' }
            # handle privateData
            # Private metadata handling does not work,
            # @see issue with New-ModuleManifest @url https://github.com/PowerShell/PowerShell/issues/5922
            # and issue with Update-ModuleManifest @url https://github.com/PowerShell/PowerShellGet/issues/294
            # $PrivateData = @{}
            # if ($null -ne $Metadata) {
            # $PrivateData.PSData = @{}
            # $PrivateData.PSData.licenseUri = $Metadata.LICENSEURL
            # $PrivateData.PSData.projectUri = $Metadata.URL
            # $PrivateData.PSData.iconUri = $Metadata.ICONURL
            # $PrivateData.PSData.description = $Metadata.DESCRIPTION
            # $PrivateData.PSData.releaseNotes = $Metadata.RELEASENOTES
            # $PrivateData.PSData.tags = $Metadata.TAGS
            # }
            switch ($ACTION) {
                'create' {
                    if ($CreateOnly) {
                        if ($PSCmdlet.ShouldProcess("ShouldProcess?")) {
                            New-ModuleManifest -RootModule "$($module.Name).psm1" -Path "$($module.ModuleBase)/$($module.Name).psd1" -FunctionsToExport $functionsList -AliasesToExport $aliasesList @ModuleSettings
                            $rc = $?
                        }
                    }
                }
                'update' {
                    if ($UpdateOnly) {
                        if ($PSCmdlet.ShouldProcess("ShouldProcess?")) {
                            if ($PreserveMetadata) {
                                Update-ModuleManifest -Path "$($module.ModuleBase)/$($module.Name).psd1" -FunctionsToExport $functionsList -AliasesToExport $aliasesList
                            } else {
                                Update-ModuleManifest -Path "$($module.ModuleBase)/$($module.Name).psd1" -FunctionsToExport $functionsList -AliasesToExport $aliasesList @ModuleSettings
                            }
                            $rc = $?
                        }
                    }
                }
                default {
                    Throw "ACTION '$ACTION' is not supported."
                }
            }
            # trim trailing spaces
            if (Test-Path "$($module.ModuleBase)/$($module.Name).psd1" -Type Leaf) {
                $content = Get-Content "$($module.ModuleBase)/$($module.Name).psd1"
                $content | ForEach-Object {$_.TrimEnd()} | Set-Content "$($module.ModuleBase)/$($module.Name).psd1"
            } else {
                Write-Host -ForegroundColor Red "Module '$($module.ModuleBase)/$($module.Name).psd1' manifest not found."
            }
            $content = Get-Content "$($module.ModuleBase)/$($module.Name).psm1"
            $content | ForEach-Object {$_.TrimEnd()} | Set-Content "$($module.ModuleBase)/$($module.Name).psm1"
        }
        # eend $?
        $module = Get-Module -ListAvailable -FullyQualifiedName "$($module.ModuleBase)/$($module.Name).psd1"
        if ($PassThru) {
            return $module
        } else {
            return $module.Path
        }
    }

    End {
        # eleave($MyInvocation.MyCommand)
    }
}

<#
.SYNOPSIS
Update a (meta) module manifest.
This function is deprecated since PwSh.Fw.BuildHelpers v1.5.0.
Use Update-MetaModuleManifest instead.
 
.DESCRIPTION
This function is deprecated since PwSh.Fw.BuildHelpers v1.5.0.
Use Update-MetaModuleManifest instead.
#>

function Update-ModuleManifestRecurse {
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([Boolean], [String], [PSModuleInfo])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$FullyQualifiedName,
        [AllowNull()]
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Metadata,
        [switch]$CreateOnly,
        [switch]$UpdateOnly,
        [switch]$Recurse,
        [switch]$PassThru
    )
    Begin {
        Write-EnterFunction
        Write-Warning "This function is deprecated since PwSh.Fw.BuidHelpers v1.5.0"
        Write-Warning "Please use Update-MetaModuleManifest instead"
        Write-Warning "The parameters are the same, so for the moment you just need to rename the function calls."
    }

    Process {
        return Update-MetaModuleManifest @PSBoundParameters
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Update a (meta) module manifest
 
.DESCRIPTION
A meta module is a module containing one or more child modules in subdirectories.
It can embed nested modules. This function take care of this inclusion.
It can recurse down directories to find nested modules to
* create / update nested modules manifest
* export functions and aliases to main module according to the subfolder name (see Recurse parameter for more informations)
 
.PARAMETER FullyQualifiedName
Full path to module file. Either an already existing manifest (.psd1), or a module content (.psm1)
 
.PARAMETER Metadata
Metadata of manifest to use. See lists @url https://docs.microsoft.com/en-us/powershell/module/powershellget/update-modulemanifest
 
.PARAMETER Recurse
If specified, instruct Update-ModuleManifestRecurse to recurse through well-known folders to find nested modules.
Following policies apply :
* modules found in Includes subdirectory will export their functions and alias to main module to be available publicly
* modules found in Private sudirectory will not export functions to main module. Functions will remain private, but availables inside main module code.
 
.PARAMETER PassThru
If specified, return the module manifest object. If not specified, return the path of the module manifest file.
 
.EXAMPLE
Update-ModuleManifestRecurse -FullyQualifiedName /path/to/my/module.psm1 -Metadata $Project
 
.OUTPUTS
System.Management.Automation.PSModuleInfo
 
This cmdlet returns objects that represent modules.
 
.NOTES
General notes
#>

function Update-MetaModuleManifest {
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([Boolean], [String], [PSModuleInfo])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$FullyQualifiedName,
        [AllowNull()]
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Metadata,
        [switch]$CreateOnly,
        [switch]$UpdateOnly,
        [switch]$Recurse,
        [switch]$PassThru
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        Write-Verbose "FullyQualifiedName = $FullyQualifiedName"
        try {
            $mainModule = Update-ModuleManifestEx -FullyQualifiedName $FullyQualifiedName -Metadata $Metadata -PassThru -CreateOnly:$CreateOnly -UpdateOnly:$UpdateOnly
        } catch {
            Write-Error $_
            return $null
        }
        if (!($mainModule)) {
            Write-Error "An error occured while updating manifest of '$FullyQualifiedName'."
            return $false
        }
        $FunctionsToExport = $mainModule.ExportedFunctions.Values.Name
        $AliasesToExport = $mainModule.ExportedAliases.Values.Name
        if ($Recurse) {
            $NestedModules = @()
            # $RequiredModules = @()
            Get-ChildItem -Path $mainModule.ModuleBase -Recurse -Name "*.psm1" -Exclude $mainModule.RootModule | ForEach-Object {
                $m = $_
                Write-Information "--> Found $m"
                # skip mainModule
                if ($mainModule.RootModule -eq $_) { continue }
                try {
                    $subModule = Update-ModuleManifestEx -FullyQualifiedName "$($mainModule.ModuleBase)/$_" -Metadata $Metadata -PreserveMetadata -PassThru -CreateOnly:$CreateOnly -UpdateOnly:$UpdateOnly
                } catch {
                    Write-Error $_
                    return $null
                }
                # $folder = ($m -split [io.path]::DirectorySeparatorChar)[0]
                $folder = Split-Path -Parent $m
                # treat Includes as RequiredModules
                # $psm = Get-Item -Path "$($mainModule.ModuleBase)/$($_)"
                Write-Information "folder = $folder"
                switch ($folder) {
                    'Includes' {
                        # $RequiredModules += $m
                        $NestedModules += @("." + [io.path]::DirectorySeparatorChar + "$m")
                        $FunctionsToExport += $subModule.ExportedFunctions.Values.Name
                        $AliasesToExport += $subModule.ExportedAliases.Values.Name
                    }
                    'Private' {
                        $NestedModules += @("." + [io.path]::DirectorySeparatorChar + "$m")
                    }
                    default {
                    }
                }
                # clean tracks
                Remove-Module -Name $subModule.Name
            }
            # finally update the mainModule with functions and aliases found in NestedModules
            # if ($RequiredModules) {
            # # $RequiredModules | fl
            # Update-ModuleManifest -Path $mainModule.Path -RequiredModules $RequiredModules
            # }
            if ($NestedModules.Count -gt 0) {
                if ($PSCmdlet.ShouldProcess("ShouldProcess?")) {
                    try {
                        Update-ModuleManifest -Path $mainModule.Path -NestedModules $NestedModules -FunctionsToExport ($FunctionsToExport | Sort-Object) -AliasesToExport ($AliasesToExport | Sort-Object)
                    } catch {
                        Write-Error $_
                        return $null
                    }
                }
            }
        }

        $mainModule = Get-Module -ListAvailable -FullyQualifiedName "$($mainModule.ModuleBase)/$($mainModule.Name).psd1"
        if ($PassThru) {
            return $mainModule
        } else {
            return $mainModule.Path
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Create or update module manifest recursively, with simple syntax
 
.DESCRIPTION
Bulk create or update module manifest at once !
It can merge global metadata with user-defined module-specific ones.
It can just create manifest if it does not exist, or just update manifest if it DOES exist, or both.
Recursion is optional, but recommended.
 
.EXAMPLE
New-ModuleManifestRecurse -Path /Path/to/Modules
 
.NOTES
General notes
#>

function New-ModuleManifestRecurse {
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]Param (

        # Path to start looking for Modules
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)]
        [string]$Path,

        # Global Metadata, possibliy merged with module specific metadata if found
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)]
        [object]$Metadata,

        # Only create new manifest, do not update existing ones
        [switch]$CreateOnly,

        # Only update existing manifest, do not create new ones
        [switch]$UpdateOnly,

        # Limit depth of subdirectories searching for modules
        [UInt16]$MaxDepth = 99
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $modules = Get-ChildItem "$Path" -name "*.psm1" -recurse -depth $MaxDepth | ForEach-Object { Get-Item "$Path/$_" }
        ForEach ($module in $modules) {
            Write-Debug "Found $($module.Name)"
            $manifestFile = Update-MetaModuleManifest -FullyQualifiedName "$($module.DirectoryName)/$($module.BaseName).psm1" -Metadata $Metadata -CreateOnly:$CreateOnly -UpdateOnly:$UpdateOnly
            # Write-Devel "-> $($module.BaseName)"
        }
    }

    End {
        Write-LeaveFunction
    }
}

function Test-ModuleObject {
    [CmdletBinding()][OutputType([Boolean])]Param (
        [AllowNull()]
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][PSModuleInfo]$Module
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $rc = $true

        everbose "Testing module '$($Module.Name)'"
        if ($null -eq $Module) {
            Write-Host -ForegroundColor Red "[-] Module is null."
            $rc = $false
        } else {
            Write-Host -ForegroundColor Green "[+] Module is not null"
        }
        if ([string]::IsNullOrEmpty($Module.Name)) {
            Write-Host -ForegroundColor Red "[-] Module name is empty"
            $rc = $false
        } else {
            Write-Host -ForegroundColor Green "[+] Module name is not empty"
        }
        if ([string]::IsNullOrEmpty($Module.ModuleBase)) {
            Write-Host -ForegroundColor Red "[-] ModuleBase is empty (VERY DANGEROUS)"
            $rc = $false
        } else {
            Write-Host -ForegroundColor Green "[+] ModuleBase seems ok (part #1)"
        }
        if ($Module.ModuleBase -eq '/') {
            Write-Host -ForegroundColor Red "[-] ModuleBase is '/' (VERY DANGEROUS)"
            $rc = $false
        } else {
            Write-Host -ForegroundColor Green "[+] ModuleBase seems ok (part #2)"
        }
        if ($Module.ModuleBase -eq $env:SystemRoot) {
            Write-Host -ForegroundColor Red "[-] ModuleBase is '$($env:SystemRoot)' (VERY DANGEROUS)"
            $rc = $false
        } else {
            Write-Host -ForegroundColor Green "[+] ModuleBase seems ok (part #3)"
        }

        return $rc
    }

    End {
        Write-LeaveFunction
    }
}