ModuleTools.psm1
<# .SYNOPSIS Retrieves information about a project by reading data from a project.json file in ModuleTools project folder. .DESCRIPTION The Get-MTProjectInfo function retrieves information about a project by reading data from a project.json file located in the current directory. Ensure you navigate to a module directory which has project.json in root directory. Most variables are already defined in output of this command which can be used in pester tests and other configs. .PARAMETER None This function does not accept any parameters. .EXAMPLE Get-MTProjectInfo Retrieves project information from the project.json file in the current directory. Useful for debuggin and writing pester tests. .OUTPUTS hastable with all project data. #> function Get-MTProjectInfo { $Out = @{} $ProjectRoot = Get-Location | Convert-Path $Out['ProjecJSON'] = Join-Path -Path $ProjectRoot -ChildPath 'project.json' if (-not (Test-Path $Out.ProjecJSON)) { Write-Error 'Not a Project folder, project.json not found' -ErrorAction Stop } ## Metadata, Import all json data $jsonData = Get-Content -Path $Out.ProjecJSON | ConvertFrom-Json -AsHashtable foreach ($key in $jsonData.Keys) { $Out[$key] = $jsonData[$key] } $ProjectName = $Out.ProjectName ## Folders $Out['ProjectRoot'] = $ProjectRoot $Out['PublicDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'public') $Out['PrivateDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'private') $Out['OutputDir'] = [System.IO.Path]::Join($ProjectRoot, 'dist') $Out['OutputModuleDir'] = [System.IO.Path]::Join($Out.OutputDir, $ProjectName) $Out['ModuleFilePSM1'] = [System.IO.Path]::Join($Out.OutputModuleDir, "$ProjectName.psm1") $Out['ManifestFilePSD1'] = [System.IO.Path]::Join($Out.OutputModuleDir, "$ProjectName.psd1") return $Out } <# .SYNOPSIS Invokes the process to build a module in ModuleTools format. .DESCRIPTION This function is used to build a module, dist folder is cleaned up and whole module is build from scracth. copies all necessary resource files. .PARAMETER None This function does not accept any parameters. .EXAMPLE Invoke-MTBuild Invokes the process to build a module. #> function Invoke-MTBuild { [CmdletBinding()] param ( ) $ErrorActionPreference = 'Stop' Reset-ProjectDist Build-Module Build-Manifest Copy-ProjectResource } <# .SYNOPSIS Runs Pester tests for using settings from project.json .DESCRIPTION This function runs Pester tests using the specified configuration and settings in project.json. Place all your tests in "tests" folder .PARAMETER None This function does not have any parameters. .EXAMPLE Invoke-MTTest Runs the Pester tests for the project. #> function Invoke-MTTest { [CmdletBinding()] param () Test-ProjectSchema Pester | Out-Null $Script:data = Get-MTProjectInfo $pesterConfig = New-PesterConfiguration -Hashtable $data.Pester $testPath = './tests' $pesterConfig.Run.Path = $testPath $pesterConfig.Run.PassThru = $true $pesterConfig.Run.Exit = $true $pesterConfig.Run.Throw = $true $pesterConfig.TestResult.OutputPath = './dist/TestResults.xml' $TestResult = Invoke-Pester -Configuration $pesterConfig if ($TestResult.Result -ne 'Passed') { Write-Error 'Tests failed' -ErrorAction Stop return $LASTEXITCODE } } <# .SYNOPSIS Create module scaffolding along with project.json file to easily build and manage modules .DESCRIPTION This command creates folder structure and project.json file easily. Use this to quikcly setup a ModuleTools compatible module. .PARAMETER Path Path where module will be created. Provide root folder path, module folder will be created as subdirectory. Path should be valid. .EXAMPLE New-MTModule -Path c:\work # Creates module inside c:\work folder .NOTES The structure of the ModuleTools module is meticulously designed according to PowerShell best practices for module development. While some design decisions may seem unconventional, they are made to ensure that ModuleTools and the process of building modules remain straightforward and easy to manage. #> function New-MTModule { [CmdletBinding(SupportsShouldProcess = $true)] param ( [string]$Path = (Get-Location).Path ) $ErrorActionPreference = 'Stop' if (-not(Test-Path $Path)) { Write-Error 'Not a valid path' } $Questions = [ordered]@{ ProjectName = @{ Caption = 'Module Name' Message = 'Enter Module name of your choice, should be single word with no special characters' Prompt = 'Name' Default = 'MANDATORY' } Description = @{ Caption = 'Module Description' Message = 'What does your module do? Describe in simple words' Prompt = 'Description' Default = 'ModuleTools Module' } Version = @{ Caption = 'Semantic Version' Message = 'Starting Version of the module (Default: 0.0.1)' Prompt = 'Version' Default = '0.0.1' } Author = @{ Caption = 'Module Author' Message = 'Enter Author or company name' Prompt = 'Name' Default = 'PS' } PowerShellHostVersion = @{ Caption = 'Supported PowerShell Version' Message = 'What is minimum supported version of PowerShell for this module (Default: 7.4)' Prompt = 'Version' Default = '7.4' } EnableGit = @{ Caption = 'Git Version Control' Message = 'Do you want to enable version controlling using Git' Prompt = 'EnableGit' Default = 'No' Choice = @{ Yes = 'Enable Git' No = 'Skip Git initialization' } } EnablePester = @{ Caption = 'Pester Testing' Message = 'Do you want to enable basic Pester Testing' Prompt = 'EnablePester' Default = 'No' Choice = @{ Yes = 'Enable pester to perform testing' No = 'Skip pester testing' } } } $Answer = @{} $Questions.Keys | ForEach-Object { $Answer.$_ = Read-AwesomeHost -Ask $Questions.$_ } # TODO check other components if ($Answer.ProjectName -notmatch '^[A-Za-z][A-Za-z0-9_.]*$') { Write-Error 'Module Name invalid. Module should be one word and contain only Letters,Numbers and ' } $DirProject = Join-Path -Path $Path -ChildPath $Answer.ProjectName $DirSrc = Join-Path -Path $DirProject -ChildPath 'src' $DirPrivate = Join-Path -Path $DirSrc -ChildPath 'private' $DirPublic = Join-Path -Path $DirSrc -ChildPath 'public' $DirResources = Join-Path -Path $DirSrc -ChildPath 'resources' $DirTests = Join-Path -Path $DirProject -ChildPath 'tests' $ProjectJSONFile = Join-Path $DirProject -ChildPath 'project.json' if (Test-Path $DirProject) { Write-Error 'Project already exists, aborting' | Out-Null } # Setup Module ($DirProject, $DirSrc, $DirPrivate, $DirPublic, $DirResources, $DirTests) | ForEach-Object { 'Creating Directory: {0}' -f $_ | Write-Verbose New-Item -ItemType Directory -Path $_ | Out-Null } ## Create ProjectJSON $JsonData = Get-Content "$PSScriptRoot\resources\ProjectTemplate.json" -Raw | ConvertFrom-Json -AsHashtable $JsonData.ProjectName = $Answer.ProjectName $JsonData.Description = $Answer.Description $JsonData.Version = $Answer.version $JsonData.Manifest.Author = $Answer.Author $JsonData.Manifest.PowerShellHostVersion = $Answer.PowerShellHostVersion $JsonData.Manifest.GUID = (New-Guid).GUID if ($Answer.EnablePester -eq 'No') { $JsonData.Remove('Pester') } Write-Verbose $JsonData $JsonData | ConvertTo-Json | Out-File $ProjectJSONFile 'Module {0} scaffolding complete' -f $Answer.ProjectName | Write-Host -ForegroundColor Green } function Build-Module { Write-Verbose 'Buidling module psm1 file' $data = Get-MTProjectInfo Test-ProjectSchema -Schema Build | Out-Null $sb = [System.Text.StringBuilder]::new() # Public Folder $files = Get-ChildItem -Path $data.PublicDir -Filter *.ps1 $files | ForEach-Object { $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null } # Private Folder $files = Get-ChildItem -Path $data.PrivateDir -Filter *.ps1 -ErrorAction SilentlyContinue if ($files) { $files | ForEach-Object { $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null } } try { Set-Content -Path $data.ModuleFilePSM1 -Value $sb.ToString() -Encoding 'UTF8' -ErrorAction Stop # psm1 file } catch { Write-Error 'Failed to create psm1 file' -ErrorAction Stop } } function Build-Manifest { Write-Verbose 'Building psd1 data file Manifest' $data = Get-MTProjectInfo ## TODO - DO schema check $PubFunctionFiles = Get-ChildItem -Path $data.PublicDir -Filter *.ps1 $functionToExport = @() $PubFunctionFiles | ForEach-Object { $functionToExport += Get-FunctionNameFromFile -filePath $_.FullName } $ParmsManifest = @{ Path = $data.ManifestFilePSD1 Author = $data.Manifest.Author Description = $data.Description FunctionsToExport = $functionToExport RootModule = "$($data.ProjectName).psm1" ModuleVersion = $data.Version PowerShellHostVersion = $data.Manifest.PowerShellHostVersion Guid = $data.Manifest.GUID Tags = $data.Manifest.Tags } if ($data.Manifest.ProjecUri) { $ParmsManifest.add('ProjectUri', $data.Manifest.ProjecUri) } if ($data.Manifest.LicenseUri) { $ParmsManifest.add('LicenseUri', $data.Manifest.LicenseUri) } if ($data.Manifest.IconUri) { $ParmsManifest.add('IconUri', $data.Manifest.IconUri) } try { New-ModuleManifest @ParmsManifest -ErrorAction Stop } catch { Write-Error -Message 'Failed to create Manifest' -ErrorAction Stop } } function Copy-ProjectResource { $data = Get-MTProjectInfo $resFolder = [System.IO.Path]::Join($data.ProjectRoot, 'src', 'resources') if (Test-Path $resFolder) { if (Get-ChildItem $resFolder -ErrorAction SilentlyContinue) { Write-Verbose 'Files found in resource folder, Copying resource folder content' Copy-Item -Path $resFolder -Destination ($data.OutputModuleDir) -Recurse -Force -ErrorAction Stop } } } function Get-FunctionNameFromFile { param($filePath) try { $moduleContent = Get-Content -Path $filePath -Raw $ast = [System.Management.Automation.Language.Parser]::ParseInput($moduleContent, [ref]$null, [ref]$null) $functionName = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) | ForEach-Object { $_.Name } return $functionName } catch { return '' } } function Read-AwesomeHost { [CmdletBinding()] param ( [Parameter()] [pscustomobject] $Ask ) ## For standard questions if ($null -eq $Ask.Choice) { do { $response = $Host.UI.Prompt($Ask.Caption, $Ask.Message, $Ask.Prompt) } while ($Ask.Default -eq 'MANDATORY' -and [string]::IsNullOrEmpty($response.Values)) if ([string]::IsNullOrEmpty($response.Values)) { $result = $Ask.Default } else { $result = $response.Values } } ## For Choice based if ($Ask.Choice) { $Cs = @() $Ask.Choice.Keys | ForEach-Object { $Cs += New-Object System.Management.Automation.Host.ChoiceDescription "&$_", $($Ask.Choice.$_) } $options = [System.Management.Automation.Host.ChoiceDescription[]]($Cs) $IndexOfDefault = $Cs.Label.IndexOf('&' + $Ask.Default) $response = $Host.UI.PromptForChoice($Ask.Caption, $Ask.Message, $options, $IndexOfDefault) $result = $Cs.Label[$response] -replace '&' } return $result } function Reset-ProjectDist { [CmdletBinding(SupportsShouldProcess = $true)] param ( ) $ErrorActionPreference = 'Stop' $data = Get-MTProjectInfo try { Write-Verbose 'Running dist folder reset' if (Test-Path $data.OutputDir) { Remove-Item -Path $data.OutputDir -Recurse -Force } # Setup Folders New-Item -Path $data.OutputDir -ItemType Directory -Force | Out-Null # Dist folder New-Item -Path $data.OutputModuleDir -Type Directory -Force | Out-Null # Module Folder } catch { Write-Error 'Failed to reset Dist folder' } } function Test-ProjectSchema { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateSet('Build', 'Pester')] [string] $Schema ) Write-Verbose "Running Schema test against using $Schema schema" $SchemaPath = @{ Build = "$PSScriptRoot\resources\Schema-Build.json" Pester = "$PSScriptRoot\resources\Schema-Pester.json" } $result = switch ($Schema) { 'Build' { Test-Json -Path 'project.json' -Schema (Get-Content $SchemaPath.Build -Raw) -ErrorAction Stop } 'Pester' { Test-Json -Path 'project.json' -Schema (Get-Content $SchemaPath.Pester -Raw) -ErrorAction Stop } Default { $false } } return $result } |