diff --git a/.claude/commands/build.md b/.claude/commands/build.md new file mode 100644 index 0000000..1666111 --- /dev/null +++ b/.claude/commands/build.md @@ -0,0 +1,15 @@ +Run the full build pipeline (lint -> test -> build -> docs): + +```powershell +Invoke-Build +``` + +Or run individual tasks: + +```powershell +Invoke-Build Lint # PSScriptAnalyzer only +Invoke-Build Test # Pester tests with JaCoCo coverage +Invoke-Build Build # Stage module to .build/ +Invoke-Build Docs # Generate markdown help via platyPS +Invoke-Build Clean # Remove .build/ +``` diff --git a/.claude/commands/docs.md b/.claude/commands/docs.md new file mode 100644 index 0000000..2ddb35c --- /dev/null +++ b/.claude/commands/docs.md @@ -0,0 +1,19 @@ +Generate markdown documentation from comment-based help: + +```powershell +# Build the module first, then generate docs +Invoke-Build Build, Docs +``` + +Or after an initial build, regenerate docs only: + +```powershell +Invoke-Build Docs +``` + +Docs are written to `docs/` as platyPS-formatted markdown. + +When adding a new public function: +1. Add the function to `src/MyModule/Public/` +2. Add its name to `FunctionsToExport` in `src/MyModule/MyModule.psd1` +3. Run `Invoke-Build Docs` to generate its help page diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000..595f400 --- /dev/null +++ b/.claude/commands/lint.md @@ -0,0 +1,11 @@ +Run PSScriptAnalyzer against the source: + +```powershell +Invoke-Build Lint +``` + +Or directly: + +```powershell +Invoke-ScriptAnalyzer -Path ./src -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse -ReportSummary +``` diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000..f3d1842 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,23 @@ +Run Pester 5 tests with code coverage: + +```powershell +Invoke-Build Test +``` + +Or directly: + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = './tests' +$config.Run.Exit = $true +$config.Output.Verbosity = 'Detailed' +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = './src' +$config.CodeCoverage.UseBreakpoints = $false +$config.CodeCoverage.OutputFormat = 'JaCoCo' +$config.CodeCoverage.OutputPath = '.build/coverage.xml' +Invoke-Pester -Configuration $config +``` + +Coverage report is written to `.build/coverage.xml` (JaCoCo format, compatible with Codecov). +Test results are written to `.build/testresults.xml` (JUnit XML). diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9088e1c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "PowerShell Module", + "image": "mcr.microsoft.com/devcontainers/powershell:7.4", + "postCreateCommand": [ + "pwsh", "-Command", + "Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force" + ], + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.powershell", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "GitHub.copilot", + "GitHub.copilot-chat" + ], + "settings": { + "powershell.scriptAnalysis.enable": true, + "powershell.scriptAnalysis.settingsPath": "${workspaceFolder}/PSScriptAnalyzerSettings.psd1", + "powershell.pester.useLegacyCodeLens": false, + "powershell.pester.outputVerbosity": "Diagnostic", + "[powershell]": { + "editor.defaultFormatter": "ms-vscode.powershell", + "editor.formatOnSave": true, + "editor.rulers": [120] + }, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true + } + } + }, + "remoteUser": "vscode" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9ba3f5d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{yml,yaml}] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1804eea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,125 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + + - name: Lint + shell: pwsh + run: Invoke-Build Lint + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module Pester -Scope CurrentUser -Force -MinimumVersion '5.0' + + - name: Test + shell: pwsh + run: Invoke-Build Test + + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v5 + with: + files: .build/coverage.xml + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: .build/testresults.xml + + security: + name: Security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + + - name: Security scan + shell: pwsh + run: | + $rules = @( + 'PSAvoidUsingInvokeExpression', + 'PSAvoidUsingConvertToSecureStringWithPlainText', + 'PSAvoidUsingPlainTextForPassword', + 'PSAvoidUsingUsernameAndPasswordParams', + 'PSAvoidUsingComputerNameHardcoded', + 'PSAvoidUsingWMICmdlet' + ) + $results = Invoke-ScriptAnalyzer -Path ./src -Recurse -IncludeRule $rules + if ($results) { + $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + exit 1 + } + + build: + name: Build & Docs + runs-on: ubuntu-latest + needs: [lint, test, security] + steps: + - uses: actions/checkout@v4 + + - name: Install tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module platyPS -Scope CurrentUser -Force + + - name: Build + shell: pwsh + run: Invoke-Build Build + + - name: Docs + shell: pwsh + run: Invoke-Build Docs + + - name: Upload module artifact + uses: actions/upload-artifact@v4 + with: + name: module-package + path: .build/MyModule/ + + - name: Upload docs artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..46cfa41 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,129 @@ +name: Publish + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: read + packages: write + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module InvokeBuild -Scope CurrentUser -Force + Install-Module platyPS -Scope CurrentUser -Force + + - name: Build + shell: pwsh + run: Invoke-Build Build + + - name: Set ModuleVersion from tag + shell: pwsh + run: | + $version = '${{ github.ref_name }}' -replace '^v', '' + $manifestPath = '.build/MyModule/MyModule.psd1' + $content = Get-Content -Path $manifestPath -Raw + $content = [regex]::Replace($content, "ModuleVersion\s*=\s*'[^']*'", "ModuleVersion = '$version'") + Set-Content -Path $manifestPath -Value $content.TrimEnd() -Encoding utf8NoBOM + Write-Host "ModuleVersion set to $version" + + - name: Upload module artifact + uses: actions/upload-artifact@v4 + with: + name: module-package-${{ github.ref_name }} + path: .build/MyModule/ + retention-days: 1 + + publish-gallery: + name: Publish to PowerShell Gallery + runs-on: ubuntu-latest + needs: build + environment: release + steps: + - uses: actions/download-artifact@v4 + with: + name: module-package-${{ github.ref_name }} + path: .build/MyModule/ + + - name: Install PSResourceGet + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + + - name: Publish + shell: pwsh + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: Publish-PSResource -Path .build/MyModule -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY + + publish-github: + name: Publish to GitHub Packages + runs-on: ubuntu-latest + needs: build + environment: release + steps: + - uses: actions/download-artifact@v4 + with: + name: module-package-${{ github.ref_name }} + path: .build/MyModule/ + + - name: Install PSResourceGet + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + + - name: Publish + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_OWNER: ${{ github.repository_owner }} + run: | + $uri = "https://nuget.pkg.github.com/$env:GITHUB_OWNER/index.json" + $cred = [pscredential]::new( + $env:GITHUB_OWNER, + (ConvertTo-SecureString $env:GITHUB_TOKEN -AsPlainText -Force) + ) + Register-PSResourceRepository -Name 'GitHubPackages' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path .build/MyModule -Repository 'GitHubPackages' -ApiKey $env:GITHUB_TOKEN + + publish-ado: + name: Publish to Azure DevOps Artifacts + runs-on: ubuntu-latest + needs: build + environment: release + if: vars.ADO_ORG != '' + steps: + - uses: actions/download-artifact@v4 + with: + name: module-package-${{ github.ref_name }} + path: .build/MyModule/ + + - name: Install PSResourceGet + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + + - name: Publish + shell: pwsh + env: + ADO_PAT: ${{ secrets.ADO_PAT }} + ADO_ORG: ${{ vars.ADO_ORG }} + ADO_FEED: ${{ vars.ADO_FEED }} + run: | + $uri = "https://pkgs.dev.azure.com/$env:ADO_ORG/_packaging/$env:ADO_FEED/nuget/v3/index.json" + $cred = [pscredential]::new('PAT', (ConvertTo-SecureString $env:ADO_PAT -AsPlainText -Force)) + Register-PSResourceRepository -Name 'ADOFeed' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path .build/MyModule -Repository 'ADOFeed' -Credential $cred diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7eabe2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Build output +.build/ + +# Compiled module artifacts +*.nupkg + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/*.code-workspace + +# Secrets +.env +*.env.local diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bbe2555 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "ms-vscode.powershell", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "GitHub.copilot", + "GitHub.copilot-chat" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cf14fd5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "PowerShell: Launch Current File", + "type": "PowerShell", + "request": "launch", + "script": "${file}", + "cwd": "${workspaceFolder}" + }, + { + "name": "PowerShell: Run Pester Tests", + "type": "PowerShell", + "request": "launch", + "script": "${workspaceFolder}/Build.ps1", + "args": ["-Task", "Test"], + "cwd": "${workspaceFolder}" + }, + { + "name": "PowerShell: Interactive Session", + "type": "PowerShell", + "request": "launch", + "script": "", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3eb8477 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "powershell.scriptAnalysis.enable": true, + "powershell.scriptAnalysis.settingsPath": "${workspaceFolder}/PSScriptAnalyzerSettings.psd1", + "powershell.pester.useLegacyCodeLens": false, + "powershell.pester.outputVerbosity": "Diagnostic", + "[powershell]": { + "editor.defaultFormatter": "ms-vscode.powershell", + "editor.formatOnSave": true, + "editor.rulers": [120] + }, + "editor.tabSize": 4, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.encoding": "utf8" +} diff --git a/Build.ps1 b/Build.ps1 new file mode 100644 index 0000000..92c8a6f --- /dev/null +++ b/Build.ps1 @@ -0,0 +1,192 @@ +<# +.SYNOPSIS + Invoke-Build task script for MyModule. +.DESCRIPTION + Run tasks with: Invoke-Build [TaskName] + Omit TaskName to run the default task: Lint -> Test -> Build -> Docs + + Tasks: + Clean Remove .build/ output directory + Lint Run PSScriptAnalyzer against src/ + Test Run Pester 5 tests with JaCoCo + JUnit output + Manifest Sync FunctionsToExport in source PSD1 from Public/ (dev tool; + preserves comments; not called automatically by Build) + Build Concatenate source into a monolithic .psm1, update the built + manifest, validate it, and stage to .build/MyModule/ + Docs Generate per-cmdlet markdown via platyPS and compile to MAML + Publish Publish to PowerShell Gallery (PSGALLERY_API_KEY) + PublishGitHub Publish to GitHub Packages (GITHUB_TOKEN, GITHUB_OWNER) + PublishADO Publish to ADO Artifacts (ADO_PAT, ADO_ORG, ADO_FEED) +#> + +param( + [string] $ModuleName = 'MyModule', + [string] $SrcPath = "$PSScriptRoot/src/$ModuleName", + [string] $BuildPath = "$PSScriptRoot/.build", + [string] $OutputPath = "$PSScriptRoot/.build/$ModuleName", + [string] $TestsPath = "$PSScriptRoot/tests", + [string] $DocsPath = "$PSScriptRoot/docs", + [string] $TemplateGuid = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' +) + +# --------------------------------------------------------------------------- +task Clean { + if (Test-Path $BuildPath) { Remove-Item $BuildPath -Recurse -Force } + $null = New-Item $OutputPath -ItemType Directory -Force + Write-Build Green 'Clean complete.' +} + +# --------------------------------------------------------------------------- +task Lint { + $settings = "$PSScriptRoot/PSScriptAnalyzerSettings.psd1" + $results = Invoke-ScriptAnalyzer -Path $SrcPath -Settings $settings -Recurse -ReportSummary + if ($results) { + $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + throw "PSScriptAnalyzer found $($results.Count) issue(s)." + } + Write-Build Green 'Lint passed.' +} + +# --------------------------------------------------------------------------- +task Test { + $null = New-Item $BuildPath -ItemType Directory -Force + $config = New-PesterConfiguration + $config.Run.Path = $TestsPath + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' + + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = $SrcPath + $config.CodeCoverage.UseBreakpoints = $false # Profiler mode: faster on large suites + $config.CodeCoverage.OutputFormat = 'JaCoCo' + $config.CodeCoverage.OutputPath = "$BuildPath/coverage.xml" + $config.CodeCoverage.CoveragePercentTarget = 80 # Fail if coverage drops below 80% + + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'JUnitXml' + $config.TestResult.OutputPath = "$BuildPath/testresults.xml" + + Invoke-Pester -Configuration $config +} + +# --------------------------------------------------------------------------- +# Manifest: developer tool — syncs FunctionsToExport in the SOURCE manifest from +# Public/ contents. Uses regex replacement to preserve comments. Not called by +# Build (Build never touches source files). +task Manifest { + $manifestPath = "$SrcPath/$ModuleName.psd1" + $data = Import-PowerShellDataFile -Path $manifestPath + + if ($data.GUID -eq $TemplateGuid) { + Write-Build Yellow @" +WARNING: $ModuleName.psd1 still uses the template GUID. +Run (New-Guid).Guid in PowerShell and paste the result into the GUID field. + +"@ + } + + $functions = @( + Get-ChildItem -Path "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue + ).BaseName | Sort-Object + + $joined = ($functions | ForEach-Object { "'$_'" }) -join ', ' + $newLine = "FunctionsToExport = @($joined)" + $content = Get-Content -Path $manifestPath -Raw + $content = [regex]::Replace($content, '(?s)FunctionsToExport\s*=\s*@\(.*?\)', $newLine) + Set-Content -Path $manifestPath -Value $content.TrimEnd() -Encoding utf8NoBOM + + Write-Build Green "Source manifest updated: $($functions.Count) function(s) ($($functions -join ', '))." +} + +# --------------------------------------------------------------------------- +task Build Clean, { + # Auto-discover public functions — never reads or writes the source manifest + $functions = @(Get-ChildItem "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue).BaseName | Sort-Object + + # Concatenate Private/ then Public/ into a single .psm1 for distribution + $sb = [System.Text.StringBuilder]::new() + foreach ($file in Get-ChildItem "$SrcPath/Private/*.ps1" -ErrorAction SilentlyContinue) { + $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw).TrimEnd()) + $null = $sb.AppendLine() + } + foreach ($file in Get-ChildItem "$SrcPath/Public/*.ps1" -ErrorAction SilentlyContinue) { + $null = $sb.AppendLine((Get-Content -Path $file.FullName -Raw).TrimEnd()) + $null = $sb.AppendLine() + } + $joined = ($functions | ForEach-Object { "'$_'" }) -join ', ' + $null = $sb.AppendLine("Export-ModuleMember -Function @($joined)") + Set-Content -Path "$OutputPath/$ModuleName.psm1" -Value $sb.ToString() -Encoding utf8NoBOM + + # Copy manifest to build output and patch FunctionsToExport (source untouched) + Copy-Item -Path "$SrcPath/$ModuleName.psd1" -Destination $OutputPath + $builtManifest = "$OutputPath/$ModuleName.psd1" + $manifestContent = Get-Content -Path $builtManifest -Raw + $newExports = "FunctionsToExport = @($joined)" + $manifestContent = [regex]::Replace($manifestContent, '(?s)FunctionsToExport\s*=\s*@\(.*?\)', $newExports) + Set-Content -Path $builtManifest -Value $manifestContent.TrimEnd() -Encoding utf8NoBOM + + # Validate the built manifest before anything tries to publish it + $validated = Test-ModuleManifest -Path $builtManifest + Write-Build Green "Build staged at $OutputPath — $($validated.Name) v$($validated.Version), $($functions.Count) function(s)." +} + +# --------------------------------------------------------------------------- +task Docs { + assert (Test-Path "$OutputPath/$ModuleName.psd1") "Run 'Invoke-Build Build' before Docs." + $null = New-Item $DocsPath -ItemType Directory -Force + + Import-Module -Name "$OutputPath/$ModuleName.psd1" -Force -ErrorAction Stop + + # Generate / update one markdown file per exported cmdlet + $commands = Get-Command -Module $ModuleName + foreach ($cmd in $commands) { + $docFile = "$DocsPath/$($cmd.Name).md" + if (Test-Path $docFile) { + Update-MarkdownHelp -Path $docFile -Force | Out-Null + } else { + New-MarkdownHelp -Command $cmd.Name -OutputFolder $DocsPath -Force | Out-Null + } + } + + # Compile markdown to MAML XML so Get-Help works for the installed module + $helpPath = "$OutputPath/en-US" + $null = New-Item $helpPath -ItemType Directory -Force + New-ExternalHelp -Path $DocsPath -OutputPath $helpPath -Force | Out-Null + + Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue + Write-Build Green "Docs: $($commands.Count) cmdlet(s) -> $DocsPath (markdown) + $helpPath (MAML)." +} + +# --------------------------------------------------------------------------- +task Publish { + assert $env:PSGALLERY_API_KEY 'Set PSGALLERY_API_KEY before publishing to PSGallery.' + Publish-PSResource -Path $OutputPath -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY + Write-Build Green 'Published to PowerShell Gallery.' +} + +task PublishGitHub { + assert $env:GITHUB_TOKEN 'Set GITHUB_TOKEN before publishing to GitHub Packages.' + assert $env:GITHUB_OWNER 'Set GITHUB_OWNER (username or org) before publishing.' + $uri = "https://nuget.pkg.github.com/$env:GITHUB_OWNER/index.json" + $cred = [pscredential]::new( + $env:GITHUB_OWNER, + (ConvertTo-SecureString $env:GITHUB_TOKEN -AsPlainText -Force) + ) + Register-PSResourceRepository -Name 'GitHubPackages' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path $OutputPath -Repository 'GitHubPackages' -ApiKey $env:GITHUB_TOKEN + Write-Build Green 'Published to GitHub Packages.' +} + +task PublishADO { + assert $env:ADO_PAT 'Set ADO_PAT before publishing to ADO Artifacts.' + assert $env:ADO_ORG 'Set ADO_ORG (Azure DevOps organisation) before publishing.' + assert $env:ADO_FEED 'Set ADO_FEED (Artifacts feed name) before publishing.' + $uri = "https://pkgs.dev.azure.com/$env:ADO_ORG/_packaging/$env:ADO_FEED/nuget/v3/index.json" + $cred = [pscredential]::new('PAT', (ConvertTo-SecureString $env:ADO_PAT -AsPlainText -Force)) + Register-PSResourceRepository -Name 'ADOFeed' -Uri $uri -Trusted -Credential $cred -ErrorAction SilentlyContinue + Publish-PSResource -Path $OutputPath -Repository 'ADOFeed' -Credential $cred + Write-Build Green 'Published to Azure DevOps Artifacts.' +} + +# Default task +task . Lint, Test, Build, Docs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20f8081 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-06-07 + +### Added +- Initial module scaffold with `Get-Example` public function +- Pester 5 unit tests with JaCoCo coverage output +- PSScriptAnalyzer configuration with formatting and security rules +- Invoke-Build task script (Lint, Test, Build, Docs, Publish, PublishGitHub, PublishADO) +- platyPS documentation generation +- GitHub Actions CI pipeline (lint, test, security, build) across Ubuntu / Windows / macOS +- GitHub Actions publish pipeline (PSGallery, GitHub Packages, ADO Artifacts) on version tag +- Dev container configuration (PowerShell 7.4) +- VS Code settings, extensions, and launch configurations diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0aaa245 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md — AI Context for PowerShell-Project-Base + +This file gives Claude Code the context needed to work effectively in this repository. + +## Project Overview + +A production-ready base template for PowerShell modules. Clone and replace +`src/MyModule/` with your own code. The dev container provides a fully +working environment with no local PowerShell setup required. + +Toolchain: + +| Concern | Tool | +|---------|------| +| Lint & style | PSScriptAnalyzer (all severities) | +| Tests | Pester 5 | +| Coverage | JaCoCo XML (Pester built-in) | +| Security SAST | PSScriptAnalyzer security rules | +| Build / publish | Invoke-Build | +| Docs | platyPS (from comment-based help) | +| Container | mcr.microsoft.com/devcontainers/powershell:7.4 | + +## Repository Layout + +``` +. +├── src/MyModule/ +│ ├── MyModule.psd1 manifest (GUID, version, FunctionsToExport='*' in src) +│ ├── MyModule.psm1 dev loader: dot-sources Public/ + Private/ +│ ├── Public/ exported cmdlets, one file per function +│ └── Private/ internal helpers, not exported +├── tests/ +│ ├── Unit/Public/ tests that import the full module +│ ├── Unit/Private/ tests that dot-source the helper directly +│ └── Integration/ +├── docs/ +│ ├── about_MyModule.md hand-maintained module overview +│ └── *.md auto-generated per-cmdlet pages (platyPS) +├── .build/ git-ignored build output +├── .devcontainer/devcontainer.json +├── .github/workflows/ +│ ├── ci.yml lint + test (3 OS) + security + build +│ └── publish.yml triggered by v*.*.* tag +├── Build.ps1 Invoke-Build task file +└── PSScriptAnalyzerSettings.psd1 +``` + +## Common Commands + +```powershell +# Install tools (once per environment) +Set-PSRepository PSGallery -InstallationPolicy Trusted +Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force + +# Default task: Lint -> Test -> Build -> Docs +Invoke-Build + +# Individual tasks +Invoke-Build Lint # PSScriptAnalyzer against src/ +Invoke-Build Test # Pester 5 -> .build/coverage.xml + testresults.xml +Invoke-Build Manifest # auto-update FunctionsToExport in src manifest +Invoke-Build Build # monolithic .psm1 -> .build/MyModule/ +Invoke-Build Docs # platyPS markdown -> docs/ +Invoke-Build Clean # remove .build/ + +# Publish (set env vars first — see README.md) +Invoke-Build Publish # -> PowerShell Gallery +Invoke-Build PublishGitHub # -> GitHub Packages (NuGet v3) +Invoke-Build PublishADO # -> ADO Artifacts (NuGet v3) +``` + +## How the Build Works + +- **Source (`src/`)**: `MyModule.psm1` is a dev loader that dot-sources all + `.ps1` files at runtime. `FunctionsToExport = '*'` in the manifest exports + everything — fine for dev/test. +- **Build output (`.build/MyModule/`)**: a monolithic `.psm1` produced by + concatenating Private/ then Public/ files, with an explicit + `Export-ModuleMember` call. The manifest gets an exact `FunctionsToExport` + list auto-discovered from `Public/`. This is what gets published. +- **`Manifest` task** validates the GUID is not the template placeholder and + updates `FunctionsToExport` in the source manifest. + +## Architecture Notes + +- **One file per function**. Do not group multiple functions in one file. +- **`FunctionsToExport` is auto-managed**. Never edit it by hand. +- **Pester 5 `New-PesterConfiguration`** only. `UseBreakpoints = $false` for + faster profiler-based coverage. +- **PSScriptAnalyzer all rules**. `PSScriptAnalyzerSettings.psd1` configures + formatting rules. CI fails on any finding. +- **No CodeQL** — not supported for PowerShell. PSScriptAnalyzer security + rules run in the dedicated `security` CI job. +- **`docs/`**: `about_MyModule.md` is hand-maintained. Per-cmdlet `.md` files + are platyPS-generated — do not edit them directly. + +## Testing Conventions + +- `tests/Unit/Public/` — import the full module; test exported behaviour only +- `tests/Unit/Private/` — dot-source the `.ps1` file; test internal logic +- `BeforeAll` / `AfterAll` for module lifecycle (not per-test) +- `Context` blocks: "When given valid input", "When given invalid input" +- 100% branch coverage on all new public functions + +## When Making Changes + +1. Add function to `Public/` or `Private/` +2. Write tests in `tests/Unit/` +3. `Invoke-Build Lint` — fix all PSScriptAnalyzer findings +4. `Invoke-Build Test` — all tests must pass +5. `Invoke-Build Build, Docs` — verify build; regenerate docs +6. Bump `ModuleVersion` in `MyModule.psd1` +7. Update `CHANGELOG.md` +8. Tag the release: `git tag v1.x.x && git push origin v1.x.x` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81e8097 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sam Hodgkinson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..61fac52 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,29 @@ +@{ + Severity = @('Error', 'Warning', 'Information') + ExcludeRules = @( + # Enforcing column-alignment on ALL assignment statements produces excessive + # noise in practice. Hashtable alignment is handled by the editor formatter. + 'PSAlignAssignmentStatement' + ) + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = $true + Kind = 'space' + IndentationSize = 4 + } + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + CheckOpenBrace = $true + CheckOpenParen = $true + CheckOperator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $true + CheckSeparator = $true + CheckParameter = $false + } + PSAvoidUsingCmdletAliases = @{ + AllowList = @() + } + } +} diff --git a/README.md b/README.md index e69de29..db64048 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,236 @@ +# PowerShell Module Base + +A production-ready PowerShell module template. Clone, rename, and ship. + +| Gate | Tool | +|------|------| +| Lint & style | PSScriptAnalyzer (all severities) | +| Tests | Pester 5 — JaCoCo coverage + JUnit XML | +| Security scan | PSScriptAnalyzer security rules | +| Build | Invoke-Build — monolithic `.psm1` for distribution | +| Docs | platyPS — markdown from comment-based help | +| CI | GitHub Actions (Linux / Windows / macOS) | +| Publish | PSGallery · GitHub Packages · ADO Artifacts | + +## Quick Start + +### Dev Container (Recommended) + +Open in VS Code and select **"Reopen in Container"**. All tools install automatically. + +```powershell +Invoke-Build # lint -> test -> build -> docs +``` + +### Local + +```powershell +Set-PSRepository PSGallery -InstallationPolicy Trusted +Install-Module InvokeBuild, Pester, PSScriptAnalyzer, platyPS, Microsoft.PowerShell.PSResourceGet -Scope CurrentUser -Force +Invoke-Build +``` + +## Rename the Template + +```powershell +# 1. Rename source directory and files +Rename-Item src/MyModule src/YourModule +Rename-Item src/YourModule/MyModule.psd1 YourModule.psd1 +Rename-Item src/YourModule/MyModule.psm1 YourModule.psm1 + +# 2. Generate a new GUID and paste it into YourModule.psd1 +(New-Guid).Guid + +# 3. Update Build.ps1 param: $ModuleName = 'YourModule' +# 4. Update tests: fix the Import-Module path in BeforeAll blocks +``` + +## Repository Layout + +``` +. +├── src/MyModule/ +│ ├── MyModule.psd1 manifest (GUID, version, author, description) +│ ├── MyModule.psm1 dev loader (dot-sources Public/ + Private/) +│ ├── Public/ exported cmdlets — one .ps1 per function +│ └── Private/ internal helpers — not exported +├── tests/ +│ ├── Unit/Public/ import full module, test exported behaviour +│ ├── Unit/Private/ dot-source the file directly, test internals +│ └── Integration/ end-to-end tests +├── docs/ +│ ├── about_MyModule.md module overview (hand-maintained) +│ └── Get-Example.md per-cmdlet help (auto-generated by platyPS) +├── .build/ build output (git-ignored) +│ └── MyModule/ monolithic module ready to publish +├── .devcontainer/ VS Code dev container (PowerShell 7.4) +├── .github/workflows/ +│ ├── ci.yml lint + test + security + build on every PR +│ └── publish.yml publish on v*.*.* tag push +├── .vscode/ +├── .claude/commands/ +├── Build.ps1 +└── PSScriptAnalyzerSettings.psd1 +``` + +## Common Commands + +```powershell +Invoke-Build # default: Lint -> Test -> Build -> Docs +Invoke-Build Lint # PSScriptAnalyzer +Invoke-Build Test # Pester 5 + coverage -> .build/coverage.xml +Invoke-Build Manifest # auto-update FunctionsToExport from Public/ +Invoke-Build Build # monolithic .psm1 -> .build/MyModule/ +Invoke-Build Docs # platyPS markdown -> docs/ +Invoke-Build Clean # remove .build/ +``` + +## How the Build Works + +The source uses a **dev loader** (`MyModule.psm1`) that dot-sources all files in +`Public/` and `Private/` at runtime — convenient for development. + +`Invoke-Build Build` produces a **monolithic** `.psm1` in `.build/MyModule/`: + +1. Concatenates all `Private/*.ps1` files +2. Concatenates all `Public/*.ps1` files +3. Appends `Export-ModuleMember` with an explicit function list +4. Copies the manifest and writes the exact `FunctionsToExport` list + +The built module is what gets published — never the `src/` directory. + +`FunctionsToExport` in `src/MyModule/MyModule.psd1` is intentionally `'*'` +(exports everything during development). The `Manifest` task and the `Build` +task both auto-discover `Public/` and write the precise list into the manifest, +so you never maintain it by hand. + +## Docs + +``` +docs/ +├── about_MyModule.md ← hand-maintained: module overview, keywords, examples +└── Get-Example.md ← auto-generated by platyPS from comment-based help +``` + +Edit comment-based help in the source `.ps1` files, then re-run +`Invoke-Build Docs` to update the markdown. Do **not** hand-edit the +auto-generated cmdlet files — changes will be overwritten. + +## Publishing + +All three publish targets are wired in `Build.ps1` and triggered by +`publish.yml` when you push a version tag. + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +### PowerShell Gallery (Public) + +**Feed URL:** (default, no registration needed) + +```powershell +$env:PSGALLERY_API_KEY = 'your-key' # from powershellgallery.com/account/apikeys +Invoke-Build Build, Publish +``` + +Required GitHub secret: `PSGALLERY_API_KEY` + +--- + +### GitHub Packages (Public or Private) + +**Feed URL:** `https://nuget.pkg.github.com/OWNER/index.json` + +```powershell +$env:GITHUB_TOKEN = 'ghp_...' +$env:GITHUB_OWNER = 'samhodgkinson' +Invoke-Build Build, PublishGitHub +``` + +**Install from GitHub Packages:** + +```powershell +$cred = [pscredential]::new( + 'YOUR_USERNAME', + (ConvertTo-SecureString 'ghp_...' -AsPlainText -Force) +) +Register-PSResourceRepository -Name GitHubPackages ` + -Uri 'https://nuget.pkg.github.com/OWNER/index.json' ` + -Trusted -Credential $cred +Install-PSResource -Name MyModule -Repository GitHubPackages -Credential $cred +``` + +Required GitHub secret: `GITHUB_TOKEN` — auto-provided in Actions, no manual secret needed. + +--- + +### Azure DevOps Artifacts (Private) + +**Organisation-scoped feed URL:** +``` +https://pkgs.dev.azure.com/ORG/_packaging/FEED/nuget/v3/index.json +``` + +**Project-scoped feed URL:** +``` +https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/nuget/v3/index.json +``` + +```powershell +$env:ADO_PAT = 'your-pat' +$env:ADO_ORG = 'your-org' +$env:ADO_FEED = 'your-feed' +Invoke-Build Build, PublishADO +``` + +**Install from ADO Artifacts:** + +```powershell +$cred = [pscredential]::new( + 'PAT', + (ConvertTo-SecureString 'your-pat' -AsPlainText -Force) +) +Register-PSResourceRepository -Name ADOFeed ` + -Uri 'https://pkgs.dev.azure.com/ORG/_packaging/FEED/nuget/v3/index.json' ` + -Trusted -Credential $cred +Install-PSResource -Name MyModule -Repository ADOFeed -Credential $cred +``` + +Required GitHub secrets: `ADO_PAT` +Required GitHub variables: `ADO_ORG`, `ADO_FEED` + +The `publish-ado` job is skipped when `ADO_ORG` is not configured — ADO is opt-in. + +## Architecture Notes + +- **One file per function** in `Public/` and `Private/`. Keeps diffs focused and code review easy. +- **`FunctionsToExport = '*'` in src** — works for development. Build writes the explicit list. +- **Pester 5 `New-PesterConfiguration` only** — no legacy hashtable syntax. + `UseBreakpoints = $false` uses the profiler-based coverage runner (faster). +- **PSScriptAnalyzer runs all rules** — `PSScriptAnalyzerSettings.psd1` configures style. + CI fails on any finding. +- **No CodeQL** — CodeQL does not support PowerShell. PSScriptAnalyzer security rules + run as a dedicated `security` CI job. +- **`docs/` split**: `about_MyModule.md` is hand-maintained; cmdlet `.md` files are + auto-generated by platyPS and should not be edited directly. + +## Testing Conventions + +- `tests/Unit/Public/FunctionName.Tests.ps1` — imports the full module +- `tests/Unit/Private/HelperName.Tests.ps1` — dot-sources the file directly +- `BeforeAll` / `AfterAll` for module import (not `BeforeEach`) +- `Context` blocks: "When given valid input", "When given invalid input" +- 100% branch coverage target on all public functions + +## When Making Changes + +1. Add function to `Public/` or `Private/` +2. Write tests in `tests/Unit/` +3. `Invoke-Build Lint` — fix all findings +4. `Invoke-Build Test` — all tests must pass +5. `Invoke-Build Build, Docs` — verify build and regenerate docs +6. Bump `ModuleVersion` in `MyModule.psd1` +7. Update `CHANGELOG.md` +8. Tag: `git tag v1.x.x && git push origin v1.x.x` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fb818ad --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 0.x | Yes | + +## Reporting a Vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. + +Report vulnerabilities by emailing the maintainer directly or by using +[GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) +for this repository. + +You should receive an acknowledgement within 48 hours and a resolution +timeline within 7 days. diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/about_MyModule.md b/docs/about_MyModule.md new file mode 100644 index 0000000..204e744 --- /dev/null +++ b/docs/about_MyModule.md @@ -0,0 +1,55 @@ +--- +Module Name: MyModule +Module Guid: 49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a +Help Version: 0.1.0 +Locale: en-US +--- + +# about_MyModule + +## SHORT DESCRIPTION + +A PowerShell module template — replace this description with your own. + +## LONG DESCRIPTION + +This module is scaffolded from **PowerShell-Project-Base**, a production-ready +module template. Replace the content under `src/MyModule/` with your own +cmdlets. + +The template enforces a consistent layout: + +- **Public/** — one `.ps1` file per exported cmdlet. The build auto-discovers + these and writes an explicit `FunctionsToExport` list into the distributed + manifest. +- **Private/** — internal helpers that are not exported. Test them by + dot-sourcing the file directly. +- **Monolithic build** — `Invoke-Build Build` concatenates all source files + into a single `.psm1` in `.build/MyModule/` for distribution. + +## GETTING STARTED + +Install the module from the source: + +```powershell +Import-Module ./src/MyModule/MyModule.psd1 -Force +Get-Example -Name 'World' +``` + +Or install from the PowerShell Gallery once published: + +```powershell +Install-PSResource -Name MyModule +``` + +## CMDLETS + +| Cmdlet | Synopsis | +|--------|----------| +| [Get-Example](Get-Example.md) | Returns a greeting message for the specified name. | + +## KEYWORDS + +- Template +- Module +- Scaffold diff --git a/src/MyModule/MyModule.psd1 b/src/MyModule/MyModule.psd1 new file mode 100644 index 0000000..911ca7a --- /dev/null +++ b/src/MyModule/MyModule.psd1 @@ -0,0 +1,31 @@ +@{ + RootModule = 'MyModule.psm1' + ModuleVersion = '0.1.0' + + # TODO: Replace with your own GUID — run (New-Guid).Guid in PowerShell + GUID = '49c1b8e7-8aae-447c-a22d-b0a0a7f3d36a' + + Author = 'Your Name' + CompanyName = 'Your Company' + Copyright = '(c) 2026. All rights reserved.' + Description = 'A PowerShell module — replace this description.' + PowerShellVersion = '7.2' + CompatiblePSEditions = @('Core') # PS 7+ is Core edition only + + # '*' exports everything in development; Invoke-Build Build auto-discovers + # Public/ functions and writes an explicit list to the built manifest under + # .build/MyModule/ without touching this file. + FunctionsToExport = @('*') + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('Template') + LicenseUri = 'https://github.com/samhodgkinson/powershell-project-base/blob/main/LICENSE' + ProjectUri = 'https://github.com/samhodgkinson/powershell-project-base' + ReleaseNotes = 'Initial release' + } + } +} diff --git a/src/MyModule/MyModule.psm1 b/src/MyModule/MyModule.psm1 new file mode 100644 index 0000000..1c783e4 --- /dev/null +++ b/src/MyModule/MyModule.psm1 @@ -0,0 +1,13 @@ +$Public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction SilentlyContinue) +$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction SilentlyContinue) + +foreach ($import in @($Public + $Private)) { + try { + . $import.FullName + } catch { + Write-Error "Failed to import function $($import.FullName): $_" + throw + } +} + +Export-ModuleMember -Function $Public.BaseName diff --git a/src/MyModule/Private/Invoke-ExampleHelper.ps1 b/src/MyModule/Private/Invoke-ExampleHelper.ps1 new file mode 100644 index 0000000..e537df8 --- /dev/null +++ b/src/MyModule/Private/Invoke-ExampleHelper.ps1 @@ -0,0 +1,10 @@ +function Invoke-ExampleHelper { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string] $Name + ) + + "Hello, $Name!" +} diff --git a/src/MyModule/Public/Get-Example.ps1 b/src/MyModule/Public/Get-Example.ps1 new file mode 100644 index 0000000..951d546 --- /dev/null +++ b/src/MyModule/Public/Get-Example.ps1 @@ -0,0 +1,81 @@ +function Get-Example { + <# + .SYNOPSIS + Returns a greeting message for the specified name. + + .DESCRIPTION + Get-Example is the template public function for this module. + It demonstrates the standard function layout used throughout: + + - [CmdletBinding()] for common parameters (-Verbose, -WhatIf, etc.) + - [OutputType()] so callers and platyPS know what to expect + - A Mandatory, pipeline-accepting parameter with validation + - begin{} / process{} / end{} blocks for correct pipeline semantics + - Comment-based help covering all sections + + Replace this function with your own cmdlets. Keep one function per + file in Public/; the build auto-discovers and exports them. + + .PARAMETER Name + The name to include in the greeting. Must not be null or empty. + Accepts value from the pipeline and from a pipeline object's Name + property. + + .EXAMPLE + Get-Example -Name 'World' + + Hello, World! + + Passes a single name directly. + + .EXAMPLE + 'Alice', 'Bob' | Get-Example + + Hello, Alice! + Hello, Bob! + + Pipes multiple strings; each produces one output string. + + .EXAMPLE + [pscustomobject]@{ Name = 'Carol' } | Get-Example + + Hello, Carol! + + Pipes an object whose Name property binds via ValueFromPipelineByPropertyName. + + .INPUTS + System.String + You can pipe a string to the Name parameter. + + .OUTPUTS + System.String + A greeting string of the form "Hello, !". + + .NOTES + Author : Your Name + Version: 0.1.0 + + .LINK + https://github.com/samhodgkinson/powershell-project-base + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [string] $Name + ) + + begin { + # One-time setup: open connections, validate cross-parameter constraints, + # initialise accumulators for aggregation in end{}. + } + + process { + Invoke-ExampleHelper -Name $Name + } + + end { + # Post-pipeline cleanup: close connections, emit aggregated results. + } +} diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/Private/Invoke-ExampleHelper.Tests.ps1 b/tests/Unit/Private/Invoke-ExampleHelper.Tests.ps1 new file mode 100644 index 0000000..df10d77 --- /dev/null +++ b/tests/Unit/Private/Invoke-ExampleHelper.Tests.ps1 @@ -0,0 +1,13 @@ +BeforeAll { + . "$PSScriptRoot/../../../src/MyModule/Private/Invoke-ExampleHelper.ps1" +} + +Describe 'Invoke-ExampleHelper' { + It 'Returns a formatted greeting string' { + Invoke-ExampleHelper -Name 'Bob' | Should -Be 'Hello, Bob!' + } + + It 'Handles names with spaces' { + Invoke-ExampleHelper -Name 'Jane Doe' | Should -Be 'Hello, Jane Doe!' + } +} diff --git a/tests/Unit/Public/Get-Example.Tests.ps1 b/tests/Unit/Public/Get-Example.Tests.ps1 new file mode 100644 index 0000000..c7d7245 --- /dev/null +++ b/tests/Unit/Public/Get-Example.Tests.ps1 @@ -0,0 +1,41 @@ +BeforeAll { + $modulePath = "$PSScriptRoot/../../../src/MyModule/MyModule.psd1" + Import-Module -Name $modulePath -Force +} + +AfterAll { + Remove-Module -Name MyModule -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-Example' { + Context 'When given a valid name' { + It 'Returns a greeting string' { + Get-Example -Name 'World' | Should -Be 'Hello, World!' + } + + It 'Accepts pipeline input by value' { + 'Alice' | Get-Example | Should -Be 'Hello, Alice!' + } + + It 'Processes multiple pipeline values' { + $result = 'Alice', 'Bob' | Get-Example + $result | Should -HaveCount 2 + $result[0] | Should -Be 'Hello, Alice!' + $result[1] | Should -Be 'Hello, Bob!' + } + + It 'Accepts ValueFromPipelineByPropertyName via Name property' { + [pscustomobject]@{ Name = 'Carol' } | Get-Example | Should -Be 'Hello, Carol!' + } + } + + Context 'When given invalid input' { + It 'Throws on empty string' { + { Get-Example -Name '' } | Should -Throw + } + + It 'Throws when Name is not supplied' { + { Get-Example } | Should -Throw + } + } +}