ModuleForge.psm1
<# Module created by ModuleForge ModuleForge Version: 1.1.0 BuildDate: 2025-06-04T01:19:07 #> function add-mfGithubScaffold { <# .SYNOPSIS Initialises a `.GitHub` scaffold in a PowerShell module, including GH Actions workflows for pester testing and build and release .DESCRIPTION This function will create a '.github' folder in the moduleforge root (if one does not exist). It will create 2 github actions workflows, 1 for Pester testing, 1 for buildAndRelease It will create 1 Pull Request template. The buildAndRelease template will use Git Tags to mark versions. If you stick with this template you should refrain from using tags for other purposes. The purpose of these workflows and templates is to get you started, creating a quick and easy workflow scaffold. Please feel free to change the workflows and template to your own needs and preferences. .EXAMPLE Add-mfGithubScaffold #### DESCRIPTION Copies the `.GitHub` folder from the module's `resource` directory to the current module, skipping existing files. #### OUTPUT Should have a .github folder, with workflows and a PR template .EXAMPLE Add-mfGithubScaffold -Force #### DESCRIPTION Copies the `.GitHub` scaffold and overwrites existing files in `.github` directory #### OUTPUT Should have a .github folder, with workflows and a PR template .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Root path of the module. Uses the current working directory by default. Aliased path, but use modulePath as paramname to avoid confusion [Parameter()] [alias('path')] [string]$modulePath = $(get-location).path, #Module Config reference [Parameter(DontShow)] [string]$configFile = 'moduleForgeConfig.xml', #githubFolder [Parameter(DontShow)] [string]$githubFolder = '.github', #Should we overwrite if files exist? [switch]$force ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" if($mockPsScriptRoot) { #Assume we are in pester test, and use the $mockPsScriptRoot param write-warning 'Assuming PSScriptRoot from $mockPsScriptRoot. This should only be done for testing' $resourceFolder = Join-Path $mockPsScriptRoot 'resource' }else{ $resourceFolder = Join-Path $PSScriptRoot 'resource' } write-verbose "ResourceFolder: $resourceFolder" if(!(test-path $resourceFolder)) { throw 'Err: Unable to find resource folder in ModuleForge module' } $resourceFolderGithub = join-path $resourceFolder 'github' if(!(test-path $resourceFolderGithub)) { throw 'Err: Found resource folder, but not Github folder in ModuleForge module' } $mfConfigFile = join-path $modulePath $configFile if(!(test-path $mfConfigFile)) { throw 'Err: No Moduleforge Config File found. Please check your module path' } $moduleGitFolder = join-path $modulePath $githubFolder } process{ if(!(test-path $moduleGitFolder)){ write-verbose "$moduleGitFolder does not exist, creating" new-item -ItemType Directory -Path $moduleGitFolder } $childItemSource = get-childitem $resourceFolderGithub -Recurse $childItemSource.foreach{ write-verbose "Checking file: $($_.name)" $destinationPath = $_.FullName.replace($resourceFolderGithub,$moduleGitFolder) write-verbose 'DestinationPath: $destinationPath' if(test-path $destinationPath){ if($force) { write-warning "Overwrite $destinationPath" copy-item -Path $_.FullName -Destination $destinationPath -Force }else{ write-warning "Skipping $destinationPath as it exists" } }else{ write-verbose "Copying $($_) to $destinationPath" copy-item -Path $_.FullName -Destination $destinationPath } } } } function add-mfRepositoryXmlData { <# .SYNOPSIS Uncompress a nuspec (Which is just a zip with a different extension), parse the XML, add the URL element for the repository, recreate the ZIP file. .DESCRIPTION Some Nuget Package Providers (such as Github Package Manager) require a repository element with a URL attribute in order to be published correctly, For Githup Packages, this is so it can tie the package back to the actual repository. At present (March 2024), new-moduleManifest and publish-psresource do not have parameter options that works for this. This function provides a work-around ------------ .EXAMPLE add-repositoryXmlData -RepositoryUri 'https://github.com/gituser/example' -NugetPackagePath = 'c:\example\module.1.2.3-beta.4.nupkg -branch 'main' -commit '1234123412341234Y' #### DESCRIPTION Unpack module.1.2.3-beta.4.nupkg to a temp location, open the NUSPEC xml and append a repository element with URL, Type, Branch and Commit attributes, repack the nupkg .INPUTS [String] - This function accepts string values for `repositoryUri` and `NugetPackagePath` via pipeline input by property name. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #RepositoryUri [Parameter(Mandatory,ValueFromPipelineByPropertyName)] [string]$repositoryUri, #Path to the actual file in the repository, should be something like C:\Users\{UserName}\AppData\Local\Temp\LocalTestRepository\{ModuleName}.{ModuleVersion}.nupkg [Parameter(Mandatory,ValueFromPipelineByPropertyName)] [string]$NugetPackagePath, #TempExtractionPath [Parameter()] $ExtractionPath = $(join-path -path ([System.IO.Path]::GetTempPath()) -childpath 'tempUnzip'), #Use force to ignore remove prompt [Parameter()] [switch]$force, #What branch to add to NUSPEC (Optional) [Parameter()] [string]$branch, [Parameter()] #What commit to add to NUSPEC (Optional) [string]$commit ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" } process{ #Check we have valid NuSpec $nuPackage = get-item $NugetPackagePath if(!$nuPackage) { throw "Unable to find: $NugetPackagePath" } if($nuPackage.Extension -ne '.nupkg') { throw "$NugetPackagePath not a .nupkg file" } write-verbose "Found nupkg file at: $($nuPackage.fullname)" #Get a clean extraction folder if(!(test-path $ExtractionPath)) { write-verbose 'Creating Extraction Path' New-Item -Path $ExtractionPath -ItemType Directory }else{ Write-warning "Extraction Path will be removed and recreated`nPath: $($ExtractionPath)" if(!$force) { $action = Read-Host 'Are you sure you want to continue with this action? (y/n)' if ($action -eq 'y') { # Insert the risky action here Write-warning 'Continuing with the action...' } else { throw 'Action cancelled' } } if($action -eq 'y' -or $force -eq $true) { #Probably dont need this IF statement, just a sanity check we have permission to destroy folder get-item -Path $ExtractionPath |remove-item -recurse -force New-Item -Path $ExtractionPath -ItemType Directory } } write-verbose 'Extracting NuSpec Archive' expand-archive -path $nuPackage.FullName -DestinationPath $ExtractionPath write-verbose 'Searching for NuSpec file' $nuSpec = Get-ChildItem -Path $ExtractionPath -Filter *.nuspec if($nuSpec.count -eq 1) { write-verbose 'Loading XML' $nuSpecXml = new-object -TypeName XML $nuSpecXml.Load($nuSpec.FullName) #Repository Element $newElement = $nuSpecXml.CreateElement("repository",$nuSpecXml.package.namespaceURI) write-verbose 'Adding Repository Type Attribute' $newElement.SetAttribute('type','git') write-verbose 'Adding Repository URL Attribute' $newElement.SetAttribute('url',$repositoryUri) if($branch) { write-verbose 'Adding Repository Branch Attribute' $newElement.SetAttribute('branch',$branch) } if($commit) { write-verbose 'Adding Repository commit Attribute' $newElement.SetAttribute('commit',$commit) } write-verbose 'Appending Element to XML' $nuSpecXml.package.metadata.AppendChild($newElement) #Save, close XML and repackage write-verbose 'Saving the NUSPEC' $nuSpecXml.Save($nuspec.FullName) remove-variable nuSpecXml start-sleep -seconds 2 #Mini pause to let the save complete write-verbose 'Repacking the nuPkg' $repackPath = join-path $ExtractionPath -ChildPath '*' write-verbose "Repack Path:$repackPath" compress-archive -Path $repackPath -DestinationPath $nuPackage.FullName -Force write-verbose 'Finished Repack' }else{ throw 'Error finding NuSpec' } } } function build-mfProject { <# .SYNOPSIS Grab all the files from source, compile them into a single PowerShell module file, create a new module manifest. .DESCRIPTION Grab the content, functions, classes, validators etc from the .\source\ directory For all functions found in files in the .\source\functions directory, export them in the module manifest Add the contents of all scripts to a PSM1 file Add the contents of any Validators to a separate external ps1 file as a module dependency Create a new module manifest from the parameters saved with new-mfProject Tag as a pre-release if a semverPreRelease label is found .EXAMPLE build-mfProject -version '0.12.2-prerelease.1' #### DESCRIPTION Make a PowerShell module from the current folder, and mark it as a pre-release version .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #What version are we building? [Parameter(Mandatory)] [semver]$version, #Root path of the module. Uses the current working directory by default [Parameter()] [alias('ModulePath')] [string]$path = $(get-location).path, [Parameter(DontShow)] [string]$configFile = 'moduleForgeConfig.xml', #Use this flag to put any classes in ScriptsToProcess [Parameter()] [switch]$exportClasses, #Use this flag to put any enums in ScriptsToProcess [Parameter()] [switch]$exportEnums, #Use this to not put anything in nestedmodules, making everything a single file. By default validators are put in a separate nestedmodule script to ensure they are loaded properly [Parameter()] [switch]$noExternalFiles ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" write-verbose 'Testing module path' try{ $moduleTest = get-item $path -ErrorAction SilentlyContinue }catch{ $moduleTest = $null } if(!$moduleTest){ throw "Unable to read from $path" } $path = $moduleTest.FullName write-verbose "Building from: $path" #Read the config file write-verbose 'Importing config file' $configPath = join-path -path $path -ChildPath $configFile if(!(test-path $configPath)) { throw "Unable to find config file at: $configPath" } $config = import-clixml $configPath -erroraction stop if(!$config) { throw "Unable to import config file from: $configPath" } #Reference version as a string $versionString = $version.tostring() write-verbose 'Checking for a build and module folder' $buildFolder = join-path -path $path -childPath 'build' if(!(test-path $buildFolder)) { write-verbose "Build folder not found at: $($buildFolder), creating" try{ #Save to var to loose the output $null = new-item -ItemType Directory -Path $buildFolder -ErrorAction Stop }catch{ throw 'Unable to create build folder' } } $moduleOutputFolder = join-path -path $buildFolder -ChildPath $($config.moduleName) if(!(test-path $moduleOutputFolder)) { write-verbose "Module folder not found at: $($moduleOutputFolder), creating" try{ $null = new-item -ItemType Directory -Path $moduleOutputFolder -ErrorAction Stop }catch{ throw 'Unable to create Module folder' } }else{ write-verbose "Module folder not found at: $($moduleOutputFolder), need to replace" try{ remove-item $moduleOutputFolder -force -Recurse start-sleep -Seconds 2 #Save to var to loose the output. More efficient than |out-null $null = new-item -ItemType Directory -Path $moduleOutputFolder -ErrorAction Stop }catch{ throw 'Unable to recreate Module folder' } } $moduleForgeDetails = (get-module 'ModuleForge' |Sort-Object -Property Version -Descending|select-object -First 1) if($moduleForgeDetails) { $mfVersion = $moduleForgeDetails.version.tostring() }else{ $mfVersion = 'unknown' } $moduleHeader = "<#`nModule created by ModuleForge`n`t ModuleForge Version: $mfVersion`n`tBuildDate: $(get-date -format s)`n#>" #Better Order [array]$folders = @('enums','validationClasses','classes','functions','private') $sourceFolder = join-path -path $path -childPath 'source' #What folders do we need to copy the files directly in [array]$copyFolders = @('resource','bin') $scriptsToProcess = New-Object System.Collections.Generic.List[string] $functionsToExport = New-Object System.Collections.Generic.List[string] $nestedModules = New-Object System.Collections.Generic.List[string] $DscResourcesToExport = New-Object System.Collections.Generic.List[string] $fileList = New-Object System.Collections.Generic.List[string] } process{ write-verbose "Attempt to build:`n`t`tmodule:$($config.moduleName)version:`n`t`t$versionString" #References for our manifest and module root $moduleFileShortname = "$($config.moduleName).psm1" $moduleFile = join-path $moduleOutputFolder -ChildPath $moduleFileShortname $fileList.Add($moduleFileShortname) $manifestFileShortname = "$($config.moduleName).psd1" $manifestFile = join-path $moduleOutputFolder -ChildPath $manifestFileShortname $fileList.Add($manifestFileShortname) write-verbose "Will create module in:`n`t`t$moduleOutputFolder;`n`t`tModule Filename: $moduleFileShortname ;`n`t`tManifest Filename: $manifestFileShortname " #References for external files, if needed $classesFileShortname = "$($config.moduleName).Classes.ps1" $classesFile = join-path -path $moduleOutputFolder -ChildPath $classesFileShortname $validatorsFileShortname = "$($config.moduleName).Validators.ps1" $validatorsFile = join-path -path $moduleOutputFolder -ChildPath $validatorsFileShortname $validatorsFileShortname = "$($config.moduleName).Validators.ps1" $validatorsFile = join-path -path $moduleOutputFolder -ChildPath $validatorsFileShortname $enumsFileShortname = "$($config.moduleName).Enums.ps1" $enumsFile = join-path -path $moduleOutputFolder -ChildPath $enumsFileShortname #Start creating the moduleFile write-verbose 'Adding Header Comment' $moduleHeader|out-file $moduleFile -Force #Do a check for DSC Resources because they change how we handle everything #Actually, for now lets not worry about DesiredStateConfig, # - its a bit broken as of July 2024, # - It changes how we build modules because nestedmodules, scriptstoprocess dont work (From previous experience) # - I don't have any need to build DSC resources at this time, so my testing will be limited # - DSC Resources are being reworked by MicroSoft so this is a moving target at the moment write-verbose 'Checking for DSC Resources. DSC Resources add nuance to module build' $dscResourcesFolder = join-path -path $sourceFolder -ChildPath 'dscClasses' if(test-path $dscResourcesFolder) { $dscResourceFiles = get-mfFolderItems -path $dscResourcesFolder -psScriptsOnly if($dscResourceFiles.count -ge 1) { write-warning 'DSC Resources Found - Ignoring Export Switches and Compiling to single module file' #See above comments throw 'DSC is not supported in this version of moduleForge. Its on the roadmap' $noExternalFiles = $true }else{ write-verbose 'No DSC Resources found' } }else{ write-verbose 'No DSC folder found' } write-verbose 'Getting all the Script Details' $folderItemDetails = get-mfFolderItemDetails -path $sourceFolder Write-Information "File Dependency Tree:`n`n$($(get-mfdependencyTree ($folderItemDetails|Select-Object relativePath,dependencies)) -join "`n")`n" -tags 'DependencyTree' #Start compiling the module file and associated content write-verbose "`n`n`n==========================================`n`n" write-verbose 'Starting module Compile' write-debug 'Starting module Compile' foreach($item in $folderItemDetails) { switch($item.group) { 'dscClasses' { Write-Information "Processing $($item.name) as a DSCClass" -tags 'FilesProcessed' write-verbose "Processing $($item.name) as a DSCClass" throw 'DSC Classes currently not supported, sorry!' } 'functions' { Write-Information "Processing $($item.name) as a Function" -tags 'FilesProcessed' write-verbose "Processing $($item.name) as a Function" $item.content|out-file $moduleFile -Append $item.functionDetails.functionName.forEach{ write-verbose "Adding $_ as functionToExport" $functionsToExport.add($_) } } 'enums' { Write-Information "Processing $($item.name) as a Enum" -tags 'FilesProcessed' write-verbose "Processing $($item.name) as an Enum" if($exportEnums -and !$noExternalFiles){ write-verbose 'Exporting enum content to external enum file' $item.content|Out-file $enumsFile -Append if($enumsFileShortname -notIn $scriptsToProcess) { $scriptsToProcess.Add($enumsFileShortname) } if($enumsFileShortname -notIn $fileList) { $fileList.Add($enumsFileShortname) } }else{ write-verbose 'Exporting enum content to module file' $item.content|out-file $moduleFile -Append } } 'validationClasses' { Write-Information "Processing $($item.name) as a ValidationClass" -tags 'FilesProcessed' write-verbose "Processing $($item.name) as ValidationClass" if($noExternalFiles) { write-verbose 'No ExternalFiles flag set' write-warning 'By setting NoExternalFiles with files in the validationClasses folder, you run the risk of your validator class objects not loading correctly.' write-warning 'If your module has DSC Classes, the NoExternalFiles switch will be forced. Avoid using custom validators with DSC Modules for predictable results' write-verbose 'Exporting validator content to external module file' $item.content|out-file $moduleFile -append }else{ write-verbose 'Exporting validator content to external validators file' $item.content|Out-file $validatorsFile -Append if($validatorsFileShortname -notIn $scriptsToProcess) { $scriptsToProcess.Add($validatorsFileShortname) } if($validatorsFileShortname -notIn $fileList) { $fileList.Add($validatorsFileShortname) } } } 'classes' { Write-Information "Processing $($item.name) as a Class" -tags 'FilesProcessed' write-verbose "Processing $($item.name) as Class" if($exportClasses -and !$noExternalFiles){ write-verbose 'Exporting classes content to external classes file' $item.content|Out-file $classesFile -Append if($classesFileShortname -notIn $scriptsToProcess) { $scriptsToProcess.Add($classesFileShortname) } if($classesFileShortname -notIn $fileList) { $fileList.Add($classesFileShortname) } }else{ write-verbose 'Exporting classes content to external module file' $item.content|out-file $moduleFile -append } } 'private' { Write-Information "Processing $($item.name) as a Private (Non Exported) Function" -tags 'FilesProcessed' write-verbose "Processing $($item.name) as a Private (Non Exported) Function" $item.content|out-file $moduleFile -Append } Default { write-warning "$($item.group) grouptype is unknown. Uncertain how to handle file: $($item.name). Will be skipped" } } } write-verbose 'Finished compiling module file' write-verbose "`n`n`n==========================================`n`n" foreach($folder in $copyFolders) { write-verbose "Processing folder for content copy: $folder" $fullFolderPath = join-path -path $sourceFolder -ChildPath $folder $folderItems = get-mfFolderItems -path $fullFolderPath if($folderItems.count -ge 1) #Now we are on PS7 we don't need to worry about measure-object { write-verbose "$($folderItems.Count) Files found, need to copy" $destinationFolder = join-path -path $moduleOutputFolder -childPath $folder write-verbose "Destination Path will be: $destinationFolder" if(!(test-path $destinationFolder)) { try{ $null = new-item -ItemType Directory -Path $destinationFolder -ErrorAction Stop write-verbose 'Created Destination Folder' }catch{ throw "Unable to make directory for: $destinationFolder" } Write-Information "Copied $folder, containing $($folderItems.Count) items, to the module" -tags 'FoldersCopied' } #Make null = to suppress the object output $null = get-mfFolderItems -path $fullFolderPath -destination $destinationFolder -copy <# Ideally we add all the copied items to the filelist param in the module manifest #But since we are putting them in a child folder, I've got concerns #Like the relativename is there, and it works, but the folder divider wont be a \ on non-windows #Probably safer to leave this out for the time being # Also worth noting, I don't think I've ever seen a manifest have a file list $folderItems.ForEach{ if($_.name -notIn $fileList) { $fileList.Add($_.name) } } #> } } write-verbose 'Building Manifest' #Manifest Base $splatManifest = @{ Path = $manifestFile RootModule = $moduleFileShortname Author = $($config.moduleAuthors -join ',') Copyright = "$(get-date -f yyyy)$(if($config.companyName){" $($config.companyName)"}else{" $($config.moduleAuthors -join ' ')"})" CompanyName = $config.companyName Description = $config.Description ModuleVersion = $versionString Guid = $config.guid PowershellVersion = $config.minimumPsVersion.tostring() CmdletsToExport = [array]@() } #Add the extra bits if present #Splatting really doesn't like nulls if($config.licenseUri) { $splatManifest.licenseUri = $config.licenseUri } if($config.projecturi){ $splatManifest.projecturi = $config.projecturi } if($config.tags){ $splatManifest.tags = $config.tags } if($config.iconUri){ $splatManifest.iconUri = $config.iconUri } if($config.requiredModules){ $splatManifest.requiredModules = $config.RequiredModules } if($config.ExternalModuleDependencies){ $splatManifest.ExternalModuleDependencies = $config.ExternalModuleDependencies } if($config.DefaultCommandPrefix){ $splatManifest.DefaultCommandPrefix = $config.DefaultCommandPrefix } if($config.PrivateData){ $splatManifest.PrivateData = $config.PrivateData } #FunctionsToExport if($functionsToExport.count -ge 1) { write-verbose "Making these functions public: $($functionsToExport.ToArray() -join ',')" #I'm not sure why, but the export of this is not an actual array. In the PSD1 it wont have the @(). I tried to force it unsuccessfully [array]$splatManifest.FunctionsToExport = [array]$functionsToExport.ToArray() }else{ write-warning 'No public functions' [array]$splatManifest.FunctionsToExport = [array]@() } #If we are exporting any of our enums, classes, validators into the Global Scope, we should do it here. # Ideally in the future a module manifest would have ClassesToExport, EnumsToExport - but I'm not gonna hold my breath for that if($scriptsToProcess.count -ge 1) { write-verbose "Scripts to process on module load: $($scriptsToProcess.ToArray() -join ',')" $splatManifest.ScriptsToProcess = [array]$scriptsToProcess.ToArray() }else{ write-verbose 'No scripts to process on module load' } #See my comment in on validators in the switch statement if($nestedModules.count -ge 1) { write-verbose "Included in modulesToProcess: $($nestedModules.ToArray() -join ',')" [array]$splatManifest.NestedModules = [array]$nestedModules.ToArray() }else{ write-verbose 'Nothing to include in modulesToProcess' } #This block should not trigger right now $DscResourcesToExport if($DscResourcesToExport.count -ge 1) { write-verbose "Included in dscResources: $($DscResourcesToExport.ToArray() -join ',')" $splatManifest.DscResourcesToExport = [array]$DscResourcesToExport.ToArray() }else{ write-verbose 'No dsc Resources to include' } #Extra Stuff if($version.PreReleaseLabel) { #Semver supplied had a pre-release label write-verbose 'Incrementing Prerelease Version' #$preReleaseSplit = $version.PreReleaseLabel.Split('.') #$preReleaseLabel = $currentPreReleaseSplit[0] write-verbose "Setting Prerelease tag to: $($version.PreReleaseLabel)" $splatManifest.Prerelease = $version.PreReleaseLabel } $splatManifest.ModuleVersion = $version #Currently not adding anything to file list, will leave this code here in case we revisit later <# if($fileList.count -ge 1) { write-verbose "Included in fileList: $($fileList.ToArray() -join ',')" [array]$splatManifest.fileList = [array]$fileList.ToArray() } #> New-ModuleManifest @splatManifest Write-Information 'Created Module Manifest' -tags 'CreatedModuleManifest' } } function get-mfDependencyTree { <# .SYNOPSIS Generate a dependency tree of ModuleForge PowerShell scripts, either in terminal or a mermaid flowchart .DESCRIPTION The `get-mfDependencyTree` function processes an array of objects representing PowerShell scripts and their dependencies. It generates a visual representation of the dependency tree, either as a text-based tree in the terminal or as a Mermaid diagram. This function helps in understanding the relationships and dependencies between different scripts and modules in a project. ------------ .EXAMPLE $folderItemDetails = get-mfFolderItemDetails -path (get-item .\source).fullname get-mfDependencyTree ($folderItemDetails|Select-Object relativePath,dependencies) #### DESCRIPTION Show files and any dependencies .INPUTS [OBJECT[]] - ReferenceData Object Array (Resulting from get-mfFolderItemDetails) accepted as pipeline input .OUTPUTS [STRING] - Returns a formatted string representing the dependency tree, Output format can be: - Multi-line string expected to print to terminal (Default Behaviour) - A mermaid chart (If specified with Output Type 'Mermaid') - A mermaid chart encapsulated in a markdown code block ('MermaidMarkdown') .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #What Reference Data are we looking at. See function example for how to retrieve [Parameter(ValueFromPipeline)] [object[]]$referenceData = (get-mfFolderItemDetails -path (get-item source).fullname), [Parameter()] [ValidateSet('Mermaid','MermaidMarkdown','Terminal')] [string]$outputType = 'Terminal' ) begin { # Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" # Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" $dependencies = New-Object System.Collections.Generic.List[object] } process { foreach ($ref in $referenceData) { $relativePath = $ref.relativePath foreach ($dep in $ref.dependencies) { $dependencies.add( [PSCustomObject]@{ Parent = $relativePath Child = $dep.ReferenceFile } ) } } $output = New-Object System.Collections.Generic.List[string] if ($outputType -eq 'Mermaid' -or $outputType -eq 'MermaidMarkdown') { if ($outputType -eq 'MermaidMarkdown') { $output.add('```mermaid') } $output.add('flowchart TD') foreach ($dep in $dependencies) { $output.add("'$($dep.Parent)' --> '$($dep.Child)'") } if ($outputType -eq 'MermaidMarkdown') { $output.add('```') } $output -join "`n" } else { $tree = @{} foreach ($dep in $dependencies) { write-verbose "In: $dep dependencyCheck" if (-not $tree.ContainsKey($dep.Parent)) { write-verbose "Need to add: $($dep.Parent) As ParentRef" $tree[$dep.Parent] = New-Object System.Collections.Generic.List[string] } write-verbose "Need to add $($dep.Child) as child of $($dep.Parent)" $tree[$dep.Parent].add($dep.Child) } $rootNodes = $referenceData.where{$_.Dependencies.Count -gt 0}.relativePath $rootNodes.foreach{ write-output $_ if ($tree.ContainsKey($_)) { foreach ($child in $tree[$_]) { printTree -node $child -level 1 } } } } } } function get-mfFolderItemDetails { <# .SYNOPSIS This function analyses a PS1 file, returning its content, any functions, classes and dependencies, as well as a relative location .DESCRIPTION The `get-mfFolderItemDetails` function takes a path to source folder It creates a job that generates a details about all found PS1 files, including: The content of PS1 files, the names of any functions, the names of any classes, and any inter-related dependencies This function uses a job to import all the ps1 items so that all types can be reflected correctly without having to load the module, So it works without having to build the manifest etc This function is primary used to build dependency trees and during build to get file contents ------------ .EXAMPLE get-mfFolderItemDetails .\source .INPUTS [STRING] - Path to Source Folder is accepted as Pipeline Input or direct assignment .OUTPUTS [Object[]] - Returns an array of objects with detailed file metadata, including: - **Name** (`[String]`) – Name of the file. - **Path** (`[String]`) – Full file path. - **FileSize** (`[Int]`) – File size in kilobytes. - **FunctionDetails** (`[Object[]]`) – Details of functions within the file. - **ClassDetails** (`[Object[]]`) – Details of classes within the file. - **Contents** (`[String]`) – Entire script content. - **Group** (`[String]`) – Subfolder grouping. - **Dependencies** (`[Object[]]`) – References to other files with name and full path. Child Object Details: #### FunctionDetails (`[Object]`) - **functionName** (`[String]`) – Name of the function. - **cmdLets** (`[Object]`) – Functions/cmdlets called, with name and usage count. - **types** (`[Object]`) – Classes referenced, with name and usage count. - **parameterTypes** (`[Object]`) – Enums used. - **Validators** (`[Object]`) – Validator classes used. - **Properties** (`[String[]]`) – Properties within the function. #### ClassDetails (`[Object]`) - **ClassName** (`[String]`) – Name of the class. - **Methods** (`[String]`) – Methods defined in the class. - **Properties** (`[String[]]`) – Properties within the class. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Path to source folder. [Parameter(ValueFromPipelineByPropertyName,ValueFromPipeline)] [string]$path = ((get-item 'source').fullname) ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" } process{ write-verbose 'Creating Scriptblock' [scriptblock]$sblock = { param($path,$folderItems) function Get-mfScriptDetails { param( [Parameter(Mandatory)] [string]$Path, [Parameter()] [string]$RelativePath, [ValidateSet('Class','Function','All')] [Parameter()] [string]$type = 'All', [Parameter()] [string]$folderGroup ) begin{ write-verbose 'Checking Item' if($path[-1] -eq '\' -or $path[-1] -eq '/') { write-verbose 'Removing extra \ or / from path' $path = $path.Substring(0,$($path.length-1)) write-verbose "New Path $path" } $file = get-item $Path if(!$file) { throw "File not found at: $path" } } process{ $AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$null) #$Functions = $AST.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) #The above was the original way to do this #However it was so efficient it also returned subfunctions AND functions in scriptblocks #Since we don't want to do that, we cycle through and look at the start and end line numbers and only return top-level functions #Leaving it here as a reminder $AllFunctions = $AST.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) $TopLevelFunctions = New-Object System.Collections.Generic.List[Object] foreach($func in $allFunctions){ $isNested = $false foreach($parentFunc in $allFunctions) { if($func -ne $parentFunc -and $func.Extent.StartLineNumber -ge $parentFunc.Extent.StartLineNumber -and $func.Extent.EndLineNumber -le $parentFunc.Extent.EndLineNumber) { $isNested = $true break } } if(-not $isNested) { $TopLevelFunctions.add($func) } } $Classes = $AST.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $true) if($type -eq 'All' -or $type -eq 'Function') { $functionDetails = foreach ($Function in $TopLevelFunctions) { $cmdletDependenciesList = New-Object System.Collections.Generic.List[string] $typeDependenciesList = New-Object System.Collections.Generic.List[string] $paramTypeDependenciesList = New-Object System.Collections.Generic.List[string] $validatorTypeDependenciesList = New-Object System.Collections.Generic.List[string] $FunctionName = $Function.Name $Cmdlets = $Function.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true) foreach($c in $Cmdlets) { $cmdletDependenciesList.add($c.GetCommandName()) } $TypeExpressions = $Function.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeExpressionAst] }, $true) $TypeExpressions.TypeName.FullName.foreach{ $tname = $_ [string]$tnameReplace = $($tname.Replace('[','')).replace(']','') $typeDependenciesList.add($tnameReplace) } $Parameters = $Function.Body.ParamBlock.Parameters $Parameters.StaticType.Name.foreach{$paramTypeDependenciesList.add($_)} $attributes = $Parameters.Attributes foreach($att in $attributes) { $refType = $att.TypeName.GetReflectionType() if($refType -and ($refType.IsSubclassOf([System.Management.Automation.ValidateArgumentsAttribute]) -or [System.Management.Automation.ValidateArgumentsAttribute].IsAssignableFrom($refType))) { [string]$tname = $Att.TypeName.FullName [string]$tname = $($tname.Replace('[','')).replace(']','') $validatorTypeDependenciesList.Add($tname) } } [psCustomObject]@{ functionName = $FunctionName cmdLets = $cmdletDependenciesList|group-object|Select-Object Name,Count types = $typeDependenciesList|group-object|Select-Object Name,Count parameterTypes = $paramTypeDependenciesList|group-object|Select-Object name,count Validators = $validatorTypeDependenciesList|Group-Object|Select-Object name,count } } } if($type -eq 'all' -or $type -eq 'Class') { $classDetails = foreach ($Class in $Classes) { $className = $Class.Name $classMethodsList = New-Object System.Collections.Generic.List[string] $classPropertiesList = New-Object System.Collections.Generic.List[string] $Methods = $Class.Members | Where-Object { $_ -is [System.Management.Automation.Language.FunctionMemberAst] } foreach($m in $Methods) { $classMethodsList.add($m.Name) } $Properties = $Class.Members | Where-Object { $_ -is [System.Management.Automation.Language.PropertyMemberAst] } foreach($p in $Properties) { $classPropertiesList.add($p.Name) } [psCustomObject]@{ className = $className methods = $classMethodsList|group-object|Select-Object Name,Count properties = $classPropertiesList|group-object|Select-Object Name,Count } } } $objectHash = @{ Name = $file.Name Path = $file.FullName FileSize = "$([math]::round($file.length / 1kb,2)) kb" FunctionDetails = $functionDetails ClassDetails = $classDetails Content = $AST.ToString() } if($RelativePath) { $objectHash.relativePath = $RelativePath } if($folderGroup) { $objectHash.group = $folderGroup } [psCustomObject]$objectHash } } $privateMatch = "*$([IO.Path]::DirectorySeparatorChar)private$([IO.Path]::DirectorySeparatorChar)*" $functionMatch = "*$([IO.Path]::DirectorySeparatorChar)functions$([IO.Path]::DirectorySeparatorChar)*" $folderItems.ForEach{ if($_.path -notlike $privateMatch -and $_.path -notlike $functionMatch) { #Need to dot source the files to make sure all the types are loaded #Only needs to happen for non-function files . $_.Path } } $thisPath = (Get-Item $path) $relPathBase = ".$([IO.Path]::DirectorySeparatorChar)$($thisPath.name)" $itemDetails = $folderItems.ForEach{ $folderPath = join-path -path $_.folder -childpath $_.RelativePath.Substring(1) $relPath = join-path -path $relPathBase -childpath $folderPath if($_.path -like $privateMatch -or $_.path -like $functionMatch -or $_.folder -eq $functionMatch -or $_.folder -eq $privateMatch) { write-verbose "$($_.Path) matched on type: Function" Get-mfScriptDetails -Path $_.Path -RelativePath $relPath -type Function -folderGroup $_.folder }else{ write-verbose "$($_.Path) matched on type: Class" Get-mfScriptDetails -Path $_.Path -RelativePath $relPath -type Class -folderGroup $_.folder } } write-verbose 'Return items in Context' $inContextList =New-Object System.Collections.Generic.List[string] $filenameReference = @{} $filenameRelativeReference = @{} $itemDetails.foreach{ $fullPath = $_.path $relPath = $_.relativePath write-verbose "Getting details for $($_.name)" $_.FunctionDetails.Foreach{ $inContextList.add($_.functionName) $filenameReference.add($_.functionName,$fullPath) $filenameRelativeReference.Add($_.functionName,$relPath) } $_.ClassDetails.Foreach{ $inContextList.add($_.className) $filenameReference.add($_.className,$fullPath) $filenameRelativeReference.Add($_.className,$relPath) } } $checklist = $filenameReference.GetEnumerator().name foreach($item in $itemDetails) { write-verbose "Checking dependencies for file: $($item.name)" $compareList =New-Object System.Collections.Generic.List[string] $item.ClassDetails.methods.name.foreach{$compareList.add($_)} $item.FunctionDetails.cmdlets.name.foreach{$compareList.add($_)} $item.FunctionDetails.types.Name.foreach{$compareList.add($_)} $item.FunctionDetails.validators.name.foreach{$compareList.add($_)} $item.FunctionDetails.parameterTypes.name.foreach{$compareList.add($_)} $dependenciesList =New-Object System.Collections.Generic.List[object] foreach($c in $compareList) { write-verbose "Checking dependency of $c" if($c -in $checklist) { write-verbose "$c found in checklist" if($item.path -ne $filenameReference["$c"]) { write-verbose "$c found in checklist" $dependenciesList.add([psCustomObject]@{Reference=$c;ReferenceFile=$filenameRelativeReference["$c"]}) }else{ write-verbose "$c found in checklist - but in same file, ignoring" } } } #Add dependencies as an item $item|add-member -MemberType NoteProperty -Name 'Dependencies' -Value $dependenciesList $item } } $global:dbgScriptBlock = $sblock write-verbose 'Getting Folder Items' [array]$folders = @('enums','validationClasses','classes','dscClasses','functions','private') $folderItems = $folders.ForEach{ $folderPath = Join-Path $path -ChildPath $_ if(!(test-path $folderPath)) { write-verbose "$folderPath not found. Skipping" }else{ write-verbose "Getting items from $folderPath" get-mfFolderItems -path $folderPath -psScriptsOnly } } write-verbose "Starting Job; arguments `nPath:$($path|out-string)`nFiles:`n$($folderItems.name|out-string))" $job = Start-Job -ScriptBlock $sblock -ArgumentList @($path, $folderItems) -WorkingDirectory $path $job|Wait-Job|out-null write-verbose 'Retrieving output and returning result' $output = Receive-Job -Job $job remove-job -job $job return $output } } function get-mfFolderItems { <# .SYNOPSIS Retrieves a filtered list of files from a specified folder, processing `.mfignore` and `.mforder` rules. .DESCRIPTION The `get-mfFolderItems` function scans a folder and applies filtering rules to return a curated list of files. It offers additional filtering logic, such as: - Ignoring entries specified in `.mfignore`. - Filtering out non-PS1 files using a switch (`-psScriptsOnly`). - Excluding test-related files (`*.test.ps1`, `*.tests.ps1`, `*.skip.ps1`). - Handling optional file copying (`-destination` and `-copy` parameters). The function ensures all returned paths are fully qualified. This function is primarily used to assist the get-mfFolderItemDetails as well as build-mfProject. The -copy switch is added to cleanly copy resources and binaries with build-mfProject .EXAMPLE get-mfFolderItems -path '.\source\functions' -psScriptsOnly #### DESCRIPTION Scans `.\source\functions`, retrieves only `.ps1` files, and excludes files matching `.mfignore` rules. .EXAMPLE get-mfFolderItems -path '.\source\functions' -destination '.\build\functions' -copy #### DESCRIPTION Scans `.\source\functions`, retrieves filtered files, and copies them to `.\build\functions`. .INPUTS [String] - Accepts a folder path via parameter or pipeline (`ValueFromPipelineByPropertyName`). OUTPUTS [Object[]] - Returns an array of objects containing: - **Name** (`[String]`) – Name of the file. - **Path** (`[String]`) – Full file path. - **RelativePath** (`[String]`) – Path relative to the source folder. - **Folder** (`[String]`) – Name of the source folder. - **(Optional) newPath** (`[String]`) – Destination path if copying. - **(Optional) newFolder** (`[String]`) – Destination folder name if copying. .NOTES Author: Adrian Andersson #> [CmdletBinding(DefaultParameterSetName='Default')] PARAM( #Path to get items from [Parameter(Mandatory,ValueFromPipelineByPropertyName,ValueFromPipeline,ParameterSetName ='Default')] [Parameter(Mandatory,ValueFromPipelineByPropertyName,ValueFromPipeline,ParameterSetName ='Copy')] [alias('s')] [string]$path, #Flag to copy scripts only [parameter(ParameterSetName ='Default')] [parameter(ParameterSetName ='Copy')] [switch]$psScriptsOnly, #Flag to copy scripts only [parameter(ParameterSetName ='Copy')] [string]$destination, #Flag to actually copy files and not just output [parameter(ParameterSetName ='Copy')] [switch]$copy ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" write-verbose "ParameterSet: $($PSCmdlet.ParameterSetName)" $fileListSelect = @( 'Name' @{ Name = 'Path' Expression = {$_.Fullname} } 'RelativePath' @{ Name = 'Folder' Expression = {$folderShortName} } ) $fileListSelect2 = @( 'Name' @{ Name = 'Path' Expression = {$_.Fullname} } 'RelativePath' 'newPath' 'newFolder' @{ Name = 'Folder' Expression = {$folderShortName} } ) } process{ #Check the Parameters and do some lite parsing write-verbose "Path set to: $path" if($path[-1] -eq '\' -or $path[-1] -eq '/') { write-verbose 'Removing extra \ or / from path' $path = $path.Substring(0,$($path.length-1)) write-verbose "New Path $path" } try{ $folderItem = get-item $path -erroraction stop #Ensure we have the full path $folder = $folderItem.FullName write-verbose "Folder Fullname: $folder" $folderShortName = $folderItem.Name write-verbose "Folder Shortname: $folderShortName" }catch{ throw "Unable to get folder at $path" } #Include the older bartender bits so we have backwards compatibility [System.Collections.Generic.List[string]]$excludeList = '.gitignore','.mfignore','.btorderEnd','.btorderStart','.btignore' if($destination) { if($destination[-1] -eq '\' -or $destination[-1] -eq '/') { write-verbose 'Removing extra \ or / from destination' $path = $path.Substring(0,$($destination.length-1)) write-verbose "New destination $destination" } if(!(test-path $destination)) { throw "Unable to resolve destination path: $destination" } } $mfIgnorePath = join-path -path $folder -childpath '.mfignore' if(test-path $mfIgnorePath) { write-verbose 'Getting ignore list from .mfignore' $content = (get-content $mfIgnorePath).where{$_.length -gt 1} $content.foreach{ $excludeList.add($_.tolower()) } } write-verbose "Full Exclude List: `n`n$($excludeList|format-list|Out-String)" #Actual processing write-verbose 'Getting Folder files' if($psScriptsOnly) { write-verbose 'Getting PS1 Files' $fileList = get-childitem -path $folder -recurse -filter *.ps1|where-object{$_.psIsContainer -eq $false -and $_.name.tolower() -notlike '*.test.ps1' -and $_.name.tolower() -notlike '*.tests.ps1' -and $_.name.tolower() -notlike '*.skip.ps1' -and $_.Name.tolower() -notin $excludeList} }else{ write-verbose 'Getting Folder files' $fileList = get-childitem -path $folder -recurse |where-object{$_.psIsContainer -eq $false -and $_.name.tolower() -notlike '*.test.ps1' -and $_.name.tolower() -notlike '*.tests.ps1' -and $_.name.tolower() -notlike '*.skip.ps1' -and $_.Name.tolower() -notin $excludeList} } write-verbose 'Add custom member values' $fileList.foreach{ $_|Add-Member -MemberType NoteProperty -Name 'RelativePath' -Value $($_.fullname.ToString()).replace("$($folder)$([IO.Path]::DirectorySeparatorChar)",".$([IO.Path]::DirectorySeparatorChar)") if($destination) { $_|add-member -MemberType NoteProperty -name 'newPath' -Value $($_.fullname.ToString()).replace($folder,$destination) $_|Add-Member -name 'newFolder' -memberType NoteProperty -value $($_.directory.ToString()).replace($folder,$destination) } } if($destination) { if($copy) { $fileList.foreach{ write-verbose "Copy file $($_.relativePath) to $($_.newFolder)" if(!(test-path $_.newFolder)) { write-verbose 'Destination folder does not exist, attempt to create' try{ $null = new-item -itemtype directory -path $_.newFolder -force -ErrorAction stop write-verbose "Made new directory at: $($_.newFolder)" }catch{ throw "Error making new directory at: $($_.newFolder)" } } try{ write-verbose "Copying $($_.relativePath) to $($_.newPath)" $null = copy-item -path ($_.fullname) -destination ($_.newPath) -force write-verbose "Copied $($_.relativePath) to $($_.newFolder)" }catch{ throw "Error with Copy: $($_.relativePath) to $($_.newFolder)" } } } $fileList|Select-Object $fileListSelect2 }else{ $fileList|Select-Object $fileListSelect } } } function get-mfGitChangeLog { <# .SYNOPSIS Generates a markdown changelog from Git commit messages between the latest and previous tags. .DESCRIPTION This function retrieves Git commit messages between the latest and previous tags, categorizes them based on predefined types, and formats them into a markdown changelog. It ensures the Git environment is correctly set up and handles errors if Git is not recognized or tags are not found. .EXAMPLE get-mfGitChangeLog DESCRIPTION Call the `get-mfGitChangeLog` function with default change Log Types. The function will generate a markdown changelog that can be sent to release or artifact notes. #### OUTPUT # Change Log Version: v1.0.0 --> v1.1.0 ## New Features - Added new authentication module ## Bug Fixes - Fixed issue with user login .EXAMPLE get-mfGitChangeLog -changeLogTypes @{ 'feat' = 'New Features' 'fix' = 'Bug Fixes' 'chore' = 'Chore and Pipeline work' 'test' = 'Test Changes' } DESCRIPTION This example demonstrates how to call the `get-mfGitChangeLog` function with a custom set of changelog types, in case you want to control your own #### OUTPUT # Change Log Version: v1.0.0 --> v1.1.0 ## New Features - Added new authentication module ## Bug Fixes - Fixed issue with user login ## Chore and Pipeline work - Updated GH Pipeline AutoBuildv3 ## Test Changes - Added test to user login function .EXAMPLE get-mfGitChangeLog -All DESCRIPTION Generates a full markdown changelog with all versions. #### OUTPUT # Change Log ## Version: v1.0.0 --> v1.1.0 ### New Features - Added new authentication module ### Bug Fixes - Fixed issue with user login ### Chore and Pipeline work - Updated GH Pipeline AutoBuildv3 ### Test Changes - Added test to user login function ## Version: v1.0.0-prev001 --> v1.1.0 ### New Features - Added function .INPUTS [hashtable] - Accepts changeLogTypes hashtable via parameter or pipeline .OUTPUTS [STRING] - Returns a Markdown Compatible string output that can be redirected to a file .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Change Logs Types and corresponding Heading. Hashtable/Key Value Pair expected. Key = git type; Value = Heading [Parameter(ValueFromPipeline)] [hashtable]$changeLogTypes = @{ 'feat' = 'New Features' 'fix' = 'Bug Fixes' 'docs' = 'Documentation Changes' 'refactor' = 'Code Rewrite/Refactor' 'perf' = 'Performance Improvements' #'chore' = 'Chore and Pipeline work' #'test' = 'Testing' }, #Switch to get a full changelog for ALL tags [Parameter()] [switch]$all ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" $markDown = [System.Collections.Generic.List[string]]::new() $markDown.add("# Change Log`n") } process{ write-verbose 'Check git commandline and directory is going to work the way we expect it to. Throw if it does not' try{ $gitVer = git --version $gitFolder = git rev-parse --is-inside-work-tree $gitFolder = $gitFolder.trim() }catch{ throw 'Error getting Git Version or working directory.' } if(!$gitVer) { throw 'Git Version undetermined' } if(!$gitFolder -or $gitFolder -notlike '*true*') { throw 'Git not recognised as inside work tree' } write-verbose 'Get the Git Tags. Use the count of tags to determine if we are bundling for a release' #If you run this after tests and build, then creating a new release w/ tag, then you will KNOW that the tag is the new version $tags = git tag --sort=-creatordate $tagCount = $tags.count write-verbose "Got $tagCount total tags; ($($tags -join ','))" if($tagCount -gt 1) { if($all) { write-verbose 'AllFlag set. Getting complete Change Log' $tagRef = 1 #We need a marker to know what the next tag is. Since we start at 0, the ref should start at 1 $tags.foreach{ write-verbose "Getting contents for $_. Reference: $tagRef" $t1 = $_ $markDown.add("## Version: $t1`n") if($tagRef -eq $tagCount){ write-verbose 'Last Entry?' write-verbose "git log `"$($t1)`" --pretty=format:`"%s`"" $commitMessages = git log "$($t1)" --pretty=format:"%s" }else{ $t2 = $tags[$tagRef] write-verbose "git log `"$($t2)..$($t1)`" --pretty=format:`"%s`"" $commitMessages = git log "$($t2)..$($t1)" --pretty=format:"%s" } $commitObjects = $commitMessages.forEach{if($_ -like '*:*'){$s = $_.split(":");[PSCustomObject]@{Type = $s[0].trim();Message = $s[1].trim()}}} $grouped = $commitObjects.where{$_.type -in $changeLogTypes.getEnumerator().name} | group-object -property 'type' $grouped.forEach{ $markDown.Add("`n### $($changeLogTypes.$($_.name))`n") $_.group.Message.ForEach{ $markDown.Add("- $_") } $markDown.Add("`n") } $tagRef++ } }else{ #Only get between the latest and previous release based on tag write-verbose "Found $tagCount total tags. Sorting to get latest and previous" $commitMessages = git log "$($tags[1])..$($tags[0])" --pretty=format:"%s" $markDown.add("Version: $($tags[1]) --> $($tags[0])`n") $commitObjects = $commitMessages.forEach{if($_ -like '*:*'){$s = $_.split(":");[PSCustomObject]@{Type = $s[0].trim();Message = $s[1].trim()}}} $grouped = $commitObjects.where{$_.type -in $changeLogTypes.getEnumerator().name} | group-object -property 'type' $grouped.forEach{ $markDown.Add("`n## $($changeLogTypes.$($_.name))`n") $_.group.Message.ForEach{ $markDown.Add("- $_") } } } }elseIf($tagCount -eq 1){ #Get all and assume this is the first tag write-verbose 'Single Tag found. Get all Commit messages to this point' $commitMessages = git log --pretty=format:"%s" $commitObjects = $commitMessages.forEach{if($_ -like '*:*'){$s = $_.split(":");[PSCustomObject]@{Type = $s[0].trim();Message = $s[1].trim()}}} $grouped = $commitObjects.where{$_.type -in $changeLogTypes.getEnumerator().name} | group-object -property 'type' $grouped.forEach{ $markDown.Add("`n## $($changeLogTypes.$($_.name))`n") $markDown.add("Version: $($tags)`n") $_.group.Message.ForEach{ $markDown.Add("- $_") } } }else{ #Assume there isn't any tags and we aren't bundling this up for a release Write-Warning 'No tags found. May not be a release. This function gets the change log between the previous tag and latest tag. If you are not using Tags this function will not work' #Break here as nothing to action return } $markDownText = $markDown -join "`n" if($markDownText){ return $markDownText } } } function get-mfGitLatestVersion { <# .SYNOPSIS Retrieves the latest Git tag version in semantic version format. .DESCRIPTION This function queries Git for available tags and processes them as semantic versions. If no tags are found, it initializes a new version starting from `1.0.0`. If Git is unavailable or returns an error, a warning is displayed, and processing continues gracefully. ------------ .EXAMPLE get-mfGitLatestVersion #### DESCRIPTION Queries the current Git repository for version tags, identifies the latest version, and returns it in `major.minor.patch` format. #### OUTPUT 1.2.3 (Example: If Git tags include `v1.0.0`, `v1.2.3`, `v1.1.0`, the function returns `1.2.3` as the latest.) .EXAMPLE get-mfGitLatestVersion -Verbose #### DESCRIPTION Runs the function with verbose output, providing detailed debugging information about the Git command execution and version determination. #### OUTPUT ``` ===========Executing get-mfGitLatestVersion=========== Got VersionTags: v1.0.0 v1.2.3 v1.1.0 Latest Tag Version: 1.2.3 ``` .OUTPUTS [semver] - Returns a Semantec Version object .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" } process{ try{ $versionTags = git tag --list 2>&1 if ($LASTEXITCODE -ne 0 -or $versionTags -match "fatal:") { throw "Git Error: $versionTags" } }catch{ Write-Warning "Error with git command: $_" $versionTags = $null } write-verbose "Got VersionTags: $versionTags" if($versionTags) { $versions = $versionTags.ForEach{[semver]::new($_.TrimStart("v"))} $latest = ($versions | Sort-Object -Descending | Select-Object -First 1) Write-Verbose "Latest Tag Version: $($latest.tostring())" } else { Write-Verbose 'Generating new version from scratch at 1' $latest = [semver]::new(1,0,0) } return $latest } } function get-mfLatestSemverFromBuildManifest { <# .SYNOPSIS If you are manually building, and you have access to the \build folder, you can use this to get the next semver .DESCRIPTION Detailed Description ------------ .EXAMPLE get-mfLatestSemverFromBuildManifest #### DESCRIPTION Import build\module\modulemanifest.psd1 Find the prerelease tag(if present) and module version. I.e. module version 1.1.0 prerelease tag prev003 = 1.1.0-prrev003 #### OUTPUT Major Minor Patch PreReleaseLabel BuildLabel ----- ----- ----- --------------- ---------- 1 1 0 prev003 .OUTPUTS [semver] - Returns a Semantec Version object .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Root path of the module. Uses the current working directory by default [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)] [alias('modulePath')] [string]$path = $(get-location).path, [Parameter(DontShow)] [string]$configFile = 'moduleForgeConfig.xml', [Parameter(DontShow)] [string]$moduleNameOverride ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" } process{ #Do some checks and parsing if(! $moduleNameOverride) { #Read the config file write-verbose 'Importing config file' $configPath = join-path -path $path -ChildPath $configFile if(!(test-path $configPath)) { throw "Unable to find config file at: $configPath" } $config = import-clixml $configPath -erroraction stop $moduleName = $config.moduleName }else{ $moduleName = $moduleNameOverride } $buildFolder = Join-Path $path 'build' write-verbose "build folder: $buildFolder" $moduleFolder = join-path $buildFolder $moduleName $manifestFile = get-childItem -Path $moduleFolder -Filter "$moduleName.psd1" if(!$manifestFile){ throw 'Manifest file not found in build folder' }else{ write-verbose "Found manifest at $($manifestFile.FullName)" $ManifestPath = $manifestFile.fullname } #Actual processing write-verbose 'Try and import manifest as datafile' try{ $manifest = Import-PowerShellDataFile -Path $ManifestPath }catch{ throw 'Manifest was found but unable to import as PSDataFile' } write-verbose 'Get Module Version' $moduleVersion = $manifest.ModuleVersion if(!$moduleVersion){ throw 'Err: Unable to get moduleVersion' }else{ write-verbose "Found Version: $moduleVersion" } write-verbose 'Check for PreRelease' $preRelease = $manifest.PrivateData.PSData.Prerelease if($preRelease) { write-verbose "Found Prerelease tag: $($preRelease)" $versionString = "$($moduleVersion)-$($preRelease)" }else{ write-verbose 'No Prerelease was found' $versionString = $moduleVersion } write-verbose 'Attempt to make semver from string' try{ [semver]::new($versionString) }catch{ throw "Err: Could not convert $versionString to semver object" } } } function get-mfNextSemver { <# .SYNOPSIS Increments the version of a Semantic Version (SemVer) object. .DESCRIPTION The `get-mfNextSemver` function takes a Semantic Version (SemVer) object as input and increments the version based on the 'increment' parameter. It can handle major, minor, and patch increments. The function also handles pre-release versions and allows the user to optionally override the pre-release label. .EXAMPLE $version = [SemVer]::new('1.0.0') get-mfNextSemver -version $version -increment 'Minor' -prerelease #### DESCRIPTION This example takes a SemVer object with version '1.0.0', increments the minor version, and adds a pre-release tag. The output will be '1.1.0-prerelease.1'. #### OUTPUT '1.1.0-prerelease.1' .EXAMPLE $version = [SemVer]::new('2.0.0-prerelease.1') get-mfNextSemver -version $version -increment 'Major' #### DESCRIPTION This example takes a SemVer object with version '2.0.0-prerelease.1', increments the major version, and removes the pre-release tag because the 'prerelease' switch is not set. The output will be '3.0.0'. #### OUTPUT '3.0.0' .INPUTS [semver] - Will accept a Semver from pipeline or via direct assignment .OUTPUTS [semver] - Returns a Semantec Version object that should increment, based on the other parameters, the input semver .NOTES Author: Adrian Andersson #> [CmdletBinding(DefaultParameterSetName='default')] PARAM( #Semver Version [Parameter(Mandatory,ValueFromPipeline,ValueFromPipelineByPropertyName,ParameterSetName='default')] [Parameter(Mandatory,ValueFromPipeline,ValueFromPipelineByPropertyName,ParameterSetName='preRelease')] [SemVer]$version, #What are we incrementing [Parameter(ParameterSetName='default')] [Parameter(ParameterSetName='preRelease')] [ValidateSet('Major','Minor','Patch')] [string]$increment, #Is this a prerelease [Parameter(ParameterSetName='preRelease')] [switch]$prerelease, #Is this a prerelease [Parameter(ParameterSetName='default')] [switch]$stableRelease, #Optional override the prerelease label. If not supplied will use 'prerelease' [Parameter(ParameterSetName='preRelease')] [Parameter(ParameterSetName='Initial')] [string]$preReleaseLabel, #Is this the initial prerelease [Parameter(ParameterSetName='Initial')] [switch]$initialPreRelease ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" #Making the default preReleaase label to be lower case. This addresses a problem with psResourceGet and AzureDevOps repositories specifically (Issue #1787) $defaultPrereleaseLabel = 'pre' if (-not $increment -and -not $prerelease -and -not $initialPreRelease -and -not $stableRelease) { throw 'At least one of "increment", parameter or "stableRelease", "prerelease", "initialPreRelease" switch should be supplied.' } } process{ # Increment the version based on the 'increment' parameter switch ($increment) { 'Major' { #$nextVersion = $version.IncrementMajor() $nextVersion = [semver]::new($version.Major+1,0,0) write-verbose "Incrementing Major Version to: $($nextVersion.tostring())" } 'Minor' { $nextVersion = [semver]::new($version.Major,$version.minor+1,0) write-verbose "Incrementing Minor Version to: $($nextVersion.tostring())" } 'Patch' { $nextVersion = [semver]::new($version.Major,$version.minor,$version.Patch+1) write-verbose "Incrementing Patch Version to: $($nextVersion.tostring())" } } # Handle pre-release versions if($prerelease -and !$nextVersion -and $version.PreReleaseLabel) { #This scenario indicates version supplied is already a prerelease, and what we want to do is increment the prerelease version write-verbose 'Incrementing Prerelease Version' $currentPreReleaseSplit = $version.PreReleaseLabel.Split('v') $currentpreReleaseLabel = $currentPreReleaseSplit[0] write-verbose "Current PreRelease Label: $currentpreReleaseLabel" if(!$preReleaseLabel -or ($currentpreReleaseLabel -ceq $preReleaseLabel)){ if($currentpreReleaseLabel -eq $preReleaseLabel) { write-warning 'It appears the prerelease casing has changed, but the label has not. This may cause unexpected ordering results.' } write-verbose 'No change to prerelease label' $nextPreReleaseLabel = $currentpreReleaseLabel $currentPreReleaseInt = [int]$currentPreReleaseSplit[1] $nextPrerelease = $currentPreReleaseInt+1 }else{ write-verbose 'Prerelease label changed. Resetting prerelease version to 1' $nextPreReleaseLabel = $preReleaseLabel $nextPreRelease = 1 } $nextVersionString = "$($version.major).$($version.minor).$($version.patch)-$($nextPreReleaseLabel)v$('{0:d3}' -f $nextPrerelease)" $nextVersion = [semver]::New($nextVersionString) write-verbose "Next Prerelease will be: $($nextVersion.ToString())" }elseIf($prerelease -and $nextVersion) { write-verbose 'Need to tag incremented version as PreRelease' #This scenario indicates we have incremented a major,minor or patch, and need to start a fresh prerelease if(!$preReleaseLabel){ $nextPreReleaseLabel = $defaultPrereleaseLabel }else{ $nextPreReleaseLabel = $preReleaseLabel } $nextVersionString = "$($nextVersion.major).$($nextVersion.minor).$($nextVersion.patch)-$($nextPreReleaseLabel)v001" $nextVersion = [semver]::New($nextVersionString) write-verbose "Next Prerelease will be: $($nextVersion.ToString())" }elseIf($prerelease){ #This is a strange scenario. Indicates that we have prerelease switch,but the version supplied wasn't a prerelease already. And we didn't increment anything. #Are we supposed to go backwards #throw 'Unsure on version scenario. Prerelease wanted but version provided was not a pre-release. Please provide a version with existing prerelease, or include an increment' #I think what we do, is we increment patch by 1 and then tag as pre-release write-warning 'Unspecified version increment. Will increment Patch. If this is not what you meant, please try again' if(!$preReleaseLabel){ $nextPreReleaseLabel = $defaultPrereleaseLabel }else{ $nextPreReleaseLabel = $preReleaseLabel } $nextVersionString = "$($version.major).$($version.minor).$($version.patch+1)-$($nextPreReleaseLabel)v001" $nextVersion = [semver]::New($nextVersionString) }elseIf($initialPreRelease){ if(!$preReleaseLabel){ $nextPreReleaseLabel = $defaultPrereleaseLabel }else{ $nextPreReleaseLabel = $preReleaseLabel } write-verbose 'Start at v1 prerelease v001' $nextVersionString = "1.0.0-$($nextPreReleaseLabel)v001" $nextVersion = [semver]::New($nextVersionString) }elseIf($stableRelease) { write-verbose 'Mark release as stable' #This scenario is for when we have a pre-release tag and we want to drop it for a stable release version if(!($version.PreReleaseLabel)) { throw 'version supplied does not contain a prerelease' } $nextVersionString = "$($version.major).$($version.minor).$($version.patch)" $nextVersion = [semver]::New($nextVersionString) write-verbose "Stable Release Version: $($nextVersion.tostring())" } return $nextVersion } } function new-mfProject { <# .SYNOPSIS Capture some basic parameters, and create the scaffold file structure .DESCRIPTION The new-mfProject function streamlines the process of creating a scaffold (or basic structure) for a new PowerShell module. Whether you’re building a custom module for automation, administration, or any other purpose, this function sets up the initial directory structure, essential files, and variables and properties. Think of it as laying the foundation for your module project. ------------ .EXAMPLE new-mfProject -ModuleName "MyModule" -description "A module for automating tasks" -moduleAuthors "John Doe" -companyName "MyCompany" -moduleTags "automation", "tasks" -projectUri "https://github.com/username/repo" -iconUri "https://example.com/icon.png" -licenseUri "https://example.com/license" -RequiredModules @("Module1", "Module2") -ExternalModuleDependencies @("Dependency1", "Dependency2") -DefaultCommandPrefix "MyMod" -PrivateData @{} #### DESCRIPTION This example demonstrates how to use the `new-mfProject` function to create a scaffold for a new PowerShell module named "MyModule". It includes a description, authors, company name, tags, project URI, icon URI, license URI, required modules, external module dependencies, default command prefix, and private data. #### OUTPUT The function will create the directory structure and essential files for the new module "MyModule" in the current working directory. It will also set up the specified metadata and dependencies. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #The name of your module [Parameter(Mandatory)] [string]$ModuleName, #A description of your module. Is used as the descriptor in the module repository [Parameter(Mandatory)] [string]$description, #Minimum PowerShell version. Defaults to 7.2 as this is the current LTS version [Parameter()] [version]$minimumPsVersion = [version]::new('7.2.0'), #Who are the primary module authors. Can expand later with add-mfmoduleAuthors command [Parameter()] [string[]]$moduleAuthors, #Company Name. If you are building this module for your organisation, this is where it goes [Parameter()] [string]$companyName, #Module Tags. Used to help discoverability and compatibility in package repositories [Parameter()] [String[]]$moduleTags, #Root path of the module. Uses the current working directory by default [alias('modulePath')] [string]$path = $(get-location).path, #Project URI. Will try and read from Git if your using a git repository. [Parameter()] [string]$projectUri = $(try{git config remote.origin.url}catch{$null}), # A URL to an icon representing this module. [Parameter()] [string]$iconUri, #URI to use for your projects license. Will try and use the license file if a projectUri is found [Parameter()] [string]$licenseUri, [Parameter(DontShow)] [string]$configFile = 'moduleForgeConfig.xml', #Modules that must be imported into the global environment prior to importing this module [Parameter()] [Object[]]$RequiredModules, #Modules that must be imported into the global environment prior to importing this module [Parameter()] [String[]]$ExternalModuleDependencies, [Parameter()] [String]$DefaultCommandPrefix, [Parameter()] [object[]]$PrivateData ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" #I'm not sure why I had this in here. Cannot remember. if($path -like '*\' -or $path -like '*/' ) { Write-Verbose 'Superfluous \ or / character found at end of modulePath, removing' $path = $path.Substring(0,$($path.Length-1)) Write-Verbose "New path = $path" } $configPath = join-path -path $path -childpath $configFile } process{ write-verbose 'Validating Module Path' if(!(test-path $path)) { throw "ModulePath: $path not found" } write-verbose 'Checking for Existing Config' if(test-path $configPath) { throw "Config already found at: $configPath" } write-verbose 'Create Folder Scaffold' add-mfFilesAndFolders -moduleRoot $path <# if($projectUri -and !$licenseUri) { write-verbose 'Auto-checking for license' if(test-path $(join-path -path $path -childPath 'LICENSE')) { $licenseUri = "$projectUri\LICENSE" } } #> #Should we use JSON for this, or CLIXML. #The vote from the internet in July 2024 is stick to CLIXML for PowerShell centric projects. So we will do that $moduleForgeReference = get-module 'ModuleForge'|Sort-Object version -Descending|Select-Object -First 1 if(! $moduleForgeReference) { $moduleForgeReference = get-module -listavailable 'ModuleForge'|Sort-Object version -Descending|Select-Object -First 1 } write-verbose 'Create config file' $config = [psCustomObject]@{ #The params set from this function moduleName = $ModuleName description = $description minimumPsVersion = $minimumPsVersion moduleAuthors = [array]$moduleAuthors companyName = $companyName tags = [array]$moduleTags #Some automatic variables projectUri = $projectUri licenseUri = $licenseUri guid = $(new-guid).guid moduleforgeVersion = $(if($moduleForgeReference){ $moduleForgeReference.Version.ToString()}else{'n/a'}) iconUri = $iconUri requiredModules = $RequiredModules ExternalModuleDependencies = $ExternalModuleDependencies DefaultCommandPrefix = $DefaultCommandPrefix PrivateData = $PrivateData } write-verbose "Exporting config to: $configPath" try{ $config|export-clixml $configPath }catch{ throw 'Error exporting config' } } } function register-mfLocalPsResourceRepository { <# .SYNOPSIS Add a local file-based PowerShell repository into the systems temp location .DESCRIPTION Allows you to test psresourceGet, as well as directly manipulate the nuget package, for example, to add git data to the nuspec .EXAMPLE register-mfLocalPsResourceRepository #### DESCRIPTION Create a powershell file repository using default values. Repository will be called: LocalTestRepository Path will be where-ever [System.IO.Path]::GetTempPath() points .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Name of the repository [Parameter()] [string]$repositoryName = 'LocalTestRepository', #Root path of the module. Uses Temp Path by default [Parameter()] [string]$path = [System.IO.Path]::GetTempPath() ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" $psResourceGet = @{ name = 'Microsoft.PowerShell.PSResourceGet' version = [version]::new('1.0.4') } $psResourceGetRef = get-module $psResourceGet.name -ListAvailable|Sort-Object -Property Version -Descending|Select-Object -First 1 if(!$psResourceGetRef -or $psResourceGetRef.Version -lt $psResourceGet.version) { throw "Module dependancy Name: $($psResourceGet.Name) minver:$($psResourceGet.version) Not found. Please install from the PSGallery" } $repositoryLocation = join-path $path -ChildPath $repositoryName } process{ write-verbose "Checking we dont already have a repository with name: $repositoryName" if(!(Get-PSResourceRepository -Name $repositoryName -erroraction Ignore)) { write-verbose 'Repository not found.' write-verbose "Checking for drive location at:`n`t$($repositoryLocation)" if(!(test-path $repositoryLocation)) { try{ New-Item -ItemType Directory -Path $repositoryLocation write-verbose 'Directory Created' }Catch{ Throw 'Error creating directory' } } $registerSplat = @{ Name = $repositoryName URI = $repositoryLocation Trusted = $true } write-verbose 'Registering resource repository' try{ Register-PSResourceRepository @registerSplat }catch{ throw 'Error creating temporary repository' } write-verbose 'Test Repository was created' if(!(Get-PSResourceRepository -Name $repositoryName -erroraction Ignore)) { throw 'Something has gone wrong. Unable to find repository' }else{ write-verbose 'Repository looks healthy' } }else{ write-verbose "$repositoryName Found" } } } function remove-mfLocalPsResourceRepository { <# .SYNOPSIS Remove the local test repository that was created with register-mfLocalPsResourceRepository .DESCRIPTION If a local test repository was created with the register-mfLocalPsResourceRepository, this command will remove it It will also remove the directory that hosted the local repository .EXAMPLE remove-mfLocalPsResourceRepository .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Name of the repository [Parameter()] [string]$repositoryName = 'LocalTestRepository', #Root path of the module. Uses Temp Path by default [Parameter()] [string]$path = [System.IO.Path]::GetTempPath() ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" $psResourceGet = @{ name = 'Microsoft.PowerShell.PSResourceGet' version = [version]::new('1.0.4') } $psResourceGetRef = get-module $psResourceGet.name -ListAvailable|Sort-Object -Property Version -Descending|Select-Object -First 1 if(!$psResourceGetRef -or $psResourceGetRef.Version -lt $psResourceGet.version) { throw "Module dependancy Name: $($psResourceGet.Name) minver:$($psResourceGet.version) Not found. Please install from the PSGallery" } $repositoryLocation = join-path $path -ChildPath $repositoryName } process{ write-verbose 'Clean up the repository' write-verbose "Checking we dont already have a repository with name: $repositoryName" $repoRef = (Get-PSResourceRepository -Name $repositoryName -erroraction Ignore) if($repoRef) { write-verbose 'Repository reference found, try and remove' Try{ unregister-PSResourceRepository -name $repositoryName -ErrorAction Stop }catch{ throw 'Error unregistering the Resource Repository' } } if((test-path $repositoryLocation)) { write-verbose "File folder found at: $repositoryLocation" try{ remove-item $repositoryLocation -force -ErrorAction Stop -Recurse write-verbose 'Directory removed' }Catch{ Throw 'Error Removing directory' } } } } function update-mfProject { <# .SYNOPSIS Update the parameters of a moduleForge project .DESCRIPTION This command allows you to update any of the parameters that were saved with the new-mfProject function without having to recreate the whole project file from scratch. ------------ .EXAMPLE update-mfProject -ModuleName "UpdatedModule" -description "An updated description for the module" -moduleAuthors "Jane Doe" -companyName "UpdatedCompany" -moduleTags "updated", "module" -projectUri "https://github.com/username/updated-repo" -iconUri "https://example.com/updated-icon.png" -licenseUri "https://example.com/updated-license" -RequiredModules @("UpdatedModule1", "UpdatedModule2") -ExternalModuleDependencies @("UpdatedDependency1", "UpdatedDependency2") -DefaultCommandPrefix "UpdMod" -PrivateData @{} #### DESCRIPTION This example demonstrates how to use the `update-mfProject` function to update multiple parameters of an existing module project. It updates the module name, description, authors, company name, tags, project URI, icon URI, license URI, required modules, external module dependencies, default command prefix, and private data. #### OUTPUT The function will update the specified parameters in the module project configuration file. .EXAMPLE update-mfProject -ModuleName "UpdatedModule" -description "An updated description for the module" #### DESCRIPTION This example demonstrates how to use the `update-mfProject` function to update only the module name and description of an existing module project. It leaves all other parameters unchanged. #### OUTPUT The function will update the module name and description in the module project configuration file. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #The name of your module [Parameter()] [string]$ModuleName, #A description of your module. Is used as the descriptor in the module repository [Parameter()] [string]$description, #Minimum PowerShell version. Defaults to 7.2 as this is the current LTS version [Parameter()] [version]$minimumPsVersion, #Who are the primary module authors. Can expand later with add-mfmoduleAuthors command [Parameter()] [string[]]$moduleAuthors, #Company Name. If you are building this module for your organisation, this is where it goes [Parameter()] [string]$companyName, #Module Tags. Used to help discoverability and compatibility in package repositories [Parameter()] [String[]]$moduleTags, #Source Code Repository to use, i.e. your repositories github/azure devops uri [Parameter()] [string]$projectUri, # A URL to an icon representing this module. [Parameter()] [string]$iconUri, #URI to use for your projects license. Will try and use the license file if a projectUri is found [Parameter()] [string]$licenseUri, #Modules that must be imported into the global environment prior to importing this module [Parameter()] [Object[]]$RequiredModules, #Modules that must be imported into the global environment prior to importing this module [Parameter()] [String[]]$ExternalModuleDependencies, #If you are specifying a Default Command Prefix via your manifest, this will update that prefix [Parameter()] [String[]]$DefaultCommandPrefix, #If you have any additional Private Data you want to add to your module manifest, add it here [Parameter()] [object[]]$PrivateData, #Root path of the module. Uses the current working directory by default [Parameter()] [Alias('modulePath')] [string]$path = $(get-location).path, #Module Config File [Parameter(DontShow)] [string]$configFile = 'moduleForgeConfig.xml' ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" write-verbose 'Testing module path' $moduleTest = get-item $path if(!$moduleTest){ throw "Unable to read from $path" } $path = $moduleTest.FullName write-verbose "update module config in: $path" #Read the config file write-verbose 'Importing config file' $configPath = join-path -path $path -ChildPath $configFile if(!(test-path $configPath)) { throw "Unable to find config file at: $configPath" } $config = import-clixml $configPath -erroraction stop } process{ if($ModuleName) { write-verbose "Updating Module name from: $($config.moduleName) -> $($moduleName)" $config.moduleName = $moduleName } if($description) { write-verbose "Updating Module description from: $($config.description) -> $($description)" $config.description = $description } if($minimumPsVersion) { write-verbose "Updating Module minimumPsVersion from: $($config.minimumPsVersion.tostring()) -> $($minimumPsVersion.tostring())" $config.minimumPsVersion = $minimumPsVersion } if($moduleAuthors) { write-verbose "Updating Module moduleAuthors from: $($config.moduleAuthors) -> $($moduleAuthors)" $config.moduleAuthors = $moduleAuthors } if($companyName) { write-verbose "Updating Module companyName from: $($config.companyName) -> $($companyName)" $config.companyName = $companyName } if($moduleTags) { write-verbose "Updating Module tags from: $($config.tags) -> $($tags)" $config.tags = $moduleTags } if($projectUri) { write-verbose "Updating Module projectUri from: $($config.projectUri) -> $($projectUri)" $config.projectUri = $projectUri } if($iconUri) { write-verbose "Updating Module iconUri from: $($config.iconUri) -> $($iconUri)" $config.iconUri = $iconUri } if($licenseUri) { write-verbose "Updating Module licenseUri from: $($config.licenseUri) -> $($licenseUri)" $config.licenseUri = $licenseUri } if($RequiredModules) { write-verbose "Updating Module RequiredModules from: $($config.RequiredModules|convertTo-json -depth 4)`n`n`t ->`n $($RequiredModules|convertTo-json -depth 4)" $config.RequiredModules = $RequiredModules } if($ExternalModuleDependencies) { write-verbose "Updating Module ExternalModuleDependencies from: $($config.ExternalModuleDependencies -join '; ') -> $($ExternalModuleDependencies -join '; ')" $config.ExternalModuleDependencies = $ExternalModuleDependencies } if($DefaultCommandPrefix) { write-verbose "Updating Module DefaultCommandPrefix from: $($config.DefaultCommandPrefix) -> $($DefaultCommandPrefix)" $config.DefaultCommandPrefix = $DefaultCommandPrefix } if($PrivateData) { write-verbose "Updating Module PrivateData from: $($config.PrivateData|convertTo-json -depth 4)`n`n`t ->`n $($PrivateData|convertTo-json -depth 4)" $config.PrivateData = $PrivateData } write-verbose "Exporting config to: $configPath" try{ $config|export-clixml $configPath }catch{ throw 'Error exporting config' } } } function write-mfModuleDocs { <# .SYNOPSIS Generates and updates function documentation using PlatyPS, and creates an index for module functions. .DESCRIPTION This function automates documentation generation based on module functions, leveraging PlatyPS to create and update Markdown help files. It also builds an `index.md` file for organizing documentation, making it easier to integrate with GitHub Pages or other documentation platforms. Additionally, if specified, it includes a changelog based on Git commits. .EXAMPLE write-mfModuleDocs -ModuleName 'MyCustomModule' -includeChangeLog #### DESCRIPTION Builds documentation for `MyCustomModule` and includes a full Git-based changelog (`changeLog.md`) alongside function help files. .EXAMPLE write-mfModuleDocs -ModuleName 'MyCustomModule' -skipIndex #### DESCRIPTION Generates function documentation without updating `index.md`. .INPUTS [string] - Path is accepted as pipeline input or via direct assignment. It has a defaulf value and does not need to be set .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #The root path where documentation should be stored. Defaults to the current directory. [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)] [alias('modulePath')] [string]$Path = $(get-item .).fullname, #The name of the PowerShell module for which documentation should be generated. [Parameter(Mandatory)] [alias('module')] [ValidateScript({ Get-Module -Name $_ -ErrorAction SilentlyContinue })] [string]$ModuleName, #Specifies the subfolder within `docsFolder` where function-specific documentation should be stored. Defaults to `functions`. [Parameter()] [alias('docsPath')] [string]$docsFolder = 'docs', #Specifies the subfolder within `docsFolder` where function-specific documentation should be stored. Defaults to `functions`. [Parameter()] [alias('functionsPath')] [string]$functionsFolder = 'functions', #If specified, retrieves and includes a Markdown changelog based on Git commit history. [Parameter()] [switch]$includeChangeLog, #If specified, skips creating or updating the `index.md` file. [Parameter()] [switch]$skipIndex ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" $platyPs = (get-module -Name PlatyPS -ListAvailable|Sort-Object -Property version -Descending|select-object -first 1) if($platyPs) { write-verbose "Build Function Documentation with PlatyPS Version $($platyPs.version)" }else{ throw "This function relies on module PlatyPS. Please install it from the PSGallery" } $module = (get-module -Name $ModuleName|Sort-Object -Property version -Descending|select-object -first 1) if($module) { write-verbose "Build Function Documentation for Module $ModuleName - version: $($module.version)" }else{ throw "Module is not pre-loaded. Please import the module first" } $customFolderSelect = @( 'name' 'basename' @{ name = 'baseFolder' expression = {$($_.Directory.FullName.replace($DocsFullPath,''))} } @{ name = 'leaf' expression = {$(split-path -path $_.Directory.FullName -leaf)} } @{ name = 'relLink' expression = {("./$($($_.Directory.FullName.replace($DocsFullPath,'')).replace('\','/'))/$($_.name)").replace('//','/')} } @{ name = 'subjectGroup' expression = {("$($($_.Directory.FullName.replace($DocsFullPath,'')).replace('\','/'))").replace('//','/').trimStart('/')} } ) } process{ #Checks and parses write-verbose "In path $path" $DocsFullPath = join-path -Path $Path -ChildPath $docsFolder if(!(test-path $DocsFullPath)) { write-verbose 'Need to make docs folder as it does not exist' New-Item -ItemType Directory -Path $DocsFullPath } $functionsFullPath = join-path -Path $DocsFullPath -ChildPath $functionsFolder if(!(test-path $functionsFullPath)) { write-verbose 'Need to make functions folder as it does not exist' New-Item -ItemType Directory -Path $functionsFullPath }else{ write-verbose 'Recreating Functions Folder' try{ remove-item $functionsFullPath -ErrorAction Stop -Force -Recurse }catch{ throw 'Error cleaning up existing functions folder' } New-Item -ItemType Directory -Path $functionsFullPath } #Actual Process write-verbose 'Create markdown help from module help' New-MarkdownHelp -Module $module.Name -Force -OutputFolder $functionsFullPath if($includeChangeLog) { write-verbose 'Creating releaseNotes file' $changeLog = get-mfGitChangeLog -all write-verbose "ChangeLog: `n$($changeLog)" if($changeLog) { $changeLogPath = Join-Path $DocsFullPath -ChildPath 'changeLog.md' $changeLog | Out-File -FilePath $changeLogPath -Force }else{ write-warning 'changeLog notes were not captured as none existed, or something went wrong' } } if($skipIndex){ Write-Verbose 'Skipping Index File' }else{ $indexContent = [System.Collections.Generic.List[string]]::new() $indexContent.add("# Documentation Index`n") $folderContent = Get-ChildItem -Path $DocsFullPath -Filter '*.md' -Recurse|Select-Object $customFolderSelect $folderGroup = $folderContent|Group-Object -Property 'subjectGroup' ($folderGroup.where{$_.'name' -eq ''}.group).foreach{ if($_.'basename' -ne 'index') { $indexContent.add("- [$($_.baseName)]($($_.relLink))") } } $folderGroup.where{$_.'name' -ne ''}.forEach{ $indexContent.add("`n## $($_.'name')`n") $_.group.foreach{ $indexContent.add("- [$($_.baseName)]($($_.relLink))") } } write-verbose 'Create Index File' $indexPath = Join-Path $DocsFullPath -ChildPath 'index.md' $indexContent -join "`n"|out-file $indexPath -Force } } } function add-mfFilesAndFolders { <# .SYNOPSIS Add the file and folder structure required by moduleForge .DESCRIPTION Create the folder structure as a scaffold, If a folder does not exist, create it. .NOTES Author: Adrian Andersson #> [CmdletBinding()] PARAM( #Root Path for module folder. Assume current working directory [Parameter(ValueFromPipelineByPropertyName,ValueFromPipeline)] [string]$moduleRoot = (Get-Item .).FullName #Use the fullname so that we don't have problems with PSDrive, symlinks, confusing bits etc ) begin{ #Return the script name when running verbose, makes it tidier write-verbose "===========Executing $($MyInvocation.InvocationName)===========" #Return the sent variables when running debug Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)" $rootDirectories = @('source') $sourceDirectories = @('functions','enums','classes','filters','validationClasses','private','bin','resource') $emptyFiles = @('.gitignore','.mfignore') } process{ write-verbose 'Verifying base folder structure' $rootDirectories.foreach{ $fullPath = Join-Path -path $moduleRoot -ChildPath $_ if(test-path $fullPath) { write-verbose "Directory: $fullpath is OK" }else{ write-information "Directory: $fullpath not found. Will create" -tags 'FileCreation' try{ $result = new-item -itemtype directory -Path $fullPath -ErrorAction Stop }catch{ throw "Unable to make new directory: $result. Please check permissions and conflicts" } } if($_ -eq 'source') { write-verbose 'Source Folder: Checking for subdirectories and files in source folder' $sourceDirectories.foreach{ $subdirectoryFullPath = join-path -path $fullPath -childPath $_ if(test-path $subdirectoryFullPath) { write-verbose "Directory: $subdirectoryFullPath is OK" }else{ write-information "Directory: $subdirectoryFullPath not found. Will create" -tags 'FileCreation' try{ $null = new-item -itemtype directory -Path $subdirectoryFullPath -ErrorAction Stop }catch{ throw "Unable to make new directory: $subdirectoryFullPath. Please check permissions and conflicts" } } $emptyFiles.ForEach{ $filePath = join-path $subdirectoryFullPath -childPath $_ if(test-path $filePath) { write-verbose "File: $filePath is OK" }else{ write-information "File: $filePath not found. Will create" -tags 'FileCreation' try{ $null = new-item -itemtype File -Path $filePath -ErrorAction Stop }catch{ throw "Unable to make new directory: $filePath. Please check permissions and conflicts" } } } } } } } } #Support function for get-mfDependencyTree function printTree { param( [string]$node, [int]$level = 0 ) $indent = ' ' * $level write-output "$indent >--DEPENDS-ON--> $node" if ($tree.ContainsKey($node)) { foreach ($child in $tree[$node]) { printTree -node $child -level ($level + 1) } } } |