PSModuleBuild.psm1

Function CreateUpdateManifest
{
    [CmdletBinding()]
    Param (
        [hashtable]$Manifest,
        [string]$OldManifestPath,
        [System.Collections.ArrayList]$FunctionNames
    )

    If (Test-Path $OldManifestPath)
    {
        $OldManifest = Import-LocalizedData -BaseDirectory (Split-Path $OldManifestPath) -FileName (Split-Path $OldManifestPath -Leaf)
        If ([version]$OldManifest.PowerShellVersion -gt $HighVersion)
        {
            $HighVersion = [version]$OldManifest.PowerShellVersion
        }
        If ($Manifest.PowerShellVersion -gt $HighVersion)
        {
            $HighVersion = $Manifest.PowerShellVersion
        }
        $Manifest.ReleaseNotes = $ReleaseNotes + $OldManifest.PrivateData.PSData.ReleaseNotes
        $Manifest.Path = $OldManifestPath
        $Manifest.PowerShellVersion = $HighVersion
        $Manifest.FunctionsToExport = $FunctionNames | Where-Object Private -eq $false | Select-Object -ExpandProperty Name
        If (-not $Manifest.ModuleVersion)
        {
            $VersionNum = [ordered]@{}
            $VersionFields = "Major","Minor","Build","Revision"
            $Count = 0
            ForEach ($VersionField in $VersionFields)
            {
                $VersionNum.Add($VersionField,$Count)
                $Count ++
            }
            Try {
                $OldModuleVersion = [version]$OldManifest.ModuleVersion
            }
            Catch {}
            If ($OldModuleVersion -is [version])
            {
                $Versions = @()
                ForEach ($Num in (0..3))
                {
                    $VF = $VersionFields[$Num]
                    If ($OldModuleVersion.$VF -lt 0 -and $VersionNum[$IncrementVersion] -gt $VersionNum[$Num])
                    {
                        $Versions += 0
                    }
                    ElseIf ($OldModuleVersion.$VF -lt 0 -and $IncrementVersion -eq $VF)
                    {
                        $Versions += 1
                    }
                    ElseIf ($OldModuleVersion.$VF -ge 0)
                    {
                        If ($VF -eq $IncrementVersion)
                        {
                            $Versions += $OldModuleVersion.$VF + 1
                        }
                        Else
                        {
                            $Versions += $OldModuleVersion.$VF
                        }
                    }
                }
                If ($IncrementVersion -eq "Last")
                {
                    $Versions[-1] ++
                }
                $Manifest.ModuleVersion = $Versions -join "."
            }
        }

        Update-ModuleManifest @Manifest
    }
    Else
    {
        $Manifest.RootModule = $ModuleName
        $Manifest.Path = $OldManifestPath
        $Manifest.PowerShellVersion = "$($HighVersion.Major).$($HighVersion.Minor)"
        $Manifest.FunctionsToExport = $FunctionNames | Where-Object Private -eq $false | Select-Object -ExpandProperty Name
        If ($ReleaseNotes)
        {
            $Manifest.ReleaseNotes = $ReleaseNotes | Out-String
        }
        New-ModuleManifest @Manifest
    }

    Return $Manifest
}
Function Invoke-PSModuleBuild {

    <#
    .SYNOPSIS
        Easily build PowerShell modules for a set of functions contained in individual PS1 files

    .DESCRIPTION
        Creating a PowerShell module can be hard, and maintaining it can be even harder. PSModuleBuild has been designed to make
        both tasks easier. In short, you put all of your advanced functions into individual .ps1 files and then invoke PSModuleBuild
        and let it collect all the functions into a PowerShell module file (.psm1) and create the PowerShell module manifest
        file (.psd1).

        Files can be excluded from processing by putting these keywords in their name:
            exclude
            tests
            psake\.ps1
            ^build\.ps1$
            \.psdeploy\.

        Public and private functions, as well as Classes, are also support by creating the proper folder structure:

        \Source
           \Public
           \Private
           \Classes

        If you have any scripts or cmdlets that need to be run at Import-Module time, you can put them in an Include.txt
        file and PSModuleBuild will read this file first and put it in the module file first. This is not strictly needed
        as PSModuleBuild will read in all .ps1 files and put them in but if you'd like to make sure these commands are run at
        the beginning of the file you can.


    .PARAMETER Path
        The path Where-Object you module folders and PS1 files containing your functions is located.

    .PARAMETER TargetPath
        The path Where-Object you want the module and manifest files to be located. If the folder does not exist Invoke-PSModuleBuild will create it, if
        it is not designated it will use the Path location. Multiple folder paths can be designated as well if you want to deploy to multiple
        locations.

    .PARAMETER ModuleName
        What you want to call your module. By default the module will be named after the folder you point
        to in Path.

    .PARAMETER Passthru
        Will produce an object with information about the newly created module

    .PARAMETER ReleaseNotes
        Any release notes you want to include in the module manifest. If a manifest file already exists Invoke-PSModuleBuild will
        read the release notes from it and join the new release notes together.

    .PARAMETER IncrementVersion
        If a previous manifest file is located, PSModuleBuild will automatically increment the version number (unless you set this parameter to
        "None"). Set which field you want to increment "major", "minor", "build" or "revision" or set it to "last" and it will increment the last
        field that previous existed. For example:

        1.1 would become 1.2
        1.0.0.3 would become 1.0.0.4

        Default settings is "Last"

    .PARAMETER Include
        Use the Include parameter to specify additional files (help files, xml, etc) that you want to include in the
        module.

    .INPUTS
        None
    
    .OUTPUTS
        [PSCustomObject]
    
    .EXAMPLE
        Invoke-PSModuleBuild -Path c:\Test-Module

        Module will be named Test-Module (.psm1 and .psd1) and will include all functions in that path.

    .EXAMPLE
        Invoke-PSModuleBuild -Path c:\Test-Module -ModuleName Make-GreatStuff -Passthru

        Module will be named Make-GreatStuff. Returned object will be:

        Name : Make-GreatStuff
        Path : c:\Test-Module
        ManifestPath : c:\Test-Module\Test-Module.psd1
        ModulePath : c:\Test-Module\Test-Module.psm1
        PublicFunctions : {Test1, Test2}
        PrivateFunctions: {Test3}

    .NOTES
        Author: Martin Pugh
        Twitter: @thesurlyadm1n
        Spiceworks: Martin9700
        Blog: www.thesurlyadmin.com

        Changelog:
        1.0 Initial Release
        1.0.9 Moved from RegEx to AST for function parsing
        1.0.10 Updated comment based help. Added Passthru parameter
        1.0.11 Updated comment based help. Exclude psake.ps1, build.ps1 and .psdeploy. from function import.
                        Added BuildVersion
        1.0.12 Removed BuildVersion. Added dynamic parameters from New-ModuleManifest.
        1.0.13 Removed a debugging line.
        1.0.14 Rename to Invoke-PSModuleBuild and create module named PSModuleBuild. Added ReleaseNotes support (New and Update-ModuleManifest treat ReleaseNotes differently)
        1.0.15 Updated comment based help
        1.1 Added multiple target paths
        1.1.38 Fixed bug with release notes. Added IncrementVersion
        1.2 Added Class support
        1.2.x Found bug in $Files collection, have to make sure no nulls
        1.2.x Found bug Where-Object classes that have multiple functions (a common thing in a class) are being rejected. Simply going to remove that functionality
                        when processing files in the Classes folder.
        1.3 Added Include parameter to specify additional files to include in the module. Files/Folders will
                        be copied into the TargetPath(s).
    .LINK
        https://github.com/martin9700/PSModuleBuild
    #>

    [CmdletBinding()]
    Param (
        [ValidateScript({ Test-Path $_ })]
        [string]$Path,
        [string[]]$TargetPath,
        [string]$ModuleName,
        [switch]$Passthru,
        [string[]]$ReleaseNotes,
        [ValidateSet("None","Last","Major","Minor","Build","Revision")]
        [string]$IncrementVersion = "Last",
        [string[]]$Include
    )
    DynamicParam {
        # Create the dictionary that this scriptblock will return:
        $DynParamDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
        $CommonParams = [System.Management.Automation.PSCmdlet]::CommonParameters
        $CommonParams += [System.Management.Automation.PSCmdlet]::OptionalCommonParameters           
            
        # Get dynamic params that real Cmdlet would have:
        $Parameters = Get-Command -Name New-ModuleManifest | Select-Object -ExpandProperty Parameters
        ForEach ($Parameter in $Parameters.GetEnumerator()) 
        {
            If ($CommonParams -notcontains $Parameter.Key)
            {
                $DynamicParameter = New-Object System.Management.Automation.RuntimeDefinedParameter (
                    $Parameter.Key,
                    $Parameter.Value.ParameterType,
                    $Parameter.Value.Attributes
                )
                #Added in check to not add Name or NotificationEmail parameters because they are defined in static parameters
                If (-not $DynParamDictionary.ContainsKey($Parameter.Key) -and $Parameter.Key -notmatch "Path|Passthru|ReleaseNotes")
                {
                    $DynParamDictionary.Add($Parameter.Key, $DynamicParameter)
                }
            }
        
        }
        # Return the dynamic parameters
        $DynParamDictionary
    }

    END {
        Write-Verbose "$(Get-Date): Invoke-PSModuleBuild started"

        If (-not $Path)
        {
            $Path = $PSScriptRoot
        }

        If ($TargetPath)
        {
            ForEach ($TP in $TargetPath)
            {
                If (-not (Test-Path $TP))
                {
                    New-Item -Path $TP -ItemType Directory
                }
            }
        }
        Else
        {
            $TargetPath = $Path
        }

        If (-not $ModuleName)
        {
            $ModuleName = Get-ItemProperty -Path $Path | Select-Object -ExpandProperty BaseName
        }

        $Module = New-Object -TypeName System.Collections.ArrayList
        $FunctionNames = New-Object -TypeName System.Collections.ArrayList
        $FunctionPredicate = { 
            ($args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]) 
        }
        $HighVersion = [version]"2.0"

        Write-Verbose "$(Get-Date): Searching for ps1 files and include.txt for module"

        #Retrieve ps1 files
        $Files = New-Object -TypeName System.Collections.ArrayList
        $RawFiles = Get-ChildItem $Path -Include *.ps1,include.txt -File -Recurse | 
            Where-Object FullName -NotMatch "Exclude|Tests|psake\.ps1|^build\.ps1|\.psdeploy\." | 
            Sort-Object Name
        
        #Include.txt always goes first
        $IncludeFiles = $RawFiles | Where-Object Name -eq "include.txt"
        If ($IncludeFiles)
        {
            ForEach ($File in $IncludeFiles)
            {
                $null = $Files.Add($File)
            }
        }

        #Classes next
        ForEach ($File in ($RawFiles | Where-Object FullName -like "*Classes*"))
        {
            $null = $Files.Add($File)
        }

        #All the rest
        ForEach ($File in $RawFiles)
        {
            If (-not $Files.Contains($File))
            {
                $null = $Files.Add($File)
            }
        }
            
        ForEach ($File in $Files)
        {
            $Raw = Get-Content -Path $File.FullName -Raw
            If (-not $Raw)
            {
                Write-Warning "File ""$($File.Name)"" is empty, skipping"
                Continue
            }

            $Private = $false
            If ($File.DirectoryName -like "*Private*" -or $File.DirectoryName -like "*classes*")
            {
                $Private = $true
            }
            $null = $Module.Add($Raw)

            #Parse out the function names
            #Thanks Zachary Loeber
            $ParseError = $null
            $Tokens = $null
            $AST = [System.Management.Automation.Language.Parser]::ParseInput($Raw, [ref]$Tokens, [ref]$ParseError)
            If ($ParseError)
            {
                Write-Error "Unable to parse $($File.FullName) because ""$ParseError""" -ErrorAction Stop
            }

            ForEach ($Name in ($AST.FindAll($FunctionPredicate, $true) | Select-Object -ExpandProperty Name))
            {
                If ($FunctionNames.Name -contains $Name -and $File.Directory -notlike "*classes*")
                {
                    Write-Error "Your module has duplicate function names: $Name. Duplicate found in $($File.FullName)" -ErrorAction Stop
                }
                Else
                {
                    If ($FunctionNames.Name -notcontains $Name)
                    {
                        $null = $FunctionNames.Add([PSCustomObject]@{
                            Name = $Name
                            Private = $Private
                        })
                    }
                }
            }

            If ($AST.ScriptRequirements.RequiredPSVersion -gt $HighVersion)
            {
                $HighVersion = $AST.ScriptRequirements.RequiredPSVersion
            }
        }

        #Create the manifest file
        Write-Verbose "$(Get-Date): Creating/Updating module manifest and module file"
        $NewManifest = @{}
        
        $Keys = $PSBoundParameters.GetEnumerator() | 
            Where-Object { $_.Key -NotMatch "Path|Passthru|ModuleName|IncrementVersion|Include" -and $CommonParams -notcontains $_.Key }
        ForEach ($Key in $Keys)
        {
            $NewManifest.Add($Key.Key,$Key.Value)
        }
        ForEach ($TP in $TargetPath)
        {
            # Copy Include files
            If ($Include)
            {
                ForEach ($IncPath in $Include)
                {
                    $FullPath = Join-Path -Path $Path -ChildPath $IncPath
                    Try {
                        $Property = Get-ItemProperty -Path $FullPath -ErrorAction Stop
                    }
                    Catch {
                        Write-Error $_
                        Continue
                    }
                    If (($Property.Attributes -band ([Io.FileAttributes]::Directory).ToString()) -eq "Directory")
                    {
                        $FullPath = Join-Path -Path $FullPath -ChildPath "*"
                    }
                    Copy-Item -Path $FullPath -Destination $TP -Recurse -Force
                }
            }
            #Save the manifest
            $ManifestPath = Join-Path -Path $TP -ChildPath "$ModuleName.psd1"
            $ResultManifest = CreateUpdateManifest -Manifest $NewManifest.Clone() -OldManifestPath $ManifestPath -FunctionNames $FunctionNames

            #Save the module file
            $ModulePath = Join-Path -Path $TP -ChildPath "$ModuleName.psm1"
            $Module | Out-File $ModulePath -Encoding ascii
            Write-Verbose "Module created at: $TP as $ModuleName"
        }

        #Passthru
        If ($Passthru)
        {
            [PSCustomObject]@{
                Name             = $ModuleName
                SourcePath       = $Path
                TargetPath       = $TargetPath
                ManifestPath     = $ManifestPath
                ModulePath       = $ModulePath
                ModuleVersion    = $ResultManifest.ModuleVersion
                RequiredVersion  = $ResultManifest.PowerShellVersion
                PublicFunctions  = @($FunctionNames | Where-Object Private -eq $false | Select-Object -ExpandProperty Name)
                PrivateFunctions = @($FunctionNames | Where-Object Private -eq $true | Select-Object -ExpandProperty Name)
                ReleaseNotes     = $ResultManifest.ReleaseNotes
            }
        }

        Write-Verbose "$(Get-Date): Invoke-PSModuleBuild completed."
    }
}