Public/New-ModuleCustomTemplate.ps1
|
<# .SYNOPSIS Generates the scaffolding for new tcs-style PowerShell modules. .DESCRIPTION For each name, creates <Path>/<Name> containing: - Classes, Public, Private, Public/Tests, Public/Templates and Private/Tests folders, each with a README.md. - <Name>.psm1 that dot-sources the .ps1 files in Private and Public and all their subfolders (such as Public/Generated/Functions and Private/Generated from New-FunctionsFromSwagger; Pester tests are skipped), exports only the Public ones and runs the tcs.core configuration, telemetry and daily update check without writing anything to the pipeline. - <Name>.psd1 manifest (version 0.1.0, PowerShell 5.1+, Desktop and Core) that requires tcs.core 0.3.0 or later plus any RequiredModules given. FunctionsToExport is an empty list: add each public function to it as you create it (wildcards are not used). - Config.ps1, an optional loader for module variables stored in the user's settings folder. It never writes into the module folder. Use -UncommentConfig to enable it in the psm1. - README.md and .gitignore. With -IncludeRepoFiles it also creates, under -RepoPath: tests/Module.Tests.ps1 (manifest, import, export, BOM, help and PSScriptAnalyzer checks), PSScriptAnalyzerSettings.psd1 (the tcs rule set) and .github/workflows/ci-validate.yml (calls the ntatschner/tcs-shared-workflows validation workflow and runs Pester on PowerShell 7 and Windows PowerShell 5.1). These files describe one module per repository. Existing files are left alone unless -Force is used. .PARAMETER Names The names of the modules you want to create. Accepts pipeline input. .PARAMETER Path The folder to create the modules in. Defaults to the current location. .PARAMETER Author Name of the module author, used in the Author and Copyright fields of the manifest. Defaults to the current user name. .PARAMETER CompanyName Value for the CompanyName field of the manifest. Defaults to an empty string. .PARAMETER Description Description of the module, used in the manifest and at the top of the module README.md. .PARAMETER RequiredModules Names of additional modules this module depends on. tcs.core (0.3.0 or later) is always added because the generated psm1 uses it. .PARAMETER UncommentConfig Enables the line in the psm1 that dot-sources Config.ps1, which loads module variables from Config.psd1 in the user's settings folder for the module. .PARAMETER IncludeRepoFiles Also create the repository files (tests/Module.Tests.ps1, PSScriptAnalyzerSettings.psd1 and .github/workflows/ci-validate.yml) under -RepoPath. .PARAMETER RepoPath The repository root for -IncludeRepoFiles. Defaults to -Path. For the usual layout (<repo>/modules/<Name>) use -Path <repo>/modules -RepoPath <repo>. .PARAMETER Force Overwrite files that already exist in the module folder (and, with -IncludeRepoFiles, the repository files). .EXAMPLE $Params = @{ Names = 'Module1', 'Module2' Path = './modules' Author = 'Me Myself & I' CompanyName = 'My Awesome Company' Description = 'This module will rock your world!' RequiredModules = 'Microsoft.Graph.Authentication' } New-ModuleCustomTemplate @Params Creates scaffolding for two modules under ./modules with the given metadata. .EXAMPLE New-ModuleCustomTemplate -Names 'my.module' -Path ./modules -RepoPath . -IncludeRepoFiles Creates ./modules/my.module plus ./tests/Module.Tests.ps1, ./PSScriptAnalyzerSettings.psd1 and ./.github/workflows/ci-validate.yml for it. .INPUTS System.String[]. Module names (Names) can be passed by pipeline. .OUTPUTS None. Creates folders and files for each module. .NOTES Author: TheCodeSaiyan #> function New-ModuleCustomTemplate { [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([void])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateNotNullOrEmpty()] [string[]]$Names, [ValidateNotNullOrEmpty()] [string]$Path = $PWD.Path, [string]$Author = $(if ($env:USER) { $env:USER } else { $env:USERNAME }), [string]$CompanyName = '', [string]$Description = 'Module Description', [string[]]$RequiredModules = @(), [switch]$UncommentConfig, [switch]$IncludeRepoFiles, [string]$RepoPath, [switch]$Force ) begin { $TelemetryArgs = @{ ModuleName = $MyInvocation.MyCommand.Module.Name ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version CommandName = $MyInvocation.MyCommand.Name ExecutionID = [guid]::NewGuid().ToString() } Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer $Templates = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'Templates' $ModuleTemplate = Join-Path -Path $Templates -ChildPath 'Module.psm1' $ConfigTemplate = Join-Path -Path $Templates -ChildPath 'Config.ps1' $GitIgnoreTemplate = Join-Path -Path $Templates -ChildPath 'GitIgnore' $ManifestTemplate = Join-Path -Path $Templates -ChildPath 'Module.psd1' $RepoTemplates = Join-Path -Path $Templates -ChildPath 'Repo' if (-not $RepoPath) { $RepoPath = $Path } # tcs.core is always required; add any other modules after it $TcsCoreRequirement = "@{ ModuleName = 'tcs.core'; ModuleVersion = '0.3.0' }" $RequiredModuleLines = @($TcsCoreRequirement) foreach ($RequiredModule in @($RequiredModules | Where-Object { $_ -and $_ -ne 'tcs.core' })) { $RequiredModuleLines += "'" + $RequiredModule.Replace("'", "''") + "'" } $RequiredModulesText = $RequiredModuleLines -join ",`n " $Failures = 0 } process { foreach ($Name in $Names) { $ParentPath = Join-Path -Path $Path -ChildPath $Name if (-not $PSCmdlet.ShouldProcess($ParentPath, 'Create module scaffolding')) { continue } try { $Directories = @( 'Classes' 'Public' 'Private' (Join-Path -Path 'Public' -ChildPath 'Tests') (Join-Path -Path 'Public' -ChildPath 'Templates') (Join-Path -Path 'Private' -ChildPath 'Tests') ) if (-not (Test-Path -Path $ParentPath)) { Write-Verbose "Creating module path: $ParentPath." $null = New-Item -Path $ParentPath -ItemType Directory -ErrorAction Stop } foreach ($Directory in $Directories) { $FullPath = Join-Path -Path $ParentPath -ChildPath $Directory if (-not (Test-Path -Path $FullPath)) { Write-Verbose "Creating path: $FullPath." $null = New-Item -Path $FullPath -ItemType Directory -ErrorAction Stop } $ReadmePath = Join-Path -Path $FullPath -ChildPath 'README.md' if ($Force -or -not (Test-Path -Path $ReadmePath)) { Set-Content -Path $ReadmePath -Value "# $Name $($Directory -replace '\\', '/')" -Encoding UTF8 -ErrorAction Stop } } $Files = @{ 'README.md' = "# $Name PowerShell Module`n`n*$Description*`n" 'Config.ps1' = Get-Content -Path $ConfigTemplate -Raw -ErrorAction Stop '.gitignore' = Get-Content -Path $GitIgnoreTemplate -Raw -ErrorAction Stop } $ModuleContent = (Get-Content -Path $ModuleTemplate -Raw -ErrorAction Stop).Replace('###TEMPLATE_MODULE_NAME', $Name) if ($UncommentConfig) { $ModuleContent = $ModuleContent -replace '(?m)^#\.\s', '. ' } $Files["$Name.psm1"] = $ModuleContent foreach ($FileName in $Files.Keys) { $FilePath = Join-Path -Path $ParentPath -ChildPath $FileName if ((Test-Path -Path $FilePath) -and -not $Force) { Write-Warning "'$FilePath' already exists and was not changed. Use -Force to overwrite it." continue } Set-Content -Path $FilePath -Value $Files[$FileName] -NoNewline -Encoding UTF8 -Force -ErrorAction Stop Write-Verbose "Created $FilePath." } $ManifestPath = Join-Path -Path $ParentPath -ChildPath "$Name.psd1" if ((Test-Path -Path $ManifestPath) -and -not $Force) { Write-Warning "'$ManifestPath' already exists and was not changed. Use -Force to overwrite it." } else { $ManifestValues = [ordered]@{ '###TEMPLATE_MODULE_NAME' = $Name '###TEMPLATE_GUID' = [guid]::NewGuid().ToString() '###TEMPLATE_AUTHOR' = $Author '###TEMPLATE_COMPANY_NAME' = $CompanyName '###TEMPLATE_COPYRIGHT' = "(c) $(Get-Date -UFormat %Y) $Author. All rights reserved." '###TEMPLATE_DESCRIPTION' = $Description } $ManifestContent = Get-Content -Path $ManifestTemplate -Raw -ErrorAction Stop foreach ($Token in $ManifestValues.Keys) { # Values are placed inside single-quoted strings, so escape single quotes $ManifestContent = $ManifestContent.Replace($Token, ([string]$ManifestValues[$Token]).Replace("'", "''")) } $ManifestContent = $ManifestContent.Replace($TcsCoreRequirement, $RequiredModulesText) Set-Content -Path $ManifestPath -Value $ManifestContent -NoNewline -Encoding UTF8 -Force -ErrorAction Stop Write-Verbose "Created module manifest $ManifestPath." } if ($IncludeRepoFiles) { # Module folder relative to the repository root, with / separators (used by CI and the tests) $RepoFull = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($RepoPath).TrimEnd('\', '/') $ModuleFull = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($ParentPath) if ($ModuleFull.StartsWith($RepoFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { $ModuleRelativePath = $ModuleFull.Substring($RepoFull.Length + 1) -replace '\\', '/' } else { $ModuleRelativePath = "modules/$Name" Write-Warning "'$ParentPath' is not under the repository path '$RepoPath'; the repository files use '$ModuleRelativePath'." } $RepoFiles = [ordered]@{ 'tests/Module.Tests.ps1' = 'Module.Tests.template' 'PSScriptAnalyzerSettings.psd1' = 'PSScriptAnalyzerSettings.template' '.github/workflows/ci-validate.yml' = 'ci-validate.yml' } foreach ($RelativeFile in $RepoFiles.Keys) { $FilePath = Join-Path -Path $RepoPath -ChildPath $RelativeFile if ((Test-Path -Path $FilePath) -and -not $Force) { Write-Warning "'$FilePath' already exists and was not changed. Use -Force to overwrite it." continue } $FileFolder = Split-Path -Path $FilePath -Parent if (-not (Test-Path -Path $FileFolder)) { $null = New-Item -Path $FileFolder -ItemType Directory -Force -ErrorAction Stop } $Content = Get-Content -Path (Join-Path -Path $RepoTemplates -ChildPath $RepoFiles[$RelativeFile]) -Raw -ErrorAction Stop $Content = $Content.Replace('###TEMPLATE_MODULE_NAME', $Name.Replace("'", "''")) $Content = $Content.Replace('###TEMPLATE_MODULE_PATH', $ModuleRelativePath.Replace("'", "''")) Set-Content -Path $FilePath -Value $Content -NoNewline -Encoding UTF8 -Force -ErrorAction Stop Write-Verbose "Created $FilePath." } } } catch { $Failures++ Write-Error "Failed to create the module scaffolding for '$Name' at '$ParentPath': $($_.Exception.Message)" } } } end { Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed ($Failures -gt 0) } } |