ModuleTools.psm1
|
function Get-MTProjectInfo { [CmdletBinding()] param( [Parameter(Position = 0)] [string]$Path = (Get-Location).Path ) $ProjectRoot = (Resolve-Path -LiteralPath $Path).Path $ProjectJson = [System.IO.Path]::Join($ProjectRoot, 'project.json') if (-not (Test-Path -LiteralPath $projectJson)) { throw "Not a project folder. project.json not found: $projectJson" } $jsonData = Get-Content -LiteralPath $projectJson -Raw | ConvertFrom-Json -AsHashtable $Out = @{} $Out['ProjectJSON'] = $ProjectJson foreach ($key in $jsonData.Keys) { $Out[$key] = $jsonData[$key] } $Out.ProjectJson = $projectJson $Out.PSTypeName = 'MTProjectInfo' $ProjectName = $jsonData.ProjectName ## Folders $Out['ProjectRoot'] = $ProjectRoot $Out['PublicDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'public') $Out['PrivateDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'private') $Out['ClassesDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'classes') $Out['ResourcesDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'resources') $Out['DocsDir'] = [System.IO.Path]::Join($ProjectRoot, 'docs') $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 [pscustomobject]$out } function Invoke-MTBuild { [CmdletBinding()] param ( ) $ErrorActionPreference = 'Stop' Reset-ProjectDist Build-Module Build-Manifest Build-Help Copy-ProjectResource } function Invoke-MTTest { [CmdletBinding()] param ( [string[]]$TagFilter, [string[]]$ExcludeTagFilter ) 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.Filter.Tag = $TagFilter $pesterConfig.Filter.ExcludeTag = $ExcludeTagFilter $pesterConfig.TestResult.OutputPath = './dist/TestResults.xml' $TestResult = Invoke-Pester -Configuration $pesterConfig if ($TestResult.Result -ne 'Passed') { Write-Error 'Tests failed' -ErrorAction Stop return $LASTEXITCODE } } function New-MTModule { [CmdletBinding(SupportsShouldProcess = $true)] param ( [string]$Path = (Get-Location).Path ) $ErrorActionPreference = 'Stop' Push-Location 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' $DirClasses = Join-Path -Path $DirSrc -ChildPath 'classes' $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 Write-Message "`nStarted Module Scaffolding" -color Green Write-Message 'Setting up Directories' ($DirProject, $DirSrc, $DirPrivate, $DirPublic, $DirResources, $DirClasses) | ForEach-Object { 'Creating Directory: {0}' -f $_ | Write-Verbose New-Item -ItemType Directory -Path $_ | Out-Null } if ( $Answer.EnablePester -eq 'Yes') { Write-Message 'Include Pester Configs' New-Item -ItemType Directory -Path $DirTests | Out-Null } if ( $Answer.EnableGit -eq 'Yes') { Write-Message 'Initialize Git Repo' New-InitiateGitRepo -DirectoryPath $DirProject } ## 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-Message -color Green } function Publish-MTLocal { [CmdletBinding()] param( [string]$ModuleDirectoryPath ) if ($ModuleDirectoryPath) { if (-not (Test-Path $ModuleDirectoryPath -PathType Container)) { New-Item $ModuleDirectoryPath -ItemType Directory -Force | Out-Null } } else { $ModuleDirectoryPath = Get-LocalModulePath } Write-Verbose "Using $ModuleDirectoryPath as path" $ProjectInfo = Get-MTProjectInfo # Ensure module is locally built and ready if (-not (Test-Path $ProjectInfo.OutputModuleDir)) { throw 'Dist folder is empty, build the module before running publish command' } # Cleanup old files $OldModule = Join-Path -Path $ModuleDirectoryPath -ChildPath $ProjectInfo.ProjectName if (Test-Path -Path $OldModule) { Write-Verbose 'Removing old module files' Remove-Item -Recurse $OldModule -Force } # Copy New Files Write-Verbose 'Copying new Files' Copy-Item -Path $ProjectInfo.OutputModuleDir -Destination $ModuleDirectoryPath -Recurse -ErrorAction Stop Write-Verbose 'Module copy to local path complete, Refresh session or import module manually' } function Update-MTModuleVersion { [CmdletBinding(SupportsShouldProcess = $true)] param( [ValidateSet('Major', 'Minor', 'Patch')] [string]$Label = 'Patch', [switch]$PreviewRelease, [switch]$StableRelease ) Write-Verbose 'Running Version Update' $data = Get-MTProjectInfo $jsonContent = Get-Content -Path $data.ProjectJSON | ConvertFrom-Json [semver]$CurrentVersion = $jsonContent.Version $Major = $CurrentVersion.Major $Minor = $CurrentVersion.Minor if ($Label -eq 'Major') { $Major = $CurrentVersion.Major + 1 $Minor = 0 $Patch = 0 } elseif ($Label -eq 'Minor') { $Minor = $CurrentVersion.Minor + 1 $Patch = 0 } elseif ($Label -eq 'Patch') { $Patch = $CurrentVersion.Patch + 1 } if ($PreviewRelease) { $ReleaseType = 'preview' } elseif ($StableRelease) { $ReleaseType = $null } else { $ReleaseType = $CurrentVersion.PreReleaseLabel } $newVersion = [semver]::new($Major, $Minor, $Patch, $ReleaseType, $null) # Update the version in the JSON object $jsonContent.Version = $newVersion.ToString() Write-Host "Version bumped to : $newVersion" # Convert the JSON object back to JSON format $newJsonContent = $jsonContent | ConvertTo-Json # Write the updated JSON back to the file $newJsonContent | Set-Content -Path $data.ProjectJSON } function Build-Help { [CmdletBinding()] param( ) Write-Verbose 'Running Help update' $data = Get-MTProjectInfo $helpMarkdownFiles = Get-ChildItem -Path $data.DocsDir -Filter '*.md' -Recurse if (-not $helpMarkdownFiles) { Write-Verbose 'No help markdown files in docs directory, skipping building help' return } if (-not (Get-Module -Name Microsoft.PowerShell.PlatyPS -ListAvailable)) { throw 'The module Microsoft.PowerShell.PlatyPS must be installed for Markdown documentation to be generated.' } $AllCommandHelpFiles = $helpMarkdownFiles | Measure-PlatyPSMarkdown | Where-Object FileType -Match CommandHelp # Export to Dist folder $AllCommandHelpFiles | Import-MarkdownCommandHelp -Path { $_.FilePath } | Export-MamlCommandHelp -OutputFolder $data.OutputModuleDir | Out-Null # Rename the directory to match locale $HelpDirOld = Join-Path $data.OutputModuleDir $Data.ProjectName #TODO: hardcoded locale to en-US, change it based on Doc type $languageLocale = 'en-US' $HelpDirNew = Join-Path $data.OutputModuleDir $languageLocale Write-Verbose "Renamed folder to locale: $languageLocale" Rename-Item -Path $HelpDirOld -NewName $HelpDirNew } 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 = @() $aliasToExport = @() $PubFunctionFiles.FullName | ForEach-Object { $functionToExport += Get-FunctionNameFromFile -filePath $_ $aliasToExport += Get-AliasInFunctionFromFile -filePath $_ } ## Import Format.ps1xml (if any) $FormatsToProcess = @() Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Format.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object { if ($data.copyResourcesToModuleRoot) { $FormatsToProcess += $_.Name } else { $FormatsToProcess += Join-Path -Path 'resources' -ChildPath $_.Name } } ## Import Types.ps1xml1 (if any) $TypesToProcess = @() Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Types.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object { if ($data.copyResourcesToModuleRoot) { $TypesToProcess += $_.Name } else { $TypesToProcess += Join-Path -Path 'resources' -ChildPath $_.Name } } $ManfiestAllowedParams = (Get-Command New-ModuleManifest).Parameters.Keys $sv = [semver]$data.Version $ParmsManifest = @{ Path = $data.ManifestFilePSD1 Description = $data.Description FunctionsToExport = $functionToExport AliasesToExport = $aliasToExport RootModule = "$($data.ProjectName).psm1" ModuleVersion = [version]$sv FormatsToProcess = $FormatsToProcess TypesToProcess = $TypesToProcess } ## Release lable if ($sv.PreReleaseLabel) { $ParmsManifest['Prerelease'] = $sv.PreReleaseLabel } # Accept only valid Manifest Parameters $data.Manifest.Keys | ForEach-Object { if ( $ManfiestAllowedParams -contains $_) { if ($data.Manifest.$_) { $ParmsManifest.add($_, $data.Manifest.$_ ) } } else { Write-Warning "Unknown parameter $_ in Manifest" } } try { New-ModuleManifest @ParmsManifest -ErrorAction Stop } catch { 'Failed to create Manifest: {0}' -f $_.Exception.Message | Write-Error -ErrorAction Stop } } function Build-Module { $data = Get-MTProjectInfo $MTBuildVersion = (Get-Command Invoke-MTBuild).Version Write-Verbose "Running ModuleTols Version: $MTBuildVersion" Write-Verbose 'Buidling module psm1 file' Test-ProjectSchema -Schema Build | Out-Null $sb = [System.Text.StringBuilder]::new() # Classes Folder $files = Get-ChildItem -Path $data.ClassesDir -Filter *.ps1 -ErrorAction SilentlyContinue $files | ForEach-Object { $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null } # 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 Copy-ProjectResource { $data = Get-MTProjectInfo $resFolder = [System.IO.Path]::Join($data.ProjectRoot, 'src', 'resources') if (Test-Path $resFolder) { ## Copy to root folder instead of creating Resource Folder in module root if ($data.copyResourcesToModuleRoot) { # Copy the resources folder content to the OutputModuleDir $items = Get-ChildItem -Path $resFolder -ErrorAction SilentlyContinue if ($items) { Write-Verbose 'Files found in resource folder, copying resource folder content' foreach ($item in $items) { Copy-Item -Path $item.FullName -Destination ($data.OutputModuleDir) -Recurse -Force -ErrorAction Stop } } } else { # Copy the resources folder to the OutputModuleDir if (Get-ChildItem $resFolder -ErrorAction SilentlyContinue) { Write-Verbose 'Files found in resource folder, Copying resource folder' Copy-Item -Path $resFolder -Destination ($data.OutputModuleDir) -Recurse -Force -ErrorAction Stop } } } } <# .SYNOPSIS Retrieves information about alias in a given function/file so it can be added to module manifest .DESCRIPTION Adding alias to module manifest and exporting it will ensure that functions can be called using alias without importing explicitly #> function Get-AliasInFunctionFromFile { param($filePath) try { $ast = [System.Management.Automation.Language.Parser]::ParseFile($filePath, [ref]$null, [ref]$null) $functionNodes = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) $function = $functionNodes[0] $paramsAttributes = $function.Body.ParamBlock.Attributes $aliases = ($paramsAttributes | Where-Object { $_.TypeName -like 'Alias' } | ForEach-Object PositionalArguments).Value $aliases } catch { return } } 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 Get-LocalModulePath { $sep = [IO.Path]::PathSeparator $ModulePaths = $env:PSModulePath -split $sep | ForEach-Object { $_.Trim() } | Select-Object -Unique if ($IsWindows) { $MatchPattern = '\\Documents\\PowerShell\\Modules' $Result = $ModulePaths | Where-Object { $_ -match $MatchPattern } | Select-Object -First 1 if ($Result -and (Test-Path $Result)) { return $Result } else { throw "No windows module path matching $MatchPattern found" } } else { # For Mac and Linux $MatchPattern = '/\.local/share/powershell/Modules$' $Result = $ModulePaths | Where-Object { $_ -match $MatchPattern } | Select-Object -First 1 if ($Result -and (Test-Path $Result)) { return $Result } else { throw "No macOS/Linux module path matching $MatchPattern found in PSModulePath." } } } function New-InitiateGitRepo { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)] [string]$DirectoryPath ) # Check if Git is installed if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Warning 'Git is not installed. Please install Git and initialize repo manually' return } Push-Location -StackName 'GitInit' # Navigate to the specified directory Set-Location $DirectoryPath # Check if a Git repository already exists if (Test-Path -Path '.git') { Write-Warning 'A Git repository already exists in this directory.' return } if ($PSCmdlet.ShouldProcess($DirectoryPath, ("Initiating git on $DirectoryPath"))) { try { git init | Out-Null } catch { Write-Error 'Failed to initialize Git repo' } } Write-Verbose 'Git repository initialized successfully' Pop-Location -StackName 'GitInit' } 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 } function Write-Message { [CmdletBinding()] param ( [Parameter(ValueFromPipeline)] [string] $Text, [ValidateSet('Yello', 'Blue', 'Green')] [string] $color = 'Blue' ) PROCESS { Write-Host $Text -ForegroundColor $color } } |