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)
    }
}