PowerShellModuleTools.psm1
function Get-ModuleMarkup { <# .SYNOPSIS Create Markup language content to automatically document PSModule .DESCRIPTION Create Markup language content to automatically document PSModule .PARAMETER ModuleName Name of module to document. Defaults to: Split-Path -Path (Get-Location) -Leaf .EXAMPLE Get-ModuleMarkup .EXAMPLE Get-ModuleMarkup | Out-File -FilePath .\FUNCTIONS.md -Encoding utf8 #> [CmdletBinding()] param ( [Parameter()] [string] $ModuleName ) if (! $ModuleName) { $ModuleName = Split-Path -Path (Get-Location) -Leaf Write-Verbose -Message 'ModuleName not set, setting it to: $ModuleName' } '# {0}' -f $ModuleName '' 'Text in this document is automatically created - don''t change it manually' '' '## Index' '' foreach ($function in (Get-Command -Module $ModuleName -CommandType Function)) { '[{0}](#{1})<br>' -f $function.Name, $function.Name } '' '## Functions' '' foreach ($function in (Get-Command -Module $ModuleName -CommandType Function)) { '<a name="{0}"></a>' -f $function.Name '### {0}' -f $function.Name '' '```' $function | Get-Help -Detailed '```' '' } } function Invoke-ModuleBuild { <# .SYNOPSIS Build module .DESCRIPTION Build module - combine files to psm1, create psd1, zip it, ... .PARAMETER Path Path .EXAMPLE Invoke-ModuleBuild #> [CmdletBinding(DefaultParameterSetName = 'JsonFile')] [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', 'type', Justification='Variable IS used, ScriptAnalyzer is wrong')] [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', 'h', Justification='Variable IS used, ScriptAnalyzer is wrong')] [System.Diagnostics.CodeAnalysis.SuppressMessage('PSPossibleIncorrectUsageOfAssignmentOperator', '', Justification='I like assigning values to variables in if statements')] param ( [Parameter()] [String] $Path, [Parameter(ParameterSetName = 'JsonFile')] [String] $JsonFile = 'Build.json', [Parameter(ParameterSetName = 'InputObject', Mandatory = $true, ValueFromPipeline = $true)] [PSCustomObject] $InputObject, [Parameter(ParameterSetName = 'Module', Mandatory = $true)] [Switch] $Module, [Parameter(ParameterSetName = 'ScriptFromTemplate', Mandatory = $true)] [Switch] $ScriptFromTemplate, [Parameter(ParameterSetName = 'ScriptFromFunction', Mandatory = $true)] [Switch] $ScriptFromFunction, [Parameter()] [String] $SourceRoot = '.', [Parameter()] [String] $BuildRoot = 'Build', [Parameter()] [String] $ManifestFile = 'Manifest.psd1', [Parameter()] [String[]] $FunctionExportFile = @('FunctionExport\*.ps1'), [Parameter()] [String[]] $FunctionPrivateFile = @('FunctionPrivate\*.ps1'), [Parameter()] [String[]] $ClassFile = @('Class\*.ps1'), [Parameter()] [String[]] $AliasFile = @('Alias\*.ps1'), [Parameter()] [String[]] $ExtraPSFile = @('Include\*.ps1'), [Parameter()] [String] $IncludeFileDir = 'IncludeFile', [Parameter()] [Version] $Version, [Parameter()] [switch] $VersionAppendDate, [Parameter()] [Switch] $NoTrim, [Parameter()] [ScriptBlock[]] $BeforeZip = @(), [Parameter()] [ScriptBlock[]] $AfterZip = @(), [Parameter(ParameterSetName = 'Module')] [Parameter(ParameterSetName = 'ScriptFromTemplate', Mandatory = $true)] [Parameter(ParameterSetName = 'ScriptFromFunction', Mandatory = $true)] [String] $TargetName, [Parameter(ParameterSetName = 'Module')] [Parameter(ParameterSetName = 'ScriptFromTemplate')] [Parameter(ParameterSetName = 'ScriptFromFunction')] [Guid] $Guid, [Parameter(ParameterSetName = 'Module')] [Hashtable] $ManifestParameters = @{}, [Parameter(ParameterSetName = 'ScriptFromTemplate', Mandatory = $true)] [String] $Template, [Parameter(ParameterSetName = 'ScriptFromFunction', Mandatory = $true)] [String] $Function, [Parameter(ParameterSetName = 'ScriptFromFunction')] [String[]] $HelperFunction = @(), [Parameter()] [Switch] $InstallModule, [Parameter()] [string] $InstallModulePath = 'CurrentUser' ) begin { Write-Verbose -Message 'Begin' $originalErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = 'Stop' # Error in this is terminating $null = Add-Type -AssemblyName 'System.IO.Compression.FileSystem' # Error in this is terminating if ($Path) { Write-Verbose -Message "cd $Path" Push-Location -Path $Path -StackName Invoke-ModuleBuild $null = $PSBoundParameters.Remove('Path') } # Get System.IO.FileInfo object from strings # Paths is relative to $SourceRoot (unless it's a full path) # Don't want to show errors if directory doesn't exist function GetFileInfo ([String[]] $Path) { foreach ($p in $Path) { if (-not [System.IO.Path]::IsPathRooted($p)) { $p = Join-Path -Path $SourceRoot -ChildPath $p } Get-Item -Path $p -ErrorAction SilentlyContinue | Where-Object -FilterScript {$_ -is [System.IO.FileInfo]} } } # Map info from GetFileInfo() in to hashtable # Key is BaseName (filename without extension). Fail if multiple files with same basename is found function GetFileInfoMap ([String[]] $Path) { $h = @{} foreach ($p in (GetFileInfo @PSBoundParameters)) { $null = $h.Add($p.BaseName, $p) } $h } # Create new directory - rename existing if that exist function CreateDirectory ($Path) { if (Test-Path -Path $Path) { Move-Item -Path $Path -Destination ($Path + ('_old_{0}' -f (Get-Date -Format yyyyMMddTHHmmssffff))) } New-Item -ItemType Directory -Path $Path } # Create file with content # Return System.IO.FileInfo function CreateFile ([String] $Dir, [String] $Name, [String] $Content, [Switch] $NoTrim) { if ($Dir) { $Name = Join-Path -Path $Dir -ChildPath $Name } if (-not $NoTrim) { # Trim trailing spaces $Content = $Content -replace '( |\t)+((\r)?\n|$)','$2' } Set-Content -Path $Name -Value $Content Get-Item -Path $Name } # Easy join multiple parts of a path function JoinPath ([array] $Path) { if ($Path.Length -gt 1) { Join-Path -Path $Path[0] -ChildPath (JoinPath -Path ($Path | Select-Object -Skip 1)) } else { $Path } } } process { Write-Verbose -Message "Process (ParameterSetName: $($PSCmdlet.ParameterSetName))" Write-Verbose -Message ("PSBoundParameters: " + (ConvertTo-Json -Depth 1 -InputObject $PSBoundParameters)) try { if ($PSCmdlet.ParameterSetName -eq 'JsonFile') { # Convert JSON to object # Raise error if -JsonFile was specified and if the file does not exist # Show warning an continue with empty object if JSON file isn't found but -JsonFile wasn't specified $throw = $PSBoundParameters.Remove('JsonFile') try { $json = Get-Content -Path $JsonFile -Raw Write-Verbose -Message "Input JSON: $json" if (-not ($objects = $json | ConvertFrom-Json)) { Write-Error -Message 'No objects found in JSON' } } catch { if ($throw) { Write-Error -Exception $_.Exception } else { Write-Warning -Message "Problems with <$JsonFile>: $_" $objects = New-Object -TypeName PSCustomObject } } # Call recursive (with -InputObject implicit) Write-Verbose -Message ("Call recursive: " + (ConvertTo-Json -Depth 1 -InputObject $PSBoundParameters)) $objects | & $PSCmdlet.MyInvocation.MyCommand.Name @PSBoundParameters } elseif ($PSCmdlet.ParameterSetName -eq 'InputObject') { # Merge data from InputObject and parameters (PSBoundParameters) # parameters normally override info from InputObject (BeforeZip/AfterZip get merged) # Call recursive # PSBoundParameters needs to be cloned, else it's the same each time process is run Write-Verbose -Message ("Input object: " + (ConvertTo-Json -Depth 1 -InputObject $InputObject)) $params = ([Hashtable] $PSBoundParameters).Clone() $null = $params.Remove('InputObject') $type = 'Module' # If we don't cast it as [PSCustomObject] then piping hashtables will show error (A parameter cannot be found that matches parameter name 'SyncRoot') ([PSCustomObject] $InputObject).PSObject.Properties | ForEach-Object -Process { Write-Verbose -Message "Processing object property $($_.Name)" if ($_.Name -eq 'Type') { # Module, ScriptFromTemplate or ScriptFromFunction - Module is default $type = $_.Value } elseif (@('BeforeZip', 'AfterZip').Contains($_.Name)) { # Commands defined in JSON comes first in array if (-not $params[$_.Name]) { # Empty array not $null $params[$_.Name] = @() } # If BeforeZip/AfterZip comes from commandline it's ScriptBlock, if it comes from JSON it's a string $params[$_.Name] = @($_.Value | ForEach-Object -Process {[ScriptBlock]::Create($_)}) + $params[$_.Name] } elseif (@('ManifestParameters').Contains($_.Name)) { # Convert object to hashtable - only one level # ConvertFrom-Json never return Hashtable, only PSCustomObject # For now it's only ManifestParameters that get's converted $params.Add($_.Name, ($_.Value.PSObject.Properties | ForEach-Object -Begin {$h=@{}} -Process {$h[$_.Name]=$_.Value} -End {$h})) } else { # parameters override info from InputObject try { $params.Add($_.Name, $_.Value) } catch { Write-Verbose -Message "<$($_.Name)> is defined both as parameter and in object" } } } # Module, ScriptFromTemplate or ScriptFromFunction is a switch $params[$type] = $true # Call recursive Write-Verbose -Message ("Call recursive: " + (ConvertTo-Json -Depth 1 -InputObject $params)) & $PSCmdlet.MyInvocation.MyCommand.Name @params } elseif ($PSCmdlet.ParameterSetName -eq 'ScriptFromFunction') { $templateContent = (.{ "<#PSScriptInfo" "`t.VERSION {{var:Version}}" "`t.GUID {{var:Guid}}" "`t.AUTHOR {{manifest:Author}}" "`t.COMPANYNAME {{manifest:CompanyName}}" "`t.COPYRIGHT {{manifest:Copyright}}" "`t.TAGS {{manifest:PrivateData.PSData.Tags}}" "`t.LICENSEURI {{manifest:PrivateData.PSData.LicenseUri}}" "`t.PROJECTURI {{manifest:PrivateData.PSData.ProjectUri}}" "`t.ICONURI {{manifest:PrivateData.PSData.IconUri}}" "`t.EXTERNALMODULEDEPENDENCIES {{manifest:PrivateData.PSData.ExternalModuleDependencies}}" "`t.REQUIREDSCRIPTS " "`t.EXTERNALSCRIPTDEPENDENCIES " "`t.RELEASENOTES {{manifest:PrivateData.PSData.ReleaseNotes}}" "#>" "" "{{function:$($Function):help}}" "{{function:$($Function):param}}" "" "" $HelperFunction | ForEach-Object -Process {"{{function:$($_)}}"} "{{function:$($Function)}}" "" "$($Function) @PSBoundParameters" }) -join "`r`n" $tmp = New-TemporaryFile Set-Content -Path $tmp -Value $templateContent # Remove/add parameters @('ScriptFromFunction', 'Function', 'HelperFunction') | ForEach-Object -Process { $null = $PSBoundParameters.Remove($_) } $PSBoundParameters['ScriptFromTemplate'] = $true $PSBoundParameters['Template'] = $tmp # Call recursive Write-Verbose -Message ("Call recursive: " + (ConvertTo-Json -Depth 1 -InputObject $PSBoundParameters)) & $PSCmdlet.MyInvocation.MyCommand.Name @PSBoundParameters } else { # Every external value in here comes as parameter ($PSBoundParameters). JSON has been converted to a paramter Write-Verbose -Message ("Input params: " + (ConvertTo-Json -Depth 1 -InputObject $PSBoundParameters)) # We assume that a function/class can be found in a file with the same name! This COULD be changed in future version # Hashtable: key is BaseName, value is System.IO.FileInfo $functionsExport = GetFileInfoMap -Path $FunctionExportFile $functions = GetFileInfoMap -Path ($FunctionPrivateFile + $FunctionExportFile) $classes = GetFileInfoMap -Path $ClassFile $aliases = GetFileInfoMap -Path $AliasFile # (Array of) System.IO.FileInfo $psFiles = GetFileInfo -Path ($ClassFile + $FunctionPrivateFile + $FunctionExportFile + $AliasFile + $ExtraPSFile) $manifestFileInfo = GetFileInfo -Path $ManifestFile # Manifest files are just PowerShell code containing one hashtable and can therefore be parsed by just running the content (is this secure!?) $manifest = & ([ScriptBlock]::Create(('' + ($manifestFileInfo | Get-Content -Raw)))) # TODO: Shoud we create a class instead of using PSCustomObject # This object is sent to BeforeZip/AfterZip $variables = [PSCustomObject] @{ Type = $PSCmdlet.ParameterSetName TargetName = $null TargetDirectory = $null Guid = $null Version = $null FunctionsExport = $null # Module Functions = $null # Module Classes = $null # Module Aliases = $null # Module Psm1 = $null # Module Psd1 = $null # Module Ps1 = $null # ScriptFromTemplate or ScriptFromFunction TargetZip = $null } # TargetName if (-not ($variables.TargetName = $TargetName)) { if ($variables.Type -eq 'Module') { if ($manifest.RootModule) { $variables.TargetName = $manifest.RootModule -replace '\.psm1$' } else { $variables.TargetName = (Get-Location | Get-Item).Name } } else { Write-Error -Message 'TargetName not defined' } } # TargetDirectory $variables.TargetDirectory = CreateDirectory -Path (Join-Path -Path $BuildRoot -ChildPath $variables.TargetName) # Guid if ($Guid) { $variables.Guid = $Guid } elseif ($manifest.Guid -and $variables.Type -eq 'Module') { $variables.Guid = $manifest.Guid } else { $variables.Guid = [Guid]::NewGuid() } # Version if ($Version) { $variables.Version = [version] $Version } elseif ($manifest.ModuleVersion) { $variables.Version = [version] $manifest.ModuleVersion } else { $variables.Version = [version] '0.1' } if ($VersionAppendDate) { $variables.Version = [version]::new($variables.Version.Major, $variables.Version.Minor, (Get-Date -Format yyyyMMdd), (Get-Date -Format HHmmss)) } if ($PSCmdlet.ParameterSetName -eq 'Module') { $variables.FunctionsExport = [String[]] @($functionsExport.Keys) $variables.Functions = [String[]] @($functions.Keys) $variables.Classes = [String[]] @($classes.Keys) $variables.Aliases = [String[]] @($aliases.Keys) # All ps1 files concenated and som Exporte-ModuleMember in the bottom # Order of ps1 files is seen when $psFiles is defined $psm1Content = (.{ $psFiles | ForEach-Object -Process {Get-Content -Raw -Path $_} $variables.FunctionsExport | ForEach-Object -Process {"Export-ModuleMember -Function $_"} $variables.Aliases | ForEach-Object -Process {"Export-ModuleMember -Alias $_"} }) -join "`r`n" # Create module file $variables.Psm1 = CreateFile -Dir $variables.TargetDirectory -Name "$($variables.TargetName).psm1" -Content $psm1Content -NoTrim:$NoTrim # Include extra files if (Test-Path -Path $IncludeFileDir) { Copy-Item -Recurse -Path (JoinPath $IncludeFileDir,'*') -Destination $variables.TargetDirectory } $psd1Tmp = Join-Path -Path $variables.TargetDirectory -ChildPath tmp.psd1 if ($manifestFileInfo) { Copy-Item -Path $manifestFileInfo -Destination $psd1Tmp } else { New-ModuleManifest -Path $psd1Tmp } # Update ReleaseNotes with git info if (-not $ManifestParameters['ReleaseNotes']) { try { if ($gitCommit = git rev-parse HEAD) { if ($gitStatus = git status -s) { Write-Warning -Message ($ManifestParameters['ReleaseNotes'] = "git commit $gitCommit (with uncommitted changes)") } else { $ManifestParameters['ReleaseNotes'] = "git commit $gitCommit" } } else { throw } } catch { Write-Verbose -Message 'Not adding git commit id to release note, maybe git is not installed' } } # ManifestParameters come as hashtable from parameter. Add other values # - but not if they are $null/$false and not if parameter already come from command line (-Path is an exception) $ManifestParameters['Path'] = $psd1Tmp @{ ModuleVersion = $variables.Version AliasesToExport = $variables.Aliases FunctionsToExport = $variables.FunctionsExport Guid = $variables.Guid RootModule = $variables.Psm1.Name }.GetEnumerator() | ForEach-Object -Process { if ($_.Value -and -not $ManifestParameters.ContainsKey($_.Key)) { $ManifestParameters.Add($_.Key, $_.Value) } } Update-ModuleManifest @ManifestParameters # Manifest $psd1Content = Get-Content -Path $psd1Tmp -Raw Remove-Item -Path $psd1Tmp if (-not $NoTrim) { # Update-ModuleManifest produces nice comments for each setting, but top comments about who ran the command and such will be stripped $psd1Content = $psd1Content -replace '^(#.*(\r?\n)+)*','' } $variables.Psd1 = CreateFile -Dir $variables.TargetDirectory -Name "$($variables.TargetName).psd1" -Content $psd1Content -NoTrim:$NoTrim } elseif ($PSCmdlet.ParameterSetName -eq 'ScriptFromTemplate') { # Replace all {{xxx}}, {{xxx:yyy}}, ... - run though ScriptBlock that will return replaced text $ps1Content = ([Regex] '\{\{((:?([\w\\\*\.-]+))+)\}\}').Replace((Get-Content -Path $Template -Raw), { # TODO: Decide if we should throw on errors or just show warnings? # Not using param() but $args instead. If using param() we don't have access to $PSBoundParameters for main function # $m is like $Matches, $v is an array: '{{xxx:yyy:zzz}}' will be @('xxx','yyy','zzz') $m = $args[0] $fullReplace = $m.Groups[0] Write-Verbose -Message "Replacing $fullReplace" $v = @($m.Groups[3].Captures.Value) if ($v[0] -eq 'function') { # {{function:xxx}} / {{function:xxx:param}} / {{function:xxx:help}} # We still assume one function == one file if ($functions.Contains($v[1])) { $f = $functions[$v[1]] | Get-Content -Raw if (-not $v[2]) { # no third block, just return function (content of file) $f } else { # TODO: this isn't bulletproof $astTokens = $astErr = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput($f, [ref] $astTokens, [ref] $astErr) if ($astErr) { Write-Warning -Message "Error parsing AST. Not replacing $fullReplace." } else { try { if ($v[2] -eq 'param') { # params() block from function # TODO make it work with function F ($xx) type of parameter $ast.EndBlock.Statements[0].Body.ParamBlock.Extent.Text } elseif ($v[2] -eq 'help') { # TODO: This only works with <# #> in beginning - not in end of function or with # on each line # and we just take the first comment - not follow rules in (eg. max one line break, parameter help can come from comment above parameter, ...) # https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help $astTokens.Where({$_.Kind -eq 'Comment'}).Item(0).Text } else { Write-Warning -Message "$($v[2]) is an unkown third option to function" } } catch { Write-Warning -Message "Not able to get $($v[2]) from function $($v[1])" } } } } else { Write-Warning -Message "No file found with function name $($v[1])" } } elseif ($v[0] -eq 'var') { # {{var:xxx}} if ($variables.($v[1])) { $variables.($v[1]) } else { Get-Variable -Name $v[1] -ValueOnly } } elseif ($v[0] -eq 'manifest' -or $v[0] -eq 'param') { # {{manifest:xxx.yyy.zzz}} / {{param:xxx.yyy.zzz}} if ($v[0] -eq 'manifest') { $value = $manifest } elseif ($v[0] -eq 'param') { $value = $PSBoundParameters } foreach ($key in ($v[1] -split '\.')) { $value = $value[$key] } $value } elseif ($v[0] -eq 'file') { # {{file:dir\file.txt}} (GetFileInfo -Path $v[1] | Get-Content -Raw) -join "`r`n" } }) # Module file $variables.Ps1 = CreateFile -Dir $variables.TargetDirectory -Name "$($variables.TargetName).ps1" -Content $ps1Content -NoTrim:$NoTrim } else { throw "Unknown ParameterSetName: $($PSCmdlet.ParameterSetName) - this should never happen - will come up as missed in Invoke-Pester -CodeCoverage" } # User defined ScriptBlock's. The second ForEach-Object is just to get $variables become $_ inside the ScriptBlock $BeforeZip | ForEach-Object -Process {$variables | ForEach-Object -Process $_} # Rename existing zip if that exist $targetZip = "$($variables.TargetDirectory)-$($variables.Version).zip" if (Test-Path -Path $targetZip) { Move-Item -Path $targetZip -Destination ($targetZip -replace '.zip$',('_old_{0}.zip' -f (Get-Date -Format yyyyMMddTHHmmssffff))) } # Zip [System.IO.Compression.ZipFile]::CreateFromDirectory($variables.TargetDirectory, $targetZip) $variables.TargetZip = Get-Item -Path $targetZip # User defined ScriptBlock's. The second ForEach-Object is just to get $variables become $_ inside the ScriptBlock $AfterZip | ForEach-Object -Process {$variables | ForEach-Object -Process $_} function JoinPath ([array] $Path) { if ($Path.Length -gt 1) { Join-Path -Path $Path[0] -ChildPath (JoinPath -Path ($Path | Select-Object -Skip 1)) } else { $Path } } # Install module if ($PSCmdlet.ParameterSetName -eq 'Module') { if ($InstallModule) { $importModule = $false if (@('AllUsers','CurrentUser') -contains $InstallModulePath) { $importModule = $true $InstallModulePath = JoinPath @( &{if ($InstallModulePath -eq 'AllUsers') {$env:ProgramFiles} else {[Environment]::GetFolderPath('MyDocuments')}} &{if ($PSVersionTable.ContainsKey('PSEdition') -and $PSVersionTable.PSEdition -eq 'Core') {'PowerShell'} else {'WindowsPowerShell'}} 'Modules' $variables.TargetName $variables.Version ) } Write-Verbose -Message "Installing module in $InstallModulePath" $moduleInstallDir = CreateDirectory -Path $InstallModulePath [System.IO.Compression.ZipFile]::ExtractToDirectory($variables.TargetZip.FullName, $moduleInstallDir.FullName) if ($importModule) { Write-Verbose -Message "Importing module $($variables.TargetName) version $($variables.Version)" Remove-Module -Name $variables.TargetName -ErrorAction SilentlyContinue -Force Import-Module -Name $variables.TargetName -RequiredVersion $variables.Version } } } } } catch { # If error was encountered inside this function then stop doing more # But still respect the ErrorAction that comes when calling this function # And also return the line number where the original error occured in verbose output Write-Verbose -Message "Detailed error info: $_`r`n$($_.InvocationInfo.PositionMessage)" Write-Error -ErrorAction $originalErrorActionPreference -Exception $_.Exception } } end { if ($Path) { Pop-Location -StackName Invoke-ModuleBuild -ErrorAction SilentlyContinue } Write-Verbose -Message 'End' } } function New-ModuleBuildStructure { <# .SYNOPSIS New module .DESCRIPTION New module .PARAMETER Path Path .EXAMPLE New-ModuleBuildStructure #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] param ( [Parameter()] [String] $Path, [Parameter()] [String[]] $Directory = @('Alias', 'Class', 'FunctionExport', 'FunctionPrivate', 'Include', 'IncludeFile'), [Parameter()] [String] $JsonFile = 'Build.json', [Parameter()] [String] $ManifestFile = 'Manifest.psd1', [Parameter()] [String] $BuildFile = 'Build.ps1', [Parameter()] [String] $TargetName, [Parameter()] [Switch] $IncludeExamples, [Parameter()] [Hashtable] $ManifestParameters = @{} ) Write-Verbose -Message "Begin (ParameterSetName: $($PSCmdlet.ParameterSetName))" $originalErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = 'Stop' # Other variables that (for new) doesn't come in as parameter $GitIgnoreFile = '.gitignore' # Check if file or directory exist and run scriptblock if it doesn't function CreatePath { param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [String] $Path, [Parameter(Mandatory = $true)] [ScriptBlock] $ScriptBlock, [Parameter()][ValidateSet('File', 'Directory')] [String] $Type = 'File', [Parameter()] [switch] $Existing ) process { if (Test-Path -Path $Path) { if ($Type -eq 'File') { $pathType = 'Leaf' $warnMsg = "File $Path already exist" $errMsg = "Cannot create file $Path, a directory already exist with that name" } elseif ($Type -eq 'Directory') { $pathType = 'Container' $warnMsg = "Directory $Path already exist" $errMsg = "Cannot create directory $Path, a file already exist with that name" } if (Test-Path -Path $Path -PathType $pathType) { if (-not $Existing) { Write-Warning -Message $warnMsg return } } else { Write-Error -Message $errMsg return } } . $ScriptBlock } } # Write JSON to file - (only first level) will be sorted # This version only works on array of hashtables function WriteJson { param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Hashtable[]] $Data, [Parameter(Mandatory = $true)] [String] $Path ) if ($input) {$Data = $input} $sorted = @( $Data | ForEach-Object -Process { $obj = New-Object -TypeName PSCustomObject $_.GetEnumerator() | Sort-Object -Property Key | ForEach-Object -Process { $obj | Add-Member -MemberType NoteProperty -Name $_.Key -Value $_.Value } $obj } ) ConvertTo-Json -Depth 10 -InputObject $sorted | Set-Content $Path } if ($PSCmdlet.ShouldProcess("Create module files in $Path")) { try { # Path defined as parameter if ($Path) { Write-Verbose -Message "cd $Path" $null = New-Item -Path $Path -ItemType Directory -ErrorAction SilentlyContinue Push-Location -Path $Path -StackName New-ModuleBuildStructure } # Resource sub directory in the module directory for this module $resourcesPath = Join-Path -Path $PSScriptRoot -ChildPath 'Resources' # TargetName end up in JSON - and in the end will be name of module if (-not $TargetName) { $TargetName = (Get-Location | Get-Item).Name Write-Verbose -Message "TargetName not defined, setting it to $TargetName based on working directory" } # Create directories $Directory | CreatePath -Type Directory -ScriptBlock { Write-Verbose "mkdir $Path" $null = New-Item -ItemType Directory -Path $Path } # Content of Build.json - if IncludeExamples is defined then more will be appended $jsonContent = @( @{ TargetName = $TargetName Type = 'Module' VersionAppendDate = $true } ) # Example stuff if ($IncludeExamples) { $exampleDirRequired = @('FunctionExport', 'FunctionPrivate') if ($exampleDirRequired.Where({$Directory.Contains($_)}).Count -eq $exampleDirRequired.Count) { # Build.json $exampleCmd = '$_.PSObject.Properties | select Name,@{N=''Value'';E={[String]$_.Value}},@{N=''Type'';E={$_.Value.GetType().Name}} | ft | Out-String' $all = @{ SourceRoot = '.' BuildRoot = 'Build' ManifestFile = 'Manifest.psd1' FunctionExportFile = @('FunctionExport\*.ps1') FunctionPrivateFile = @('FunctionPrivate\*.ps1') ClassFile = @('Class\*.ps1') AliasFile = @('Alias\*.ps1') ExtraPSFile = @('Extra1\*.ps1', 'Extra2\*.ps1') Version = '1.2.3' NoTrim = $false BeforeZip = @($exampleCmd) AfterZip = @($exampleCmd) } $jsonContent += @( $all + @{ TargetName = 'ExampleModule' Type = 'Module' Guid = [Guid]::NewGuid() ManifestParameters = @{Copyright = 'You shall not pass'} } $all + @{ TargetName = 'ExampleScriptFromFunction' Type = 'ScriptFromFunction' Guid = [Guid]::NewGuid() Function = 'ExampleFunction' HelperFunction = 'HelperFunction' } $all + @{ TargetName = 'ExampleScriptFromTemplate' Type = 'ScriptFromTemplate' Guid = [Guid]::NewGuid() Template = 'ExampleTemplate.ps1' } ) # Example functions CreatePath -Path 'FunctionExport\ExampleFunction.ps1' -ScriptBlock { 'function ExampleFunction {param($P) "ExampleFunction: $P"; HelperFunction "$P plus more"}' | Set-Content -Path $Path } CreatePath -Path 'FunctionPrivate\HelperFunction.ps1' -ScriptBlock { 'function HelperFunction {param($P) "HelperFunction: $P"}' | Set-Content -Path $Path } # Example template CreatePath -Path 'ExampleTemplate.ps1' -ScriptBlock { @( '{{function:ExampleFunction}}' '{{function:HelperFunction}}' 'ExampleFunction "Calling ExampleFunction"' 'HelperFunction "Calling HelperFunction"' ) -join "`r`n" | Set-Content -Path $Path } } else { Write-Warning -Message "-IncludeExamples is selected but -Directory does not include `"$($exampleDirRequired -join '" or "')`". Cannot create example functions." } } # Create Build.json CreatePath -Path $JsonFile -ScriptBlock { Write-Verbose -Message "Creating file $Path" $jsonContent | WriteJson -Path $Path } # Create Manifest.psd1 CreatePath -Path $ManifestFile -ScriptBlock { Write-Verbose -Message "Creating file $Path" $manifestParametersNew = if ($ManifestParameters['RootModule']) { @{} } else { @{RootModule = "$($TargetName).psm1"} } New-ModuleManifest -Path $Path @ManifestParameters @manifestParametersNew } # Update Manifest.psd1 CreatePath -Path $ManifestFile -Existing -ScriptBlock { Write-Verbose -Message "Updating file $Path" # Update-ModuleManifest produces nicer content than New-ModuleManifest (eg. UTF-8) Update-ModuleManifest -Path $Path @ManifestParameters (Get-Content -Path $Path -Raw) -replace '^(#.*(\r?\n)+)*','' | Set-Content -Path $Path } # Copy Build.ps1 CreatePath -Path $BuildFile -Existing -ScriptBlock { Write-Verbose -Message "Updating file $Path" $resourceBuild = Join-Path -Path $resourcesPath -ChildPath 'Build.ps1' Get-Content -Raw -Path $resourceBuild | Set-Content -Path $Path # Not using Copy-Item to avoid NTFS properties copied } # Create .gitignore CreatePath -Path $GitIgnoreFile -ScriptBlock { "Build/*`r`n~*`r`n*~" | Set-Content -Path $Path } } catch { # If error was encountered inside this function then stop doing more # But still respect the ErrorAction that comes when calling this function # And also return the line number where the original error occured in verbose output Write-Verbose -Message "Detailed error info: $_`r`n$($_.InvocationInfo.PositionMessage)" Write-Error -ErrorAction $originalErrorActionPreference -Exception $_.Exception } finally { if ($Path) { Pop-Location -StackName New-ModuleBuildStructure -ErrorAction SilentlyContinue } } } Write-Verbose -Message 'End' } Set-Alias -Name Update-ModuleBuildStructure -Value New-ModuleBuildStructure Export-ModuleMember -Function Get-ModuleMarkup Export-ModuleMember -Function New-ModuleBuildStructure Export-ModuleMember -Function Invoke-ModuleBuild Export-ModuleMember -Alias Update-ModuleBuildStructure |