Includes/PwSh.Fw.Build.Powershell.psm1

#Requires -version 7.0

<#
.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 }
            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 }
            if ($Metadata.Prerelease) { $ModuleSettings.Prerelease = $Metadata.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 Metada
Metada 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.
 
.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,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Metadata,
        [switch]$PreserveMetadata,
        [switch]$PassThru
    )
    Begin {
        # eenter($MyInvocation.MyCommand)
    }

    Process {
        # fill / translate metadata to module settings
        $ModuleSettings = ConvertTo-PowershellModuleSettings -Metadata $Metadata

        $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
        }
        # load dependencies before loading module itself
        if ($Metadata.depends) { $Metadata.depends | Import-Module }
        # in case we import a module psm1 wthout 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 ($PSCmdlet.ShouldProcess("ShouldProcess?")) {
                        New-ModuleManifest -RootModule "$($module.Name).psm1" -Path "$($module.ModuleBase)/$($module.Name).psd1" -FunctionsToExport $functionsList -AliasesToExport $aliasesList @ModuleSettings
                        $rc = $?
                    }
                }
                'update' {
                    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 module manifest with recursivity
 
.DESCRIPTION
A module 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
 
.PARAMETER FullyQualifiedName
Full path to module file. Either an already existing manifest (.psd1), or a module content (.psm1)
 
.PARAMETER Metada
Metada 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
* modules found in Private sudirectory will not export anything to main module
 
.PARAMETER PassThru
If specified, return the module 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-ModuleManifestRecurse {
    [CmdletBinding(SupportsShouldProcess = $true)][OutputType([String], [boolean])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$FullyQualifiedName,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Metadata,
        [switch]$Recurse,
        [switch]$PassThru
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        Write-Verbose "FullyQualifiedName = $FullyQualifiedName"
        try {
            $mainModule = Update-ModuleManifestEx -FullyQualifiedName $FullyQualifiedName -Metadata $Metadata -PassThru
        } catch {
            eerror $_
            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
                } catch {
                    eerror $_
                    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 {
                        eerror $_
                        return $null
                    }
                }
            }
        }

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

    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
    }
}