nl.nlsw.DotNet.psm1

# __ _ ____ _ _ _ _ ____ ____ ____ ____ ____ ___ _ _ ____ ____ ____
# | \| |=== |/\| |___ | |--- |=== ==== [__] |--- | |/\| |--| |--< |===
#
# @file nl.nlsw.DotNet.psm1
# @date 2023-11-18
#requires -version 5

class DotNet {

    # NuGet repository of .NET packages
    static $nugetRepository = @{
        Name = "nuget.org";
        Provider = "NuGet";
        URL = " https://api.nuget.org/v3/index.json";
    }

    # static constructor
    static DotNet() {
        # run this only once to install required packages
        [DotNet]::Install()
    }

    # Function with dummy behavior that can be called to trigger
    # the one-time class construction.
    static [void] Check() {
    }

    # Make sure the NuGet PackageProvider is installed and the "nuget.org" PackageSource is registerd.
    static [void] Install() {
        $repo = [DotNet]::nugetRepository
        # check if the NuGet package provider is available
        $nugetPP = Get-PackageProvider $repo.Provider -ErrorAction SilentlyContinue
        if (!$nugetPP) {
            write-verbose ("{0,16} {1}" -f "installing","NuGet Package Provider for CurrentUser")
            # install the NuGet package provider for the current user
            Install-PackageProvider $repo.Provider -verbose -Scope CurrentUser
        }
        $nugetPS = Get-PackageSource $repo.Name
        if (!$nugetPS) {
            # register the NuGet package source
            Register-PackageSource -ProviderName $repo.Provider -Name $repo.Name -Location $repo.URL -verbose
        }
    }
}

<#
.SYNOPSIS
 Builds a combined PowerShell module and .NET package.
 
.DESCRIPTION
 Builds a combined PowerShell module and .NET package. The resulting package
 can be published in a PowerShell Gallery (PowerShellGet) repository,
 as well as in a .NET (NuGet) repository.
 
 Building a PowerShell Module for publication is done by the Publish-Module
 command of PowerShellGet. This command not only builds the package, but
 publishes it as well in a target repository.
 
 In order to be able to post-process the package, a file system repository
 is required. You can use an existing repository, or let the function create
 a temporary one.
 
 Note, that the repository needs to contain dependent modules, apart from the
 ones declared in the module manifest's ExternalModuleDependencies list.
 When creating multiple (dependent) packages, feed them in the right order into
 the function.
 
 The Publish-Module function packages all files in the module folder into the
 module package, without using the FileList in the module manifest.
 This function therefore copies the module manifest and the files listed in the
 FileList into a temporary folder. Publish-Module is called on this temporary
 folder, so the set of files in the package is fully controlled.
 
 If the PowerShell module contains a C# project for building a .NET library,
 the C# project is built. The resulting library assemblies are copied into the
 'lib' folder, as required for NuGet. For this process the dotnet SDK is
 expected to be available.
 
 After creation of the PowerShell module package, a few NuGet metadata elements
 that are not (yet) supported by PowerShellGet can be updated in the package
 nuspec manifest.
 
.PARAMETER Path
 The name of the module manifest file or of the folder containing the module to package.
 May contain wildcards.
 
.PARAMETER Repository
 The name of the PowerShellGet repository to publish the module into. This must be
 a repository on the local file system. If the repository does not exist, a local
 folder $RepositoryFolder is created in the current directory and temporarily
 registered as the named repository.
 
 Note that the repository needs to have a flat, i.e. non-hierarchical, structure.
 This means you cannot use `nuget add` to add packages to this repository.
 
 By default, a repository called 'LocalPSGet' is used.
 
.PARAMETER RepositoryFolder
 The name of the folder that is created in the current directory as repository
 folder, in case the specified $Repository does not exist.
 By default, the folder is '.LocalPSGet'.
 
.PARAMETER Force
 Overwrite the package if it already exists.
 
.PARAMETER MetadataElement
 One or more .nuspec metadata elements that PowerShellGet not (yet) supports
 in the module manifest, but are needed (or nice to have) in the NuGet
 specification file in the package.
 
 The contents of these elements that are present in the module manifest
 'PrivateData.PSData' section, is copied to the corresponding element in
 the nuspec file in the package.
 
.INPUTS
 String
 System.IO.FileSystemInfo
 
.OUTPUTS
 System.IO.FileInfo
 
.LINK
 https://learn.microsoft.com/en-us/powershell/scripting/gallery/how-to/publishing-packages/publishing-a-package
 
.LINK
 https://learn.microsoft.com/en-us/nuget/create-packages/overview-and-workflow
 
.EXAMPLE
 $packageFile = Build-DotNetPowerShellPackage "C:\data\projects\nl.nlsw.Document"
 
 Builds the NuGet package file nl.nlsw.Document.<version>.nupkg that contains both
 a PowerShell module as well as a .NET assembly package.
#>

function Build-DotNetPowerShellPackage {
    [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='Medium')]
    [OutputType([System.IO.FileInfo])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Justification="Is approved in PS6", Scope='Function')]
    param (
        [Parameter(Mandatory=$false, Position=0, ValueFromPipeline = $true,
            HelpMessage="Enter the name of the module folder or manifest file to process")]
        [SupportsWildcards()]
        [object]$Path = ".",

        [Parameter(Mandatory=$false)]
        [string]$Repository = "LocalPSGet",

        [Parameter(Mandatory=$false)]
        [string]$RepositoryFolder = ".LocalPSGet",

        [Parameter(Mandatory=$false)]
        [switch]$Force,

        [Parameter(Mandatory=$false)]
        [string[]]$MetadataElement = @('icon','readme','repository')
    )
    begin {
        $workFolder = [System.IO.Path]::Combine($env:TEMP,'pspackage')
        $manifestExt = ".psd1"
        $packageExt = ".nupkg"
        $temporaryRepo = $false
        if ($Repository) {
            # check the existence and usability of the repository
            $repo = Get-PSRepository -Name $Repository -ErrorAction SilentlyContinue
            if (!$repo) {
                # create the repo in the current file system location
                # create a temporary folder for packaging (PowerShellGet does not have a separate Package-Module command :-(
                $repoFolder = New-Item -ItemType Directory -Force -Path "./" -Name $RepositoryFolder
                # temporarily register the 'repository'
                Register-PSRepository -Name $Repository -SourceLocation $repoFolder.FullName -PublishLocation $repoFolder.FullName -InstallationPolicy Trusted | write-verbose
                $temporaryRepo = $true
                $repo = Get-PSRepository -Name $Repository -ErrorAction SilentlyContinue
            }
            $publishLocation = [uri]$repo.PublishLocation
            if (!$publishLocation.IsFile) {
                throw [ArgumentException]::new("repository '$Repository' does not have a PublishLocation on the file system: '$publishLocation'","Repository")
            }
            $repoFolder = get-item $publishLocation.LocalPath
        }
        else {
            throw [ArgumentException]::new("please specify a PowerShellGet repository to use","Repository")
        }

        # .NET 4.5 required for using ZipFile and friends
        Add-Type -assembly "System.IO.Compression"
        Add-Type -assembly "System.IO.Compression.FileSystem"
    }
    process {
        $Path |  foreach-object {
            # collect and filter .psd1 files
            $item = $_
            if ($item -is [string]) {
                $item = get-item $item
            }
            if ($item -is [System.IO.DirectoryInfo]) {
                $item.GetFiles("*" + $manifestExt)
            }
            elseif (($item -is [System.IO.FileInfo]) -and ($item.Extension -eq $manifestExt)) {
                $item
            }
        } | where-object { $_ } | foreach-object {
            $item = $_
            if ($PSCmdlet.ShouldProcess($item.FullName)) {
                Push-Location $item.Directory
                try {
                    # get the package name (without terminating '.')
                    $packageName = [System.IO.Path]::ChangeExtension($item.Name,"").TrimEnd('.')
                    $psd = Import-PowerShellDataFile $item.FullName
                    # determine the version of the package
                    $packageVersion = $psd.ModuleVersion
                    if ($psd.PrivateData.PSData.Prerelease) {
                        $packageVersion += '-'
                        $packageVersion += $psd.PrivateData.PSData.Prerelease
                    }
                    # determine the NuGet package name
                    $packageVersionName = $packageName + '.' + $packageVersion
                    # the package being build
                    $packageFile = [System.IO.FileInfo]::new([System.IO.Path]::Combine($repoFolder.FullName,$packageVersionName+$packageExt))
                    if ($packageFile.Exists -and !$Force)  {
                        # the package already exists
                        Write-Error (("package '$packageFile' already exists; specify -Force to overwrite"))
                        return
                    }
                    # check if a .csproj is present, if so, build that project
                    $csproj = [System.IO.FileInfo]::new([System.IO.Path]::ChangeExtension($item.FullName,".csproj"))
                    if ($csproj.Exists) {
                        $dllname = [System.IO.Path]::ChangeExtension($item.Name,".dll")
                        if (!(Get-Command dotnet)) {
                            throw [InvalidOperationException]::new(("missing 'dotnet.exe' to build the C# project {0}" -f $csproj))
                        }
                        write-verbose ("{0,16} {1}" -f "building",$csproj)
                        dotnet build | write-verbose
                        # @see https://github.com/dotnet/runtime/blob/main/docs/design/features/host-error-codes.md
                        if ($LASTEXITCODE -eq 0) {
                            # success, get the built dll(s) in the build folder
                            $dlls = Get-ChildItem -Recurse -File -Include $dllname -Path "bin/Debug/"
                            # copy dll to lib folder
                            foreach ($dll in $dlls) {
                                # create the platform folder in the lib folder
                                $platform = New-Item -ItemType Directory -Force -Path "lib/" -Name $dll.Directory.Name
                                if ($platform) {
                                    $libdll = Copy-Item $dll.FullName $platform.FullName -PassThru
                                    write-verbose ("{0,16} {1}" -f "built",$libdll.FullName)
                                }
                            }
                        }
                        else {
                            throw [InvalidOperationException]::new(("dotnet build error of C# project {0}" -f $csproj))
                        }
                    }
                    # create a copy of the folders and files to package, based on the FileList in the manifest
                    # create a temporary folder for packaging
                    $packaging = New-Item -ItemType Directory -Force -Path $workFolder -Name $packageName
                    foreach ($file in $psd.FileList) {
                        $relativePath = [System.IO.Path]::GetDirectoryName($file)
                        # create the target folder
                        $pfolder = New-Item -ItemType Directory -Force -Path $packaging.FullName -Name $relativePath
                        $pfile = Copy-Item $file $pfolder.FullName -PassThru
                        write-verbose ("{0,16} {1}" -f "including",$pfile.FullName)
                    }
                    # make sure that the manifest itself is also packaged (locate in the root of the package)
                    $pfile = Copy-Item $item.FullName $packaging.FullName -PassThru
                    write-verbose ("{0,16} {1}" -f "including",$pfile.FullName)
                    # remove the existing package
                    if ($packageFile.Exists) {
                        write-verbose ("{0,16} {1}" -f "removing",$packageFile.FullName)
                        $packageFile.Delete()
                    }
                    # now, package (and publish) the PowerShell Module
                    write-verbose ("{0,16} {1}" -f "packaging",$packaging.FullName)
                    # do not add automatic tags
                    Publish-Module -Path $pfile.DirectoryName -Repository $Repository -SkipAutomaticTags
                    # check status of published package
                    $packageFile.Refresh()
                    if ($packageFile.Exists) {
                        # check if we need to update the nuspec file with NuGet features not covered by PowerShellGet
                        $update = $false
                        if ($MetadataElement) {
                            foreach ($element in $MetadataElement) {
                                if ($psd.PrivateData.PSData.$element) {
                                    $update = $true
                                    break
                                }
                            }
                        }
                        if ($update) {
                            # define for some elements the preferred location in the metadata element
                            $successors = @{ 'icon'='p:iconUrl'; 'readme'='p:releaseNotes'; 'repository'='p:projectUrl'; }
                            write-verbose ("{0,16} {1}" -f "updating",$packageFile.FullName)
                            $zipFile = [System.IO.Compression.ZipFile]::Open($packageFile.FullName, [System.IO.Compression.ZipArchiveMode]::Update)
                            try {
                                $nuspecFileName = $packageName + '.nuspec'
                                $nuspecEntry = $zipFile.GetEntry($nuspecFileName)
                                if (!$nuspecEntry) {
                                    throw [InvalidOperationException]::new(("file '$nuspecFileName' not found in '$packageFile'"))
                                }
                                write-verbose ("{0,16} {1}:{2}" -f "reading",$packageFile.FullName,$nuspecFileName)
                                # read the nuspec as XmlDocument
                                $nuspecStream = $nuspecEntry.Open()
                                $reader = [System.IO.StreamReader]::new($nuspecStream)
                                $nuspec = [xml]$reader.ReadToEnd();
                                $nsm = [System.Xml.XmlNamespaceManager]::new($nuspec.NameTable)
                                # read the (default) namespace from the document (it differs per NuGet run)
                                $nuspecNs = $nuspec.DocumentElement.GetNamespaceOfPrefix('')
                                if ($nuspecNs) {
                                    $nsm.AddNamespace('p',$nuspecNs)
                                }
                                $reader.Dispose()
                                # write-verbose ($nuspec.OuterXml.ToString())
                                # update the nuspec
                                foreach ($element in $MetadataElement) {
                                    if ($psd.PrivateData.PSData.$element -and ($null -eq $nuspec.package.metadata.SelectSingleNode("p:$element",$nsm))) {
                                        $node = $nuspec.CreateElement($element,$nuspecNs)
                                        $value = $psd.PrivateData.PSData.$element
                                        if ($value -is [hashtable]) {
                                            foreach ($kvp in $value.GetEnumerator()) {
                                                if (![string]::IsNullOrEmpty($kvp.Value)) {
                                                    $node.SetAttribute($kvp.Key,$kvp.Value)
                                                    write-verbose (("{0,16} {1}.{2} = {3}" -f "metadata",$node.Name,$kvp.Key,$kvp.Value))
                                                }
                                            }
                                        }
                                        else {
                                            # string element
                                            $node.InnerText = $psd.PrivateData.PSData.$element
                                            write-verbose (("{0,16} {1} = {2}" -f "metadata",$node.Name,$node.InnerText))
                                        }
                                        $successor = $successors[$element]
                                        # put the readme before the releaseNotes if that exists, otherwise append at the metadata
                                        $nextSibling = if ($successor) { $nuspec.package.metadata.SelectSingleNode($successor,$nsm) } else { $null }
                                        $nuspec.package.metadata.InsertBefore($node, $nextSibling) | out-null
                                    }
                                }
                                write-verbose ("{0,16} {1}:{2}" -f "writing",$packageFile.FullName,$nuspecFileName)
                                # write the nuspec back to the zipfile
                                $nuspecStream = $nuspecEntry.Open()
                                $nuspec.Save($nuspecStream)
                                $nuspecStream.Dispose()
                            }
                            finally {
                                $zipFile.Dispose()
                            }
                        }
                    }
                }
                finally {
                    # cleanup the temporary folder
                    if ($packaging) {
                        Remove-Item $packaging -Recurse
                    }
                    Pop-Location
                }
                $packageFile.Refresh()
                if ($packageFile.Exists) {
                    write-verbose ("{0,16} {1}" -f "built",$packageFile.FullName)
                    write-output $packageFile
                }
            }
        }
    }
    end {
        if ($temporaryRepo) {
            # unregister the (temporary) repository
            Unregister-PSRepository -Name $Repository | write-verbose
        }
        # If($?){ # only execute if the function was successful.
        if (Test-path $workFolder) {
            write-verbose ("{0,16} {1}" -f "removing",$workFolder)
            Remove-Item $workFolder
        }
    }
}

<#
.SYNOPSIS
 Get a .NET package for using the included DLL in a PowerShell session.
 
.DESCRIPTION
 Using a .NET DLL that is available as NuGet package on nuget.org requires
 installation of the package and importing the assembly into the PowerShell
 session (with Add-Type).
 
 This operation tests if the package is installed. If not, it is installed
 for the CurrentUser from nuget.org. It returns the installed package.
 
.PARAMETER Name
 Specifies then name of the package. May be input via the pipeline.
 Use pipeline notation if you want to specify multiple packages.
 
.PARAMETER RequiredVersion
 Specifies the exact version of the package to find.
 
.PARAMETER MinimumVersion
 Specifies the maximum package version that you want to find.
 
.PARAMETER MaximumVersion
 Specifies the minimum package version that you want to find. If a higher
 version is available, that version is returned.
 
.PARAMETER SkipDependencies
 Skips the installation of software dependencies.
 
.INPUTS
 System.String
 
.OUTPUTS
 Microsoft.PackageManagement.Packaging.SoftwareIdentity#GetPackage
 
 Note that the Source property of the returned object contains the
 path to the locally installed package.
 
.LINK
 https://learn.microsoft.com/en-us/powershell/module/packagemanagement/get-package
 
.NOTES
 This function requires PowerShellGet 2.2.5 with PackageManagement 1.4.8.1, or better.
 If it needs to install a package, it also requires the NuGet Package Provider
 and the "nuget.org" Package Source, which in turn will also be installed
 and registered if not present already.
 
 This function is a helper for solving the problem:
 https://stackoverflow.com/questions/39257572/loading-assemblies-from-nuget-packages
 
#>

function Get-DotNetPackage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline = $true)]
        [Alias("PackageName")]
        [string]$Name,

        [Parameter(Mandatory=$false)][Alias("Version")]
        [string]$RequiredVersion,

        [Parameter(Mandatory=$false)]
        [string]$MinimumVersion,

        [Parameter(Mandatory=$false)]
        [string]$MaximumVersion,

        [Parameter(Mandatory=$false)]
        [switch]$SkipDependencies
    )
    begin {
    }
    process {
        $Name | where-object { ![string]::IsNullOrEmpty($_) } | foreach-object {
            $packageName = $_
            # create the arguments for the Get-Package function
            $attrs = [System.Collections.Generic.Dictionary[[string],[object]]]::new($PSBoundParameters)
            $attrs.Remove("SkipDependencies") | Out-Null
            # check the presence of the package
            $package = Get-Package @attrs -ErrorAction SilentlyContinue
            if (!$package) {
                # make the user aware of the installation action
                $PSBoundParameters["Verbose"] = $true
                write-verbose ("{0,16} {1}" -f "installing",$packageName)
                # First check if the NuGet package provider is available and the "nuget.org" source is registered.
                [DotNet]::Check()
                # install the package (specify the source to avoid exception in case of multiple sources)
                $source = [DotNet]::nugetRepository
                $package = Install-Package @PSBoundParameters -Scope CurrentUser -Source $source.Name -ProviderName $source.Provider
                # Install-Package returns the package location in .Payload.Directories[0]. { .Location, .Name }
                # we need to do a Get-Package, to get the package location in the .Source property.
                $package = Get-Package @attrs
            }
            write-output $package
        }
    }
    end {
    }
}

<#
.SYNOPSIS
 Import a .NET class library in a PowerShell session.
 
.DESCRIPTION
 Using a .NET DLL that is available as NuGet package on nuget.org requires
 installation of the package and importing the class library assembly into
 the PowerShell session (with Add-Type).
 
 This operation performs this operation. By default, it assumes that the
 name of the library (assembly) equals the name of the package.
 Specify the PackageName if that differs from the library assembly name.
 
.PARAMETER Name
 Specifies the class library name. This must be the name of the
 .NET library assembly (.DLL) file and the NuGet package as well.
 
 May be input via the pipeline.
 
.PARAMETER PackageName
 Specifies the name of the NuGet package that contains the class library.
 By default, the package name equals the Name parameter.
 
.PARAMETER RequiredVersion
 Specifies the exact version of the package to find.
 
.PARAMETER MinimumVersion
 Specifies the maximum package version that you want to find.
 
.PARAMETER MaximumVersion
 Specifies the minimum package version that you want to find. If a higher
 version is available, that version is returned.
 
.PARAMETER SkipDependencies
 Skips the installation of software dependencies.
 
.PARAMETER TargetFramework
 The .NET framework of the class library to import.
 By default, "netstandard2.0".
 
.INPUTS
 System.String
 
.OUTPUTS
 System.IO.FileInfo - of the loaded DLL
 
.LINK
 https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/add-type
 
.NOTES
 This function uses Get-DotNetPackage if needed.
#>

function Import-DotNetLibrary {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline = $true)]
        [string]$Name,

        [Parameter(Mandatory=$false)]
        [string]$PackageName,

        [Parameter(Mandatory=$false)][Alias("Version")]
        [string]$RequiredVersion,

        [Parameter(Mandatory=$false)]
        [string]$MinimumVersion,

        [Parameter(Mandatory=$false)]
        [string]$MaximumVersion,

        [Parameter(Mandatory=$false)]
        [switch]$SkipDependencies,

        [Parameter(Mandatory=$false)]
        [string]$TargetFramework = "netstandard2.0"
    )
    begin {
    }
    process {
        $Name | where-object { ![string]::IsNullOrEmpty($_) } | foreach-object {
            $assemblyName = $_
            # check the presence of the assembly in the session (
            $assemblies = [AppDomain]::CurrentDomain.GetAssemblies()
            if (!($assemblyName -in $assemblies.GetName().Name)) {
                # create the arguments for the Get-DotNetPackage function
                $attrs = [System.Collections.Generic.Dictionary[[string],[object]]]::new($PSBoundParameters)
                if ($PSBoundParameters["PackageName"]) {
                    $attrs.Remove("Name") | Out-Null
                }
                $attrs.Remove("TargetFramework") | Out-Null
                # Second, check the presence of the package, and install if needed
                $package = Get-DotNetPackage @attrs
                # Get the dll for the target framework
                $packageFile = get-item $package.Source
                $packageFolder = $packageFile.DirectoryName
                $assemblyFile = get-item ("$packageFolder/lib/$TargetFramework/$assemblyName.dll")
                write-verbose ("{0,16} {1}" -f "loading",$assemblyFile)
                Add-Type -Path $assemblyFile
                write-output $assemblyFile
            }
        }
    }
    end {
    }
}



Export-ModuleMember -Function *