From 2d1ad0868da3a4c42a21beed76e737d13ab68c68 Mon Sep 17 00:00:00 2001 From: Stephen Mills Date: Sat, 18 Apr 2026 18:41:18 +1000 Subject: [PATCH 1/5] feat!: manifest-driven template engine with custom template support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded parameter, prompt, and validation logic with a manifest-driven system. Each template directory now contains a template.psd1 that declares its parameters, conditions, sections, and layers. The engine processes any conforming manifest without special-casing. The Module template's configuration is decoupled from Anvil's source code. Parameter definitions, defaults, validation rules, file conditions, and CI layer declarations now live in template.psd1 rather than being spread across New-AnvilModule, Invoke-InteractivePrompt, and Assert-ValidConfiguration. New-AnvilModule gains a -Template parameter for scaffolding from custom templates. Users can point it at any directory containing a template.psd1 and the engine will prompt for parameters, validate input, apply conditions, and produce the output — no Anvil source changes needed. The built-in Module template is now an instance of the same system. Template engine changes: - Invoke-TemplateEngine supports IncludeWhen/ExcludeWhen file conditions and conditional content sections via <%#section%> markers - Module template configuration moved to template.psd1 manifest - Token replacement renamed from ModuleName to Name throughout templates - File/license/docs exclusion handled by manifest conditions instead of post-processing deletes - CI provider layering driven by manifest Layers declaration - Docs task conditionally included via template sections - Get-AnvilTemplate reads manifest metadata and discovers layers from manifest declarations rather than scanning for a CI directory - .gitattributes added to template for consistent line endings - Git init no longer stages and commits automatically New private functions: - Test-ManifestCondition, Test-FileCondition (condition evaluation) - Resolve-TemplateSections (conditional content blocks) - Format-TokenValue, Resolve-AutoToken (token formatting pipeline) - Invoke-ManifestPrompt, Assert-ManifestConfiguration (manifest-driven prompt and validation, replacing Invoke-InteractivePrompt and Assert-ValidConfiguration) - Read-TemplateManifest, Assert-TemplateManifest (manifest loading and schema validation) - Convert-PromptResult, Resolve-DefaultFrom, Read-PromptUri (helpers) New public functions: - Invoke-AnvilBuild (wrapper around InvokeBuild, resolves project root) Removed: - Invoke-InteractivePrompt, Assert-ValidConfiguration, Copy-CITemplates - getting-started/ directory no longer ships with scaffolded projects Documentation: - Consolidated docs and readme to flow better - Rewrote scaffolded project README - Added template-authoring.md guide - Updated all user-facing references to use Invoke-AnvilBuild - Added CompatiblePSEditions to module manifest template --- .gitattributes | 1 + CONTRIBUTING.md | 37 +- README.md | 105 +++--- docs/build-pipeline.md | 168 --------- docs/cicd-integration.md | 108 ------ docs/commands/Invoke-AnvilBuild.md | 136 +++++++ docs/commands/New-AnvilModule.md | 22 +- docs/customization.md | 137 ------- docs/development.md | 178 --------- docs/faq.md | 45 +-- docs/getting-started.md | 75 +--- docs/index.md | 48 +-- docs/project-structure.md | 171 --------- docs/reference.md | 254 +++++++++++++ docs/template-authoring.md | 357 ++++++++++++++++++ .../Private/Assert-ManifestConfiguration.ps1 | 96 +++++ src/Anvil/Private/Assert-TemplateManifest.ps1 | 203 ++++++++++ .../Private/Assert-ValidConfiguration.ps1 | 91 ----- src/Anvil/Private/Convert-PromptResult.ps1 | 59 +++ src/Anvil/Private/Copy-CITemplates.ps1 | 50 --- src/Anvil/Private/Format-TokenValue.ps1 | 70 ++++ .../Private/Invoke-InteractivePrompt.ps1 | 212 ----------- src/Anvil/Private/Invoke-ManifestPrompt.ps1 | 127 +++++++ src/Anvil/Private/Invoke-TemplateEngine.ps1 | 34 +- src/Anvil/Private/Read-PromptUri.ps1 | 41 ++ src/Anvil/Private/Read-TemplateManifest.ps1 | 38 ++ src/Anvil/Private/Resolve-AutoToken.ps1 | 43 +++ src/Anvil/Private/Resolve-DefaultFrom.ps1 | 33 ++ .../Private/Resolve-TemplateSections.ps1 | 77 ++++ src/Anvil/Private/Test-FileCondition.ps1 | 72 ++++ src/Anvil/Private/Test-ManifestCondition.ps1 | 50 +++ src/Anvil/Public/Get-AnvilTemplate.ps1 | 95 +++-- src/Anvil/Public/Invoke-AnvilBuild.ps1 | 87 +++++ src/Anvil/Public/New-AnvilModule.ps1 | 184 ++++----- .../azure-pipelines-release.yml.tmpl | 2 +- .../AzurePipelines/azure-pipelines.yml.tmpl | 2 +- .../CI/GitHub/.github/workflows/ci.yml.tmpl | 2 +- .../GitHub/.github/workflows/release.yml.tmpl | 2 +- .../Templates/CI/GitLab/.gitlab-ci.yml.tmpl | 2 +- src/Anvil/Templates/Module/.gitattributes | 1 + .../Templates/Module/CONTRIBUTING.md.tmpl | 41 +- src/Anvil/Templates/Module/LICENSE.tmpl | 193 ++++++++++ src/Anvil/Templates/Module/README.md.tmpl | 169 +++++++-- .../Module/build/build.settings.psd1.tmpl | 4 +- ...ld.settings_DEFAULTS_DO_NOT_EDIT.psd1.tmpl | 2 +- .../Module/build/module.build.ps1.tmpl | 21 +- .../Templates/Module/docs/README.md.tmpl | 4 +- .../Module/getting-started/build-pipeline.md | 168 --------- .../getting-started/cicd-integration.md | 108 ------ .../Module/getting-started/customization.md | 137 ------- .../Module/getting-started/development.md | 178 --------- .../Templates/Module/getting-started/faq.md | 53 --- .../Module/getting-started/getting-started.md | 96 ----- .../getting-started/project-structure.md | 171 --------- .../{__ModuleName__ => __Name__}/Imports.ps1 | 0 .../Private/Format-GreetingText.ps1.tmpl | 0 .../Private/README.md | 0 .../PrivateClasses/GreetingBuilder.ps1 | 0 .../PrivateClasses/README.md | 0 .../Public/Get-Greeting.ps1.tmpl | 0 .../Public/README.md | 0 .../__Name__.psd1.tmpl} | 5 +- .../__Name__.psm1.tmpl} | 2 +- src/Anvil/Templates/Module/template.psd1 | 122 ++++++ .../integration/BuildArtifacts.Tests.ps1.tmpl | 4 +- .../Module/tests/integration/README.md | 54 +-- .../Format-GreetingText.Tests.ps1.tmpl | 8 +- .../Module/tests/unit/Private/README.md | 90 +---- .../GreetingBuilder.Tests.ps1.tmpl | 16 +- .../tests/unit/PrivateClasses/README.md | 51 +-- .../unit/Public/Get-Greeting.Tests.ps1.tmpl | 4 +- .../Module/tests/unit/Public/README.md | 91 +---- .../Templates/Module/tests/unit/README.md | 107 +----- ...s1.tmpl => __Name__.Module.Tests.ps1.tmpl} | 14 +- tests/integration/GoldenTemplate.Tests.ps1 | 217 ++++++++++- tests/unit/Anvil.Module.Tests.ps1 | 15 +- .../Assert-ManifestConfiguration.Tests.ps1 | 178 +++++++++ .../Private/Assert-TemplateManifest.Tests.ps1 | 293 ++++++++++++++ .../Assert-ValidConfiguration.Tests.ps1 | 110 ------ .../Private/Convert-PromptResult.Tests.ps1 | 119 ++++++ tests/unit/Private/Copy-CITemplates.Tests.ps1 | 46 --- .../unit/Private/Format-TokenValue.Tests.ps1 | 117 ++++++ .../Invoke-InteractivePrompt.Tests.ps1 | 218 ----------- .../Private/Invoke-ManifestPrompt.Tests.ps1 | 198 ++++++++++ .../Private/Invoke-TemplateEngine.Tests.ps1 | 169 +++++++++ tests/unit/Private/Read-PromptUri.Tests.ps1 | 80 ++++ .../Private/Read-TemplateManifest.Tests.ps1 | 114 ++++++ .../unit/Private/Resolve-AutoToken.Tests.ps1 | 76 ++++ .../Private/Resolve-DefaultFrom.Tests.ps1 | 65 ++++ .../unit/Private/Resolve-PathTokens.Tests.ps1 | 12 +- .../Resolve-TemplateSections.Tests.ps1 | 248 ++++++++++++ .../unit/Private/Test-FileCondition.Tests.ps1 | 135 +++++++ .../Private/Test-ManifestCondition.Tests.ps1 | 118 ++++++ tests/unit/Public/Get-AnvilTemplate.Tests.ps1 | 76 +++- tests/unit/Public/New-AnvilModule.Tests.ps1 | 50 +++ tests/unit/TemplateManifestParity.Tests.ps1 | 92 +++++ 96 files changed, 4957 insertions(+), 3217 deletions(-) create mode 100644 .gitattributes delete mode 100644 docs/build-pipeline.md delete mode 100644 docs/cicd-integration.md create mode 100644 docs/commands/Invoke-AnvilBuild.md delete mode 100644 docs/customization.md delete mode 100644 docs/development.md delete mode 100644 docs/project-structure.md create mode 100644 docs/reference.md create mode 100644 docs/template-authoring.md create mode 100644 src/Anvil/Private/Assert-ManifestConfiguration.ps1 create mode 100644 src/Anvil/Private/Assert-TemplateManifest.ps1 delete mode 100644 src/Anvil/Private/Assert-ValidConfiguration.ps1 create mode 100644 src/Anvil/Private/Convert-PromptResult.ps1 delete mode 100644 src/Anvil/Private/Copy-CITemplates.ps1 create mode 100644 src/Anvil/Private/Format-TokenValue.ps1 delete mode 100644 src/Anvil/Private/Invoke-InteractivePrompt.ps1 create mode 100644 src/Anvil/Private/Invoke-ManifestPrompt.ps1 create mode 100644 src/Anvil/Private/Read-PromptUri.ps1 create mode 100644 src/Anvil/Private/Read-TemplateManifest.ps1 create mode 100644 src/Anvil/Private/Resolve-AutoToken.ps1 create mode 100644 src/Anvil/Private/Resolve-DefaultFrom.ps1 create mode 100644 src/Anvil/Private/Resolve-TemplateSections.ps1 create mode 100644 src/Anvil/Private/Test-FileCondition.ps1 create mode 100644 src/Anvil/Private/Test-ManifestCondition.ps1 create mode 100644 src/Anvil/Public/Invoke-AnvilBuild.ps1 create mode 100644 src/Anvil/Templates/Module/.gitattributes delete mode 100644 src/Anvil/Templates/Module/getting-started/build-pipeline.md delete mode 100644 src/Anvil/Templates/Module/getting-started/cicd-integration.md delete mode 100644 src/Anvil/Templates/Module/getting-started/customization.md delete mode 100644 src/Anvil/Templates/Module/getting-started/development.md delete mode 100644 src/Anvil/Templates/Module/getting-started/faq.md delete mode 100644 src/Anvil/Templates/Module/getting-started/getting-started.md delete mode 100644 src/Anvil/Templates/Module/getting-started/project-structure.md rename src/Anvil/Templates/Module/src/{__ModuleName__ => __Name__}/Imports.ps1 (100%) rename src/Anvil/Templates/Module/src/{__ModuleName__ => __Name__}/Private/Format-GreetingText.ps1.tmpl (100%) rename src/Anvil/Templates/Module/src/{__ModuleName__ => __Name__}/Private/README.md (100%) rename src/Anvil/Templates/Module/src/{__ModuleName__ => __Name__}/PrivateClasses/GreetingBuilder.ps1 (100%) rename src/Anvil/Templates/Module/src/{__ModuleName__ => __Name__}/PrivateClasses/README.md (100%) rename src/Anvil/Templates/Module/src/{__ModuleName__ => __Name__}/Public/Get-Greeting.ps1.tmpl (100%) rename src/Anvil/Templates/Module/src/{__ModuleName__ => __Name__}/Public/README.md (100%) rename src/Anvil/Templates/Module/src/{__ModuleName__/__ModuleName__.psd1.tmpl => __Name__/__Name__.psd1.tmpl} (88%) rename src/Anvil/Templates/Module/src/{__ModuleName__/__ModuleName__.psm1.tmpl => __Name__/__Name__.psm1.tmpl} (97%) create mode 100644 src/Anvil/Templates/Module/template.psd1 rename src/Anvil/Templates/Module/tests/unit/{__ModuleName__.Module.Tests.ps1.tmpl => __Name__.Module.Tests.ps1.tmpl} (83%) create mode 100644 tests/unit/Private/Assert-ManifestConfiguration.Tests.ps1 create mode 100644 tests/unit/Private/Assert-TemplateManifest.Tests.ps1 delete mode 100644 tests/unit/Private/Assert-ValidConfiguration.Tests.ps1 create mode 100644 tests/unit/Private/Convert-PromptResult.Tests.ps1 delete mode 100644 tests/unit/Private/Copy-CITemplates.Tests.ps1 create mode 100644 tests/unit/Private/Format-TokenValue.Tests.ps1 delete mode 100644 tests/unit/Private/Invoke-InteractivePrompt.Tests.ps1 create mode 100644 tests/unit/Private/Invoke-ManifestPrompt.Tests.ps1 create mode 100644 tests/unit/Private/Read-PromptUri.Tests.ps1 create mode 100644 tests/unit/Private/Read-TemplateManifest.Tests.ps1 create mode 100644 tests/unit/Private/Resolve-AutoToken.Tests.ps1 create mode 100644 tests/unit/Private/Resolve-DefaultFrom.Tests.ps1 create mode 100644 tests/unit/Private/Resolve-TemplateSections.Tests.ps1 create mode 100644 tests/unit/Private/Test-FileCondition.Tests.ps1 create mode 100644 tests/unit/Private/Test-ManifestCondition.Tests.ps1 create mode 100644 tests/unit/TemplateManifestParity.Tests.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 400d10c..313341e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,7 @@ ## Prerequisites -- **PowerShell 7.2+** (required by the bootstrap script and ModuleFast) -- No other tooling required — the bootstrap script handles everything. +- **PowerShell 7.2+** ## Setup @@ -13,22 +12,42 @@ cd Anvil ./build/bootstrap.ps1 ``` -## Development loop +## Development ```powershell Invoke-Build -File ./build/module.build.ps1 -Task Lint, Test ``` +Run the full pipeline before submitting: + +```powershell +Invoke-Build -File ./build/module.build.ps1 +``` + +To test your changes interactively: + +```powershell +Import-Module ./src/Anvil/Anvil.psd1 -Force +``` + ## Conventions -- One function per file, filename matches function name. -- Public functions in `src/Anvil/Public/`, private in `Private/`. -- Always use `Join-Path` — never backslash concatenation. -- Pester 5 syntax only (`New-PesterConfiguration`, `BeforeAll`, `BeforeDiscovery`). -- Tag tests with `'Unit'` or `'Integration'`. +- One function per file, filename matches function name +- Public functions in `src/Anvil/Public/`, private in `Private/` +- Always use `Join-Path` for path construction +- Pester 5 syntax only +- Tag tests with `'Unit'` or `'Integration'` +- Template tokens use `<%Name%>` for content and `__Name__` for paths +- Template manifests (`template.psd1`) are the source of truth for template parameters and conditions + +## Testing + +Unit tests cover individual functions. Integration tests scaffold real projects and verify the output. Both must pass before merging. + +When adding a new private function, include an "is not exported" test in the test file. ## Pull requests 1. Branch from `main` -2. Run `Invoke-Build -File ./build/module.build.ps1` — ensure it passes +2. Run the full pipeline and ensure it passes 3. Open a PR against `main` diff --git a/README.md b/README.md index bd6c32b..76b38ad 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,7 @@ [![PowerShell Gallery][gallery-badge]][gallery-link] [![License][license-badge]][license-link] -Anvil helps you create, develop, and ship PowerShell modules. It scaffolds a complete project structure with build pipelines, testing, linting, and CI/CD — then stays useful as you add functions, manage dependencies, and iterate. - -> **Note:** Anvil is still in early development. Expect breaking changes, incomplete features, and rough edges. - -## Features - -- Scaffold a new module project that's ready to go from a single command -- Helpers to add functions, classes, tests, and dependencies to your module as you develop from the terminal -- A build process that auto-formats your code, lints it, runs tests with coverage, generates docs, compiles a single-file module, and packages it for publishing - - Pester (v5) test harness, with code coverage reporting and thresholds - - PSScriptAnalyzer linting with custom rules that ship with the project, and a simple process for adding your own - - Narkdown documentation generation using platyPS - - Anvil bootstraps its own build dependencies and your runtime dependencies during development via [ModuleFast](https://github.com/JustinGrote/ModuleFast) -- CI/CD workflows for GitHub Actions, Azure Pipelines, and GitLab CI -- Can build modules that are compatible with PowerShell 5.1, but authoring requires PowerShell >=7.2 +Anvil scaffolds PowerShell module projects with a working build pipeline, test infrastructure, linting, and CI/CD. It also provides commands for adding functions, classes, and dependencies as you develop. ## Installation @@ -26,50 +12,43 @@ Anvil helps you create, develop, and ship PowerShell modules. It scaffolds a com Install-Module -Name Anvil -Scope CurrentUser ``` -## Quick Start +Requires PowerShell 7.2 or later. Modules you build can target any version down to 5.1. -Scaffold a new module: +## Create a module ```powershell -$Params = @{ - Name = 'NetworkTools' - DestinationPath = '~/Projects' - Author = 'Jane Doe' - CIProvider = 'GitHub' - GitInit = $true -} -New-AnvilModule @Params +New-AnvilModule -Interactive ``` -Or run `New-AnvilModule -Interactive` for a guided wizard. - -Bootstrap and build: +This walks you through naming the module, choosing a CI provider, selecting a license, and configuring build options. When it finishes, you have a complete project that builds, lints, tests, and packages out of the box: ```powershell -cd ~/Projects/NetworkTools +cd MyModule Invoke-AnvilBootstrapDeps -Invoke-Build -File ./build/module.build.ps1 +Invoke-AnvilBuild ``` -Then keep developing — Anvil stays with you after scaffolding: +For scripted or CI-driven usage, pass parameters directly: ```powershell -New-AnvilFunction -FunctionName 'Get-Widget' -Scope Public -New-AnvilFunction -FunctionName 'Format-Row' -Scope Private -New-AnvilClass -ClassName 'HttpClient' -Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0' -Invoke-AnvilBootstrapDeps -Import-AnvilModule +$Params = @{ + Name = 'NetworkTools' + DestinationPath = '~/Projects' + Author = 'Jane Doe' + CIProvider = 'GitHub' + GitInit = $true +} +New-AnvilModule @Params ``` -## What you get +## Project structure ``` NetworkTools/ ├── src/NetworkTools/ Module source (Public/, Private/, PrivateClasses/) ├── build/ InvokeBuild pipeline, bootstrap, settings ├── tests/ Pester 5 unit and integration tests -├── docs/ platyPS documentation +├── docs/ Documentation ├── requirements.psd1 Module dependencies ├── .github/workflows/ CI/CD (or Azure Pipelines / GitLab CI) ├── PSScriptAnalyzerSettings.psd1 @@ -78,16 +57,35 @@ NetworkTools/ └── .gitignore ``` +The build pipeline handles formatting, linting, unit tests with code coverage, module compilation into a single distributable file, integration tests, and packaging. Build dependencies are bootstrapped automatically — nothing to install manually beyond Anvil itself. + +CI/CD workflows are included for GitHub Actions, Azure Pipelines, or GitLab CI, with tag-triggered releases to the PowerShell Gallery. + +## Developing with Anvil + +After scaffolding, Anvil provides commands for common tasks: + +```powershell +New-AnvilFunction -FunctionName 'Get-Widget' -Scope Public # creates function + test +New-AnvilFunction -FunctionName 'Format-Row' -Scope Private # internal helper + test +New-AnvilClass -ClassName 'WidgetResult' # class + test +Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0' # adds to manifest + requirements +Import-AnvilModule # reload for interactive testing +``` + +## Custom templates + +Anvil's scaffolding is driven by a manifest-based template system. You can create your own templates with custom parameters, conditions, and file structures. See [Template Authoring](docs/template-authoring.md). + ## Documentation -- [Getting Started](docs/getting-started.md) — scaffold a project, bootstrap, first build -- [Development](docs/development.md) — adding functions, classes, dependencies, testing, the daily workflow -- [Project Structure](docs/project-structure.md) — what every file and directory does -- [Build Pipeline](docs/build-pipeline.md) — every build task explained, settings reference -- [CI/CD Integration](docs/cicd-integration.md) — GitHub Actions, Azure Pipelines, GitLab CI -- [Customization](docs/customization.md) — custom lint rules, types, formats, build tasks -- [FAQ](docs/faq.md) — common questions and troubleshooting -- [Command Reference](docs/commands/) — detailed help for all commands +| | | +|---|---| +| [Getting Started](docs/getting-started.md) | Scaffold a project, bootstrap, first build | +| [Reference](docs/reference.md) | Project structure, build pipeline, CI/CD, customization | +| [Template Authoring](docs/template-authoring.md) | Creating custom templates | +| [Command Reference](docs/commands/) | Detailed help for all commands | +| [FAQ](docs/faq.md) | Troubleshooting | ## Contributing @@ -99,7 +97,18 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. ## Acknowledgements -Heavily inspired by [Catesta](https://github.com/techthoughts2/Catesta). +Anvil builds on and is inspired by the work of these projects: + +| Project | License | Role | +|---------|---------|------| +| [Catesta](https://github.com/techthoughts2/Catesta) | MIT | Inspiration for Anvil's design | +| [InvokeBuild](https://github.com/nightroman/Invoke-Build) | Apache 2.0 | Build pipeline engine | +| [ModuleFast](https://github.com/JustinGrote/ModuleFast) | MIT | Dependency bootstrapping | +| [Pester](https://github.com/pester/Pester) | Apache 2.0 | Test framework | +| [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) | MIT | Linting and formatting | +| [PSResourceGet](https://github.com/PowerShell/PSResourceGet) | MIT | Module publishing | +| [platyPS](https://github.com/PowerShell/platyPS) | MIT | Documentation generation | +| [Indented.ScriptAnalyzerRules](https://github.com/indented-automation/Indented.ScriptAnalyzerRules) | MIT | Custom PSScriptAnalyzer rules | [ci-badge]: https://github.com/f0oster/Anvil/actions/workflows/ci.yml/badge.svg?branch=main diff --git a/docs/build-pipeline.md b/docs/build-pipeline.md deleted file mode 100644 index dec9952..0000000 --- a/docs/build-pipeline.md +++ /dev/null @@ -1,168 +0,0 @@ -# Build Pipeline - -The build is powered by [InvokeBuild](https://github.com/nightroman/Invoke-Build). All tasks are defined in `build/module.build.ps1` and configured via `build/build.settings.psd1`. Default values are stored in `build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1` — any missing or invalid setting in your config falls back to its default automatically. - -## Pipelines - -There are two composite tasks. The default pipeline runs everything except publishing: - -``` -. (default) → Clean, Validate, Format, Lint, Test, Docs, Build, IntegrationTest, Package -``` - -The release pipeline adds Version at the start and Publish at the end: - -``` -Release → Version, ., Publish -``` - -## Running the build - -```powershell -# Full default pipeline -Invoke-Build -File ./build/module.build.ps1 - -# Specific tasks -Invoke-Build -File ./build/module.build.ps1 -Task Lint, Test - -# Release with version injection -Invoke-Build -File ./build/module.build.ps1 -Task Release -NewVersion 1.0.0 - -# Release with prerelease label -Invoke-Build -File ./build/module.build.ps1 -Task Release -NewVersion 1.0.0 -Prerelease beta1 -``` - -During development, `Invoke-Build -Task Lint, Test` is the fastest feedback loop. It skips formatting, docs, compilation, and packaging — just checks your code and runs tests. - -## Build settings - -All settings live in `build/build.settings.psd1`. Edit this file to customise the build — you never need to modify `module.build.ps1`. - -| Setting | Default | Valid values | What it controls | -|---------|---------|-------------|-----------------| -| `ModuleName` | *(your module)* | Non-empty string | Module to build | -| `CoverageThreshold` | `80` | `0`–`100` | Minimum code coverage percentage (0 to disable) | -| `IncludeDocs` | `$true` or `$false` | Boolean | Whether the Docs task generates platyPS documentation | -| `TestOutputFormat` | `'NUnitXml'` | `NUnitXml`, `NUnit2.5`, `NUnit3`, `JUnitXml` | Test result format for CI reporting | -| `TestVerbosity` | `'Detailed'` | `None`, `Normal`, `Detailed`, `Diagnostic` | Pester output verbosity | -| `LintFailOn` | `@('Warning', 'Error')` | `Error`, `Warning`, `Information`, `ParseError` | Severity levels that fail the build | -| `AssetDirectories` | `@('Types', 'Formats', 'Assemblies')` | Array of strings | Extra directories copied to the staged module | - -If you remove a setting or set it to an invalid value, the build uses the default from `build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1` and prints a warning. - -## Task reference - -### Clean - -Deletes and recreates the `artifacts/` directory with subdirectories for `package/`, `testResults/`, and `archive/`. This ensures every build starts from a clean state. - -### Validate - -Sanity checks before doing real work. Verifies the module manifest exists and is valid, confirms the `.psm1` exists, and reports the PowerShell version. If the manifest is malformed, the build fails here with a clear error rather than somewhere deeper in the pipeline. - -### Format - -Runs `Invoke-Formatter` on every `.ps1` file in the module source directory using the rules from `PSScriptAnalyzerSettings.psd1`. This auto-fixes formatting issues — indentation, whitespace around operators, brace placement — so the Lint task only reports substantive problems. - -This task modifies your source files in place. If you're tracking formatting changes, commit before running the build. - -### Lint - -Runs `Invoke-ScriptAnalyzer` against the module source with the settings from `PSScriptAnalyzerSettings.psd1`. It also loads any `.psm1` files found in `build/analyzers/` as custom rules. - -The build fails if any issues matching the `LintFailOn` severities are found (default: Warning and Error). Information-level findings are reported but don't fail the build unless you add `'Information'` to `LintFailOn`. - -The custom rules that ship with Anvil projects catch: - -| Rule | What it catches | -|------|----------------| -| AvoidProcessWithoutPipeline | `process` block in a function that doesn't accept pipeline input | -| AvoidNestedFunctions | Function definitions inside other functions | -| AvoidSmartQuotes | Curly/smart quote characters copied from word processors | -| AvoidEmptyNamedBlocks | Empty `begin`, `process`, `end`, or `dynamicparam` blocks | -| AvoidNewObjectPSObject | `New-Object PSObject` instead of `[PSCustomObject]@{}` | -| AvoidWriteOutput | Unnecessary `Write-Output` (output flows implicitly in PowerShell) | - -To disable any rule, add its name to `ExcludeRules` in `PSScriptAnalyzerSettings.psd1`. To add your own rules, drop a `.psm1` file in `build/analyzers/`. - -### Test - -Runs Pester 5 unit tests from `tests/unit/` with code coverage enabled. Coverage is measured against `.ps1` files in `PrivateClasses/`, `Public/`, and `Private/`. - -The test result format and verbosity are controlled by the `TestOutputFormat` and `TestVerbosity` settings. The `PESTER_OUTPUT_FORMAT` environment variable overrides `TestOutputFormat` if set (useful for CI systems that need a specific format without changing the settings file). - -The test task fails the build if any test fails or if coverage drops below `CoverageThreshold` (default: 80%). Set the threshold to 0 to disable coverage enforcement while keeping the report. - -### Docs - -This task is controlled by the `IncludeDocs` setting. When set to `$false`, the task skips automatically. You can toggle it at any time in `build/build.settings.psd1` without editing the build script. - -Generates and maintains platyPS markdown documentation in `docs/commands/`. The behavior depends on whether documentation already exists: - -- **First run** (no `docs/commands/` directory): generates markdown for every exported function from the module's comment-based help using `New-MarkdownHelp` -- **Subsequent runs**: updates existing markdown with `Update-MarkdownHelp`, which refreshes parameter metadata and syntax blocks while preserving any manual edits you've made to descriptions, examples, and notes - -The generated markdown is meant to be committed and maintained as source. Edit the files to add richer descriptions, usage notes, or links — the update process won't overwrite your changes. - -If platyPS isn't installed, the Docs task skips gracefully. - -### Build - -Produces the compiled module in `artifacts/package//`. This is the most complex task and does several things: - -1. **Copies static assets** — directories listed in the `AssetDirectories` setting (default: `Types/`, `Formats/`, `Assemblies/`), if they exist in source -2. **Compiles the `.psm1`** — merges `Imports.ps1`, then `PrivateClasses/*.ps1`, `Private/*.ps1`, and `Public/*.ps1` into a single file in that order. The compiled module loads faster than dot-sourcing individual files at import time. -3. **Generates the manifest** — creates a fresh `.psd1` with `FunctionsToExport` set to the Public function names. If `requirements.psd1` exists, populates `RequiredModules` from it. -4. **Generates MAML help** — if `docs/commands/` has markdown and platyPS is available, converts it to MAML XML in the staged module's `en-US/` directory for `Get-Help` support. -5. **Injects version** — if `-NewVersion` was passed, the staged manifest gets that version instead of the source's `0.0.0`. - -### IntegrationTest - -Runs the built-in integration tests from `tests/integration/` against the compiled output in `artifacts/package/`. These tests verify that the module was built correctly and can be imported. They're included in every scaffolded project — you don't need to write or maintain them. - -### Package - -Creates a ZIP archive of the staged module in `artifacts/archive/`. The archive is named `-.zip`. - -### Version - -Reports the current version and confirms what `-NewVersion` or `-Prerelease` will apply. - -### Publish - -Publishes the staged module to the PowerShell Gallery using `Publish-PSResource`. Requires the `PSGALLERY_API_KEY` environment variable. - -The task has two safety checks: -- Refuses to run without an API key -- Refuses to publish version `0.0.0` (the placeholder), with a message telling you to pass `-NewVersion` - -### DevCC - -Generates a Coverage Gutters-compatible `coverage.xml` at the project root for VS Code inline coverage display. Unlike the Test task, DevCC doesn't fail on coverage threshold — it's meant for iterative local use, not enforcement. - -Install the [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) extension in VS Code to see green/red inline markers. - -## Version management - -The source manifest always contains version `0.0.0`. This is deliberate. Versions are injected at build time, not maintained in source. - -For local development, `0.0.0` artifacts are clearly not releases. For CI releases, the version comes from the git tag: - -```bash -git tag v1.2.0 -git push origin v1.2.0 -# CI runs: Invoke-Build -Task Release -NewVersion 1.2.0 -``` - -The source `.psd1` is never modified. This means: -- No "bump version" commits cluttering your history -- No drift between tags and manifest versions -- CI is the single source of truth for release versions - -For prerelease labels: - -```powershell -Invoke-Build -Task Release -NewVersion 1.2.0 -Prerelease beta1 -``` - -This produces a module that the Gallery treats as prerelease (requires `-AllowPrerelease` to install). diff --git a/docs/cicd-integration.md b/docs/cicd-integration.md deleted file mode 100644 index 59a5e53..0000000 --- a/docs/cicd-integration.md +++ /dev/null @@ -1,108 +0,0 @@ -# CI/CD Integration - -Anvil generates CI/CD workflows that run the full build pipeline on push/PR and publish to the PowerShell Gallery on tagged releases. The generated workflows are starting points — you'll likely need to adjust them for your environment. - -## Choosing a provider - -```powershell -New-AnvilModule -Name 'MyModule' -DestinationPath . -Author 'Dev' -CIProvider GitHub -``` - -Available providers: `GitHub`, `AzurePipelines`, `GitLab`, `None`. Use `None` if you handle CI yourself or don't need it yet. You can always add CI files later by scaffolding a second project and copying the workflow files. - -## How releases work - -All three providers follow the same pattern. Understanding this flow is important before configuring anything. - -1. You develop on a branch, push, and merge. CI runs the full default pipeline on every push. No publishing happens. - -2. When you're ready to release, you tag the commit: - - ```bash - git tag v1.0.0 - git push origin v1.0.0 - ``` - -3. The release workflow triggers on the `v*` tag pattern. It extracts the version number from the tag name (strips the `v` prefix), passes it to the build script as `-NewVersion`, and runs the full Release pipeline including Publish. - -4. The Publish task pushes the module to the PowerShell Gallery. It refuses to publish version `0.0.0` (the source placeholder), so if the version extraction fails, the build fails safely rather than publishing garbage. - -The source `.psd1` is never modified. The version exists only in the CI workspace during the build. There are no "version bump" commits. - -## GitHub Actions - -### Generated files - -| File | Trigger | Purpose | -|------|---------|---------| -| `.github/workflows/ci.yml` | Push/PR to main | Runs the default pipeline | -| `.github/workflows/release.yml` | Tags matching `v*` | Builds with version injection, publishes | - -### Setup - -1. Go to your repository **Settings > Environments** and create an environment called `psgallery` -2. Under the `psgallery` environment, add `PSGALLERY_API_KEY` as an environment secret with your PowerShell Gallery API key -3. Optionally, add required reviewers to the environment for manual approval before publishing -4. Optionally, restrict the environment to the `main` branch under **Deployment branches** - -## Azure Pipelines - -### Generated files - -| File | Trigger | Purpose | -|------|---------|---------| -| `azure-pipelines.yml` | Push/PR | CI pipeline | -| `azure-pipelines-release.yml` | Tags matching `v*` | Release pipeline | - -### Setup - -1. Go to **Pipelines > New pipeline** and create a pipeline from `azure-pipelines.yml`. This is your CI pipeline — name it something like `CI`. -2. Create a second pipeline from `azure-pipelines-release.yml`. This is your release pipeline — name it something like `Release`. -3. On the release pipeline, go to **Variables** and add `PSGALLERY_API_KEY` as a secret variable with your PowerShell Gallery API key. - -The release pipeline references a `psgallery` environment, which is created automatically on the first run. In Azure DevOps, environments don't hold secrets — secrets are pipeline variables. Environments are used for approval gates and deployment tracking. If you want manual sign-off before publishing, add an approval check under **Pipelines > Environments > psgallery**. - -## GitLab CI - -### Generated file - -| File | Stages | Purpose | -|------|--------|---------| -| `.gitlab-ci.yml` | ci, publish | Combined CI and release | - -The publish stage only runs for tags matching `v*` (controlled by a `rules` clause). - -### Setup - -1. Go to **Operate > Environments** and create an environment called `psgallery` -2. Go to **Settings > CI/CD > Variables** and add `PSGALLERY_API_KEY` as a protected, masked variable scoped to the `psgallery` environment -3. Go to **Settings > Repository > Protected tags** and add `v*` as a protected tag pattern (required for protected variables to be injected) - -For approval gates on the free tier, add `when: manual` to the publish job. Protected environments with role-based approvals require GitLab Premium. - -### Notes - -GitLab CI uses the `mcr.microsoft.com/powershell:lts-ubuntu-22.04` Docker image and runs on Linux by default. A Windows CI job is included but commented out — it requires a self-hosted runner tagged `windows`. GitLab shared runners on gitlab.com are Linux only. - -Test results use JUnit format for GitLab's test report integration. - -## Testing CI locally - -You can simulate what CI does without pushing: - -```powershell -./build/bootstrap.ps1 -Invoke-Build -File ./build/module.build.ps1 -Task Release -NewVersion 1.0.0-local -``` - -The Publish task will fail (no API key), but everything else runs. This is useful for verifying the full pipeline before tagging a release. The `-local` suffix is arbitrary — it just makes the version obviously non-production. - -## Adding CI to an existing project - -If you scaffolded with `-CIProvider None` and want to add CI later, the simplest approach is to scaffold a throwaway project with the desired provider and copy the workflow files: - -```powershell -New-AnvilModule -Name 'Temp' -DestinationPath $env:TEMP -Author 'x' -CIProvider GitHub -Force -``` - -Then copy `.github/workflows/` (or the equivalent) into your real project. The workflows reference `./build/bootstrap.ps1` and `./build/module.build.ps1`, which already exist in your project. diff --git a/docs/commands/Invoke-AnvilBuild.md b/docs/commands/Invoke-AnvilBuild.md new file mode 100644 index 0000000..a1089b1 --- /dev/null +++ b/docs/commands/Invoke-AnvilBuild.md @@ -0,0 +1,136 @@ +--- +external help file: Anvil-help.xml +Module Name: Anvil +online version: +schema: 2.0.0 +--- + +# Invoke-AnvilBuild + +## SYNOPSIS +Runs the InvokeBuild pipeline in an Anvil project. + +## SYNTAX + +``` +Invoke-AnvilBuild [[-Task] ] [-NewVersion ] [-Prerelease ] [-Path ] + [-ProgressAction ] [] +``` + +## DESCRIPTION +Locates build/module.build.ps1 in the project root and invokes it +with the specified tasks. +If no tasks are specified, runs the +default pipeline. + +## EXAMPLES + +### EXAMPLE 1 +``` +Invoke-AnvilBuild +``` + +### EXAMPLE 2 +``` +Invoke-AnvilBuild -Task Lint, Test +``` + +### EXAMPLE 3 +``` +Invoke-AnvilBuild -Task Release -NewVersion 1.0.0 +``` + +## PARAMETERS + +### -Task +One or more build tasks to run. +When omitted, runs the default +pipeline (Clean, Validate, Format, Lint, Test, Build, +IntegrationTest, Package). + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -NewVersion +Version number to inject into the compiled module manifest. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Prerelease +Prerelease label to set on the compiled module manifest. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Path +The project root directory. +If not provided, walks up from the +current directory to find the project root. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None +## OUTPUTS + +### None +## NOTES + +## RELATED LINKS diff --git a/docs/commands/New-AnvilModule.md b/docs/commands/New-AnvilModule.md index d4451af..a03c704 100644 --- a/docs/commands/New-AnvilModule.md +++ b/docs/commands/New-AnvilModule.md @@ -17,8 +17,8 @@ and CI/CD pipelines. New-AnvilModule [[-Name] ] [[-DestinationPath] ] [-Author ] [-Description ] [-CompanyName ] [-MinPowerShellVersion ] [-CompatiblePSEditions ] [-CIProvider ] [-License ] [-IncludeDocs] [-CoverageThreshold ] [-Tags ] - [-ProjectUri ] [-LicenseUri ] [-Force] [-GitInit] [-Interactive] [-PassThru] - [-ProgressAction ] [-WhatIf] [-Confirm] [] + [-ProjectUri ] [-LicenseUri ] [-Template ] [-Force] [-GitInit] [-Interactive] + [-PassThru] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -308,6 +308,24 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Template +Template to scaffold from. Accepts a template name (looked up +under the bundled Templates directory) or an absolute path to a +directory containing a template.psd1 manifest. +Default: Module. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: Module +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Force Removes and re-creates the destination directory if it already exists. diff --git a/docs/customization.md b/docs/customization.md deleted file mode 100644 index 1a4c4ac..0000000 --- a/docs/customization.md +++ /dev/null @@ -1,137 +0,0 @@ -# Customization - -Anvil projects are convention-based. The build system discovers files automatically — you extend the project by adding files in the right places, not by editing configuration. - -For day-to-day tasks like adding functions, classes, and dependencies, see [Development](development.md). This page covers advanced extension points. - -## Custom PSScriptAnalyzer rules - -The Lint task automatically discovers `.psm1` files in `build/analyzers/` and loads them as custom rule sources. To add a rule, create a new `.psm1` file: - -```powershell -# build/analyzers/MyProjectRules.psm1 -using namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic -using namespace System.Management.Automation.Language - -function AvoidHardcodedPaths { - [CmdletBinding()] - [OutputType([DiagnosticRecord])] - param( - [StringConstantExpressionAst]$ast - ) - - if ($ast.Value -match '^[A-Z]:\\') { - [DiagnosticRecord]@{ - Message = 'Avoid hardcoded Windows paths. Use Join-Path or environment variables.' - Extent = $ast.Extent - RuleName = $myinvocation.MyCommand.Name - Severity = 'Warning' - } - } -} -``` - -Each rule is a function that receives an AST node and returns `DiagnosticRecord` objects for violations. The function name becomes the rule name. PSSA calls your function once per matching AST node in each file being analyzed. - -Common AST parameter types: -- `[ScriptBlockAst]` — entire function/script bodies -- `[FunctionDefinitionAst]` — function definitions -- `[CommandAst]` — command invocations -- `[StringConstantExpressionAst]` — string literals - -To disable any rule (built-in or custom), add it to `ExcludeRules` in `PSScriptAnalyzerSettings.psd1`: - -```powershell -ExcludeRules = @( - 'PSAvoidUsingWriteHost' - 'AvoidHardcodedPaths' -) -``` - -## Types and formatting - -PowerShell supports custom type extensions and formatting views via `.ps1xml` files. These are useful when your module returns custom objects and you want to control how they display. - -### Type extensions - -Create `src/MyModule/Types/MyModule.Types.ps1xml` to add calculated properties, methods, or default display property sets to your types: - -```xml - - - MyModule.ConnectionResult - - - IsSuccess - $this.StatusCode -lt 400 - - - - -``` - -### Formatting views - -Create `src/MyModule/Formats/MyModule.Format.ps1xml` to define table or list views: - -```xml - - - - MyModule.ConnectionResult - - MyModule.ConnectionResult - - - - - - - - - - - Host - StatusCode - LatencyMs - - - - - - - -``` - -### Wiring them up - -Reference both in your module manifest: - -```powershell -TypesToProcess = @('Types/MyModule.Types.ps1xml') -FormatsToProcess = @('Formats/MyModule.Format.ps1xml') -``` - -The Build task copies `Types/` and `Formats/` directories to the staged module and carries these manifest properties through to the published manifest. - -**Important:** Type and format files affect the entire PowerShell session, not just your module. If you add a formatting view for `System.IO.FileInfo`, it changes how files display everywhere. Keep your type extensions scoped to types your module owns. - -## Adding build tasks - -InvokeBuild tasks are PowerShell scriptblocks. Add them to `build/module.build.ps1`: - -```powershell -task Deploy { - Write-BuildHeader 'Deploy' - # your deployment logic here - Write-BuildFooter 'Deploy complete' -} -``` - -Add the task name to a composite task to include it in a pipeline, or run it standalone: - -```powershell -Invoke-Build -File ./build/module.build.ps1 -Task Deploy -``` - -Note that modifying `module.build.ps1` means your project's build pipeline has diverged from Anvil's default. Future Anvil versions may ship updated build scripts, and migrating will require manually merging your changes. diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index c4d9a99..0000000 --- a/docs/development.md +++ /dev/null @@ -1,178 +0,0 @@ -# Development - -This page covers the day-to-day workflow of building a module with Anvil — adding functions, writing tests, managing dependencies, and running the build. If you haven't scaffolded a project yet, start with [Getting Started](getting-started.md). - -## The development loop - -Once your project is scaffolded and bootstrapped, the development cycle looks like this: - -1. **Scaffold a new function** with `New-AnvilFunction -FunctionName 'Get-Widget' -Scope Public`. This creates the function file with boilerplate and a matching test file. - -2. **Write your implementation** in `src//Public/Get-Widget.ps1`. Public functions get comment-based help scaffolding. Private functions get a minimal template. - -3. **Write tests** in `tests/unit/Public/Get-Widget.Tests.ps1`. The generated test file already imports the module and has the right `BeforeAll`/`AfterAll` pattern — just add your assertions. - -4. **Add dependencies** if needed with `Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0'`, then run `Invoke-AnvilBootstrapDeps` to install them. - -5. **Reload the module** with `Import-AnvilModule`. This finds and re-imports the development version of your module from anywhere in the project tree, so you can test interactively in the terminal without typing out manifest paths. - -6. **Lint and test** with `Invoke-Build -Task Lint, Test`. This runs PSScriptAnalyzer and your Pester unit tests and reports on test coverage. For integration tests, run a full build. - -7. **Run the full pipeline** before committing: `Invoke-Build -File ./build/module.build.ps1`. This adds docs generation, module compilation, integration tests, and packaging on top of lint and test. - -## Adding functions - -### Public functions - -```powershell -New-AnvilFunction -FunctionName 'Test-NetworkConnection' -Scope Public -``` - -This creates two files: - -- `src//Public/Test-NetworkConnection.ps1` — a function scaffold with `[CmdletBinding()]`, `[OutputType()]`, and a comment-based help block -- `tests/unit/Public/Test-NetworkConnection.Tests.ps1` — a Pester test scaffold with module import, a placeholder test, and the standard `BeforeAll`/`AfterAll` pattern - -Open the function file, replace the placeholder logic, then open the test file and write real assertions. The scaffolds are starting points, not finished code. - -Public function names must use an [approved PowerShell verb](https://learn.microsoft.com/en-us/powershell/scripting/developer/cmdlet/approved-verbs-for-windows-powershell-commands). Anvil validates this and rejects names like `Fetch-Data`. If you have a good reason to use a non-standard verb, pass `-SkipVerbCheck`. - -### Private functions - -```powershell -New-AnvilFunction -FunctionName 'Resolve-HostAddress' -Scope Private -``` - -Private functions don't need approved verbs, don't get comment-based help by default, and their tests use `InModuleScope` to reach inside the module. They're internal helpers — not visible to users of your module. - -### Organizing with subdirectories - -As your module grows, you can organize functions into subdirectories: - -```powershell -New-AnvilFunction -FunctionName 'Get-DnsRecord' -Scope Public -Location 'Dns' -``` - -This creates `src//Public/Dns/Get-DnsRecord.ps1` and `tests/unit/Public/Dns/Get-DnsRecord.Tests.ps1`. The module loader and build system discover files recursively, so nesting is purely organizational — it doesn't affect behavior. - -## Adding classes - -```powershell -New-AnvilClass -ClassName 'ConnectionResult' -``` - -Classes go in `PrivateClasses/` and are loaded before any functions, so your functions can use them. The generated test uses `InModuleScope` to instantiate the class and includes a test verifying it's not accessible outside the module. - -### Things to know about PowerShell classes - -**Type updates require a new session.** When you change a class definition and run `Import-Module -Force`, PowerShell reloads the functions but the class definition is pinned to the .NET type system from the first load. You must close and reopen your PowerShell session to pick up class changes. There's no workaround — this is a PowerShell engine limitation. - -**Load order is alphabetical.** Anvil authored products process the files in `PrivateClasses/` in filename order. If class `B` inherits from class `A`, make sure `A.ps1` sorts before `B.ps1`. A common convention is to prefix with numbers (`01-BaseClass.ps1`, `02-DerivedClass.ps1`) when inheritance order matters, or to always group classes that depend on each other together in a single file, in the required order. - -**Classes can't see `$script:` variables.** Unlike functions, class methods don't have access to module-scoped variables. If a class needs configuration or state from the module, pass it through the constructor or a method parameter. - -**Classes are not easily exported from modules.** PowerShell has no `ClassesToExport` mechanism. It's recommended to not try expose classes for module consumers to use directly. Classes are best used for organizing internal logic and acting as DTOs. For public module APIs, it's best to stick to exported functions. If you need to expose behavior from a class, wrap its methods in Public functions instead. - -For a full list of PowerShell class limitations, see the [Microsoft documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_classes?view=powershell-7.6#limitations). - -## Module initialization (Imports.ps1) - -`Imports.ps1` runs before any classes or functions load. Use it for module-scoped variables, assembly loading, or any initialization your code depends on: - -```powershell -$script:ResourcePath = Join-Path -Path $PSScriptRoot -ChildPath 'Resources' -$script:ApiBaseUrl = 'https://api.example.com/v1' -$script:DefaultTimeout = 30 -Add-Type -Path "$PSScriptRoot\lib\MyLibrary.dll" -``` - -Any `$script:` variable defined here is accessible from all Public and Private functions within the module. During development, the `.psm1` dot-sources this file. At build time, its content is merged into the top of the compiled module — so behavior is identical in both modes. - -Don't put function definitions here — use `Public/` or `Private/` for that. - -## Managing dependencies - -If your module depends on other modules at runtime, declare them with `Add-AnvilDependency`: - -```powershell -Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0' -Add-AnvilDependency -Name 'ImportExcel' -Version '7.8.6' -Add-AnvilDependency -Name 'PSFramework' -``` - -This updates two files: `requirements.psd1` (used by the bootstrap and build) and the source module manifest's `RequiredModules`. Version specs follow ModuleFast syntax: `'>=5.0.0'` for a minimum version, `'5.7.1'` for an exact pin, or `'latest'` (the default) for any version. - -After adding a dependency, install it: - -```powershell -Invoke-AnvilBootstrapDeps -``` - -To remove a dependency: - -```powershell -Remove-AnvilDependency -Name 'Az.Storage' -Force -``` - -Build tools (InvokeBuild, Pester, PSScriptAnalyzer) are managed separately in `build/build.requires.psd1` as module consumers will never need these installed. Don't add them to `requirements.psd1`. - -## Testing - -### Running tests - -```powershell -# Run all unit tests -Invoke-Build -File ./build/module.build.ps1 -Task Test - -# Run a single test file directly -Invoke-Pester -Path tests/unit/Public/Test-NetworkConnection.Tests.ps1 - -# Run tests with inline coverage in VS Code -Invoke-Build -File ./build/module.build.ps1 -Task DevCC -``` - -The DevCC task generates a `coverage.xml` file in Coverage Gutters format. Install the [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) VS Code extension to see coverage inline in your editor. - -### Testing private functions - -Use Pester's [`InModuleScope`](https://pester.dev/docs/usage/modules#testing-private-functions) inside individual `It` blocks: - -```powershell -It 'formats the output correctly' { - InModuleScope 'MyModule' { - Format-Internal -Name 'test' | Should -Be 'expected' - } -} -``` - -Don't wrap `Describe` or `Context` in `InModuleScope` — only `It` blocks. - -### Passing data into InModuleScope - -Variables from the test scope aren't visible inside `InModuleScope`. Use [`-ArgumentList`](https://pester.dev/docs/commands/InModuleScope) with a matching `param()` block: - -```powershell -It 'validates the configuration' { - $Config = @{ Name = 'Test'; Timeout = 30 } - InModuleScope 'MyModule' -ArgumentList $Config { - param($Config) - Assert-ValidConfig -Configuration $Config | Should -Not -Throw - } -} -``` - -### Coverage threshold - -The default is 80%. Pester fails the Test task if coverage drops below this. Change `CoverageThreshold` in `build/build.settings.psd1` to any value from 0 to 100. Set it to 0 to disable coverage enforcement. - -## Reloading the module - -After making changes, reload the development module: - -```powershell -Import-AnvilModule -``` - -This walks up the directory tree to find your project root, locates the source manifest, and imports it with `-Force`. You can run it from anywhere inside the project. - -Note that class changes require a new PowerShell session — `Import-Module -Force` (which `Import-AnvilModule` uses) reloads functions but not class definitions. diff --git a/docs/faq.md b/docs/faq.md index 55f4dc4..51b7ae5 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,53 +1,28 @@ # FAQ -## Why create Anvil when similar projects like ModuleBuilder, Catesta, Stucco, etc. exist? - -Anvil grew out of using Catesta across several projects. Eventually I decided to build something in line with my own preferences. A simple build system I could understand and modify without the complexity of Plaster, and authoring tools that streamline some of the more tedious areas of module authoring. - - -## Why is the version in my build 0.0.0? - -This is actually by design. The source manifest (the development manifest) uses `0.0.0` as a placeholder. The version is injected at build time: - -```powershell -Invoke-Build -Task Release -NewVersion x.x.x -``` - -In CI, the version is extracted from the git tag automatically, and no references to the modules version is referenced anywhere in the repository itself. This avoids "version bump" commits and means that git tags on your repository are the single source of truth for module versions. See [Build Pipeline > Version Management](build-pipeline.md#version-management). - ## The first build after scaffolding fails -This shouldn't happen. The scaffolded project includes sample functions and tests that should pass out of the box. If the build fails, check: - -- **Are you running PowerShell 7.2+?** The bootstrap script requires it because ModuleFast does. Run `$PSVersionTable.PSVersion` to check. -- **Are you running from the project root?** InvokeBuild expects to be invoked from the directory containing the build script, or with an explicit `-File` path. +This should not happen. The scaffolded project includes sample functions and tests that pass out of the box. Check: -If none of these help, this is a bug in Anvil — please report it. +- **Are you running PowerShell 7.2+?** The bootstrap requires it. Run `$PSVersionTable.PSVersion` to check. +- **Are you running from the project root?** InvokeBuild expects the build script path to be correct. -## The Publish task refuses to run +If neither of these helps, this is a bug in Anvil. -It checks two things: -1. The `PSGALLERY_API_KEY` environment variable must be set -2. The staged manifest version must not be `0.0.0` +## The Publish task doesn't run -If you see "Cannot publish placeholder version 0.0.0", pass `-NewVersion`: - -```powershell -Invoke-Build -Task Release -NewVersion 1.0.0 -``` +The Publish task requires the `PSGALLERY_API_KEY` environment variable and a version other than `0.0.0`. Both are normally handled by the CI/CD workflow — the release pipeline extracts the version from the git tag and the API key comes from the environment secret. If publishing fails, check that your CI environment has the API key configured and that the tag follows the `v*` pattern. See the [CI/CD section](reference.md#cicd-integration) for setup instructions. ## My class tests fail after changing the class -PowerShell classes are tied to the .NET type system. `Import-Module -Force` reloads functions but does not update class definitions. You must start a new PowerShell session to pick up class changes. This is a PowerShell limitation, not a Pester or Anvil issue. See [Development > Adding classes](development.md#adding-classes) for more on class quirks. +PowerShell classes are tied to the .NET type system. `Import-Module -Force` reloads functions but does not update class definitions. Start a new PowerShell session to pick up class changes. ## Why is there a `{{ Fill ProgressAction Description }}` in my docs? -The `-ProgressAction` common parameter was introduced in PowerShell 7.4. platyPS v0.14.2 predates this parameter and doesn't know how to describe it. This placeholder appears in every function's documentation. +The `-ProgressAction` common parameter was introduced in PowerShell 7.4. platyPS v0.14.2 predates this parameter and does not know how to describe it. This placeholder appears in every function's documentation. -You can safely replace it with a description like "Determines how the cmdlet responds to progress updates" or leave it as-is — it doesn't affect `Get-Help` output. +You can replace it with a description like "Determines how the cmdlet responds to progress updates" or leave it as-is. ## Can I target Windows PowerShell 5.1? -Yes. Set `-MinPowerShellVersion 5.1` and `-CompatiblePSEditions @('Desktop', 'Core')` when scaffolding. The generated module will work on both Windows PowerShell and PowerShell 7+. - -The build tooling itself requires 7.2+ (because ModuleFast does), but the module you produce can target 5.1. You build on modern PowerShell and ship for whatever version your users need. +Yes. Set `-MinPowerShellVersion 5.1` and `-CompatiblePSEditions @('Desktop', 'Core')` when scaffolding. The build tooling requires 7.2+, but the module you produce can target any version of PowerShell that your module code supports. diff --git a/docs/getting-started.md b/docs/getting-started.md index 746ddbe..6eb3110 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,96 +1,55 @@ # Getting Started -This guide walks through creating a module from scratch, adding real functionality, running the build, and understanding the development loop. - ## Prerequisites -You need **PowerShell 7.2 or later** for building. This is a firm requirement because ModuleFast (the dependency installer) needs it. The module you create can target any version down to 5.1 — the build tooling and the runtime target are separate concerns. - -You don't need to install InvokeBuild, Pester, PSScriptAnalyzer, or platyPS manually. The bootstrap script handles all of that. +You need **PowerShell 7.2 or later** for building. The module you create can target any version down to 5.1 — the build tooling and the runtime target are separate concerns. -Git is optional but recommended. If you pass `-GitInit`, Anvil creates a repository with an initial commit. If git is on your PATH, the interactive wizard also detects your name from `git config user.name`. +You do not need to install InvokeBuild, Pester, PSScriptAnalyzer, or platyPS manually. The bootstrap script handles all of that. ## Creating a module -### The interactive way - The `-Interactive` switch starts a guided wizard: ```powershell New-AnvilModule -Interactive ``` -You'll see prompts for module name, destination, author, description, CI provider, license, and more. Each prompt shows a default in brackets — press Enter to accept it. The author name is pulled from your git config if available. - -You can also pre-fill some parameters and let the wizard prompt for the rest: +You can also pre-fill parameters and let the wizard prompt for the rest: ```powershell New-AnvilModule -Interactive -Name 'NetworkTools' -Author 'Jane Doe' ``` -This is the fastest way to get started if you're exploring. Every value can be overridden later by editing the generated files. - -### The scripted way - -For repeatable scaffolding (or CI-driven project creation), pass parameters directly: +For repeatable scaffolding, pass parameters directly: ```powershell $Params = @{ - Name = 'NetworkTools' - DestinationPath = '~/Projects' - Author = 'Jane Doe' - Description = 'Cmdlets for network diagnostics and monitoring.' - CompanyName = 'Contoso' - CIProvider = 'GitHub' - License = 'MIT' - MinPowerShellVersion = '7.2' - CompatiblePSEditions = @('Core') - Tags = @('Network', 'Diagnostics') - IncludeDocs = $true - GitInit = $true + Name = 'NetworkTools' + DestinationPath = '~/Projects' + Author = 'Jane Doe' + CIProvider = 'GitHub' + License = 'MIT' + GitInit = $true } New-AnvilModule @Params ``` -Without `-Interactive`, Anvil applies defaults silently for any optional parameters not specified. `-Name`, `-DestinationPath`, and `-Author` are required. - -### What happens next - -Anvil creates a `NetworkTools/` directory with the full project structure, prints a summary, and (if `-GitInit` was set) commits everything. You'll see output like: - -``` -[Anvil] Creating project: NetworkTools -[Anvil] Destination: ~/Projects/NetworkTools -[Anvil] Base template: 34 files -[Anvil] CI (GitHub): 2 files - -[Anvil] Project 'NetworkTools' scaffolded successfully! -[Anvil] Next steps: - cd ~/Projects/NetworkTools - ./build/bootstrap.ps1 - Invoke-Build -File ./build/module.build.ps1 -``` +Without `-Interactive`, Anvil applies defaults silently for any optional parameters not specified. ## First build ```powershell cd ~/Projects/NetworkTools -./build/bootstrap.ps1 -Invoke-Build -File ./build/module.build.ps1 +Invoke-AnvilBootstrapDeps +Invoke-AnvilBuild ``` -The bootstrap script uses [ModuleFast](https://github.com/JustinGrote/ModuleFast) to install pinned versions of InvokeBuild, Pester, PSScriptAnalyzer, and platyPS into your user module path. This takes a few seconds on first run and is near-instant on subsequent runs. - -The build pipeline then runs: Clean, Validate, Format, Lint, Test, Docs, Build, IntegrationTest, Package. +The bootstrap installs pinned versions of the build toolchain via [ModuleFast](https://github.com/JustinGrote/ModuleFast). `Invoke-AnvilBuild` runs the full pipeline: format, lint, test, compile, and package. -The scaffolded project comes with a sample public function (`Get-Greeting`), a sample private function (`Format-GreetingText`), a sample class (`GreetingBuilder`), and tests for all three. The first build should pass out of the box — if it doesn't, that's a bug in Anvil. +The scaffolded project comes with sample functions, a sample class, and tests for all of them. The first build should pass out of the box. ## What to do next -At this point you have a working module with sample code and a green build. Read [Development](development.md) to learn the day-to-day workflow — adding functions, managing dependencies, running tests, and building. - -Other useful references: +At this point you have a working module with sample code and a green build. The scaffolded project includes a README with the development workflow, project layout, and conventions. Open it to learn how to add functions, manage dependencies, write tests, and run individual build tasks. -- [Project Structure](project-structure.md) — what every file and directory does -- [Build Pipeline](build-pipeline.md) — every build task explained -- [CI/CD Integration](cicd-integration.md) — setting up GitHub Actions, Azure Pipelines, or GitLab CI +For reference documentation on Anvil's build system, CI/CD setup, and customization, see the [Reference](reference.md) guide. diff --git a/docs/index.md b/docs/index.md index 83affb8..2c53c96 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,26 +2,6 @@ Anvil is a PowerShell module scaffolder. It generates complete module projects with build pipelines, test infrastructure, linting, documentation, and CI/CD workflows so you can start writing code immediately instead of wiring up tooling. -## Why Anvil? - -Setting up a PowerShell module project properly is tedious. You need InvokeBuild tasks, Pester configuration, PSScriptAnalyzer settings, code coverage, CI workflows, a bootstrap script, a compiled module build, and a publishing pipeline. You either copy-paste from your last project (inheriting its mistakes) or spend an afternoon getting it right. - -Anvil does this in one command. The generated project follows community best practices: compiled modules for fast loading, separate source and build artifacts, automated formatting, and version injection from CI tags. Everything is opinionated but configurable. - -## What you get - -A scaffolded project includes: - -- **Module source** with Public/Private/PrivateClasses layout and an `Imports.ps1` for module-scoped initialization -- **InvokeBuild pipeline** with Format, Lint, Test, Docs, Build, IntegrationTest, Package, and Publish tasks -- **Pester 5 tests** with unit test scaffolds for the sample functions and post-build integration tests that validate the compiled output -- **PSScriptAnalyzer** configuration with formatting rules and custom analyzers that catch common mistakes (nested functions, process blocks without pipeline parameters, smart quotes) -- **platyPS documentation** generation with a generate-once, update-on-subsequent-builds workflow -- **CI/CD workflows** for GitHub Actions, Azure Pipelines, or GitLab CI with tag-triggered releases -- **ModuleFast bootstrap** that installs build dependencies from a pinned manifest with zero prerequisite tooling - -After scaffolding, Anvil provides commands to add functions, classes, tests, and module dependencies to the project without leaving the terminal. - ## Quick start ```powershell @@ -33,26 +13,10 @@ See [Getting Started](getting-started.md) for the full walkthrough. ## Documentation -| Guide | What it covers | -|-------|---------------| +| | | +|---|---| | [Getting Started](getting-started.md) | Scaffold a project, bootstrap, first build | -| [Development](development.md) | Adding functions, classes, dependencies, testing, the daily workflow | -| [Project Structure](project-structure.md) | What every file and directory does | -| [Build Pipeline](build-pipeline.md) | Every build task explained, settings reference | -| [CI/CD Integration](cicd-integration.md) | GitHub Actions, Azure Pipelines, GitLab CI setup | -| [Customization](customization.md) | Custom lint rules, types, formats, build tasks | -| [FAQ](faq.md) | Common issues and troubleshooting | - -## Command reference - -| Command | Purpose | -|---------|---------| -| [New-AnvilModule](commands/New-AnvilModule.md) | Scaffold a new module project | -| [New-AnvilFunction](commands/New-AnvilFunction.md) | Add a function and its test to a project | -| [New-AnvilClass](commands/New-AnvilClass.md) | Add a PowerShell class and its test | -| [New-AnvilTest](commands/New-AnvilTest.md) | Add a standalone test file | -| [Add-AnvilDependency](commands/Add-AnvilDependency.md) | Declare a module dependency | -| [Remove-AnvilDependency](commands/Remove-AnvilDependency.md) | Remove a module dependency | -| [Invoke-AnvilBootstrapDeps](commands/Invoke-AnvilBootstrapDeps.md) | Install build tools and module dependencies | -| [Import-AnvilModule](commands/Import-AnvilModule.md) | Import the development module from the current project | -| [Get-AnvilTemplate](commands/Get-AnvilTemplate.md) | List available templates and CI providers | +| [Reference](reference.md) | Project structure, build pipeline, CI/CD, customization | +| [Template Authoring](template-authoring.md) | Creating custom templates | +| [Command Reference](commands/) | Detailed help for all commands | +| [FAQ](faq.md) | Troubleshooting | diff --git a/docs/project-structure.md b/docs/project-structure.md deleted file mode 100644 index 39fc140..0000000 --- a/docs/project-structure.md +++ /dev/null @@ -1,171 +0,0 @@ -# Project Structure - -This page explains what Anvil generates and why each piece exists. Understanding the structure helps when you need to customize or debug the build. - -## Overview - -``` -MyModule/ -├── .github/workflows/ CI/CD workflows (if CIProvider is GitHub) -├── src/MyModule/ Module source code -│ ├── MyModule.psd1 Module manifest -│ ├── MyModule.psm1 Module loader (dot-sources everything) -│ ├── Imports.ps1 Module-scoped variables and setup -│ ├── PrivateClasses/ PowerShell classes -│ ├── Public/ Exported functions (one per file) -│ └── Private/ Internal helper functions (one per file) -├── requirements.psd1 Module dependencies (managed by Add-AnvilDependency) -├── build/ -│ ├── module.build.ps1 InvokeBuild task definitions -│ ├── bootstrap.ps1 ModuleFast dependency installer -│ ├── build.settings.psd1 Module name and coverage threshold -│ ├── build.requires.psd1 Anvil build toolchain versions -│ └── analyzers/ Custom PSScriptAnalyzer rules -├── tests/ -│ ├── unit/ Pester 5 unit tests -│ │ ├── MyModule.Module.Tests.ps1 -│ │ ├── Public/ -│ │ ├── Private/ -│ │ └── PrivateClasses/ -│ └── integration/ Post-build validation tests -├── docs/ Documentation -│ └── commands/ platyPS command reference (generated) -├── PSScriptAnalyzerSettings.psd1 -├── .editorconfig -├── .vscode/ -├── .gitignore -├── CONTRIBUTING.md -├── LICENSE -└── README.md -``` - -## Module source - -### The manifest (`MyModule.psd1`) - -The module manifest declares metadata (author, description, version, tags) and runtime properties (PowerShell version, compatible editions, required modules). During development, `FunctionsToExport` is commented out — the Build task generates this automatically from the files in `Public/`. - -The source version is always `0.0.0`. This is a placeholder. Real versions are injected at build time (see [Build Pipeline](build-pipeline.md#version-management)). - -### The module loader (`MyModule.psm1`) - -During development, the `.psm1` dot-sources files in a specific order: - -1. **`Imports.ps1`** — module-scoped variables and initialization -2. **`PrivateClasses/*.ps1`** — classes, loaded first because functions may depend on them -3. **`Public/*.ps1`** — exported functions -4. **`Private/*.ps1`** — internal helpers - -It exports only the Public functions. During compilation, this file is replaced with a single merged `.psm1`. - -### Imports.ps1 - -Runs before any classes or functions load. Use it for `$script:` variables, assembly loading, or other module-wide initialization. See [Development > Module initialization](development.md#module-initialization-importsps1) for details and examples. - -### Public, Private, PrivateClasses - -The convention is one function or class per file, with the filename matching the function/class name. All three directories support nested subdirectories — the module loader discovers `.ps1` files recursively. - -- **Public** functions are exported and visible to users after `Import-Module` -- **Private** functions are loaded but not exported — they're internal helpers -- **PrivateClasses** are loaded before functions so they can be used by both Public and Private code - -## Build system - -### bootstrap.ps1 - -A self-contained script that installs [ModuleFast](https://github.com/JustinGrote/ModuleFast) (if not present) and then uses it to install build tools from `build.requires.psd1` and module dependencies from `requirements.psd1`. It requires PowerShell 7.2+ because ModuleFast does. - -Modules are installed to the user-scoped module path, not globally. The bootstrap is safe to run repeatedly — it's fast when dependencies are already installed. - -You can also run it via `Invoke-AnvilBootstrapDeps` from anywhere inside the project. - -### build.requires.psd1 - -Declares Anvil's build toolchain versions grouped by scope. This file is for build tools only — module dependencies belong in `requirements.psd1`. - -```powershell -@{ - Build = @{ - 'InvokeBuild' = '5.12.1' - 'PSScriptAnalyzer' = '1.23.0' - } - Test = @{ - 'Pester' = '5.7.1' - } - Docs = @{ - 'platyPS' = '0.14.2' - } -} -``` - -Install selectively with `./build/bootstrap.ps1 -Scope Build,Test`. Versions are pinned for reproducible builds — update them deliberately, not accidentally. - -### requirements.psd1 - -Declares module dependencies that your module needs at runtime. Managed by `Add-AnvilDependency` and `Remove-AnvilDependency`: - -```powershell -@{ - 'Az.Storage' = '>=5.0.0' - 'ImportExcel' = '7.8.6' -} -``` - -The bootstrap installs these alongside the build tools. The Build task reads this file and populates `RequiredModules` in the published manifest automatically. - -### build.settings.psd1 - -User-editable build configuration. Controls module name, test output, linting strictness, documentation generation, and more. See [Build Pipeline > Build settings](build-pipeline.md#build-settings) for the full list of available settings. - -### build.settings_DEFAULTS_DO_NOT_EDIT.psd1 - -Default values for all build settings. Do not edit this file — it serves as a fallback when settings are missing or invalid in `build.settings.psd1`. The build script merges both files at startup: your settings take precedence, invalid or missing values fall back to defaults with a warning. - -### module.build.ps1 - -The InvokeBuild task graph. See [Build Pipeline](build-pipeline.md) for a detailed explanation of every task. - -### analyzers/ - -Custom PSScriptAnalyzer rules shipped with the project. The Lint task automatically discovers every `.psm1` file in this directory and loads it as a rule source. You can add your own rules by dropping files here and disable any rule (built-in or custom) via `ExcludeRules` in `PSScriptAnalyzerSettings.psd1`. - -## Tests - -The test structure mirrors the source structure: - -| Source | Tests | -|--------|-------| -| `Public/Get-Widget.ps1` | `tests/unit/Public/Get-Widget.Tests.ps1` | -| `Private/Format-Row.ps1` | `tests/unit/Private/Format-Row.Tests.ps1` | -| `PrivateClasses/MyClass.ps1` | `tests/unit/PrivateClasses/MyClass.Tests.ps1` | - -**Unit tests** (`tests/unit/`) test source code directly by importing the module from `src/`. Public function tests call functions by name. Private function and class tests use `InModuleScope` to reach inside the module. - -**Integration tests** (`tests/integration/`) run after the Build task and validate that the compiled module was built correctly and can be imported. - -Each test file imports the module in `BeforeAll` and cleans up in `AfterAll`. - -## Configuration files - -**`PSScriptAnalyzerSettings.psd1`** — linter rules and formatting configuration. Controls brace style (OTBS), indentation (4 spaces), whitespace rules, and which rules to exclude. - -**`.editorconfig`** — editor-agnostic formatting (indent style, line endings, trailing whitespace). Respected by VS Code, JetBrains, and most other editors. - -**`.vscode/`** — VS Code workspace settings (PowerShell extension configuration), build tasks (Bootstrap, Build, Lint, Test, Coverage), and recommended extensions. - -## Build output - -After a successful build, `artifacts/` contains: - -``` -artifacts/ -├── package/MyModule/ Staged module (ready to publish) -│ ├── MyModule.psd1 Clean manifest with explicit exports -│ ├── MyModule.psm1 Compiled single-file module -│ └── en-US/ MAML help (generated from docs/commands/) -├── testResults/ NUnit XML + JaCoCo coverage XML -└── archive/ ZIP of the staged module -``` - -The `en-US/` directory contains generated MAML help for `Get-Help` support. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..e430e23 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,254 @@ +# Reference + +## Project structure + +``` +MyModule/ +├── src/MyModule/ Module source code +│ ├── MyModule.psd1 Module manifest +│ ├── MyModule.psm1 Module loader +│ ├── Imports.ps1 Module-scoped variables and setup +│ ├── PrivateClasses/ PowerShell classes +│ ├── Public/ Exported functions (one per file) +│ └── Private/ Internal helper functions (one per file) +├── requirements.psd1 Module dependencies (managed by Add-AnvilDependency) +├── build/ +│ ├── module.build.ps1 InvokeBuild task definitions +│ ├── bootstrap.ps1 ModuleFast dependency installer +│ ├── build.settings.psd1 Build configuration +│ ├── build.requires.psd1 Build toolchain versions +│ └── analyzers/ Custom PSScriptAnalyzer rules +├── tests/ +│ ├── unit/ Pester 5 unit tests +│ └── integration/ Post-build validation tests +├── docs/ Documentation +│ └── commands/ platyPS command reference (generated) +├── PSScriptAnalyzerSettings.psd1 +├── .editorconfig +├── .vscode/ +├── .gitignore +├── CONTRIBUTING.md +├── LICENSE +└── README.md +``` + +### Module source + +The **manifest** (`MyModule.psd1`) declares metadata and runtime properties. The source version is always `0.0.0` — real versions are injected at build time. + +The **module loader** (`MyModule.psm1`) dot-sources files in order: `Imports.ps1`, then `PrivateClasses/`, `Public/`, and `Private/`. At build time, these are compiled into a single `.psm1`. + +**`Imports.ps1`** runs before any classes or functions load. Use it for `$script:` variables, assembly loading, or other module-wide initialization. + +**`Public/`**, **`Private/`**, and **`PrivateClasses/`** each support nested subdirectories. The module loader discovers `.ps1` files recursively. + +### Build system + +**`bootstrap.ps1`** installs [ModuleFast](https://github.com/JustinGrote/ModuleFast) and uses it to install build tools from `build.requires.psd1` and module dependencies from `requirements.psd1`. Modules are installed to the user-scoped module path. + +**`build.requires.psd1`** declares build tool versions grouped by scope (Build, Test, Docs). Versions are pinned for reproducible builds. + +**`requirements.psd1`** declares runtime module dependencies. Managed by `Add-AnvilDependency` and `Remove-AnvilDependency`. + +**`build.settings.psd1`** contains user-editable build configuration. Invalid or missing settings fall back to defaults from `build.settings_DEFAULTS_DO_NOT_EDIT.psd1`. + +**`analyzers/`** contains custom PSScriptAnalyzer rules. The Lint task loads every `.psm1` file in this directory automatically. + +### Tests + +Unit tests in `tests/unit/` mirror the source structure. Integration tests in `tests/integration/` validate the compiled module after the Build task. + +### Configuration files + +**`PSScriptAnalyzerSettings.psd1`** controls linter rules, formatting style, and rule exclusions. + +**`.editorconfig`** sets editor-agnostic formatting (indent style, line endings, trailing whitespace). + +**`.vscode/`** includes workspace settings, build tasks, and recommended extensions for VS Code. + +## Build pipeline + +The build is powered by [InvokeBuild](https://github.com/nightroman/Invoke-Build). All tasks are defined in `build/module.build.ps1`. + +### Pipelines + +The default pipeline runs everything except publishing: + +``` +. (default) → Clean, Validate, Format, Lint, Test, Build, IntegrationTest, Package +``` + +If the project was scaffolded with `-IncludeDocs`, a Docs task is included between Test and Build. + +The release pipeline adds Version at the start and Publish at the end: + +``` +Release → Version, ., Publish +``` + +### Running the build + +```powershell +Invoke-AnvilBuild # full pipeline +Invoke-AnvilBuild -Task Lint, Test # fast feedback +Invoke-AnvilBuild -Task Release -NewVersion 1.0.0 # release build +``` + +### Build settings + +All settings live in `build/build.settings.psd1`: + +| Setting | Default | What it controls | +|---------|---------|-----------------| +| `ModuleName` | *(your module)* | Module to build | +| `CoverageThreshold` | `80` | Minimum code coverage percentage (0 to disable) | +| `IncludeDocs` | `$true` or `$false` | Whether the Docs task runs | +| `TestOutputFormat` | `'NUnitXml'` | Test result format for CI | +| `TestVerbosity` | `'Detailed'` | Pester output verbosity | +| `LintFailOn` | `@('Warning', 'Error')` | Severity levels that fail the build | +| `AssetDirectories` | `@('Types', 'Formats', 'Assemblies')` | Extra directories copied to the staged module | + +### Task reference + +**Clean** — deletes and recreates `artifacts/` so every build starts clean. + +**Validate** — checks that the module manifest and `.psm1` exist and are valid. + +**Format** — runs `Invoke-Formatter` on all `.ps1` files using the rules from `PSScriptAnalyzerSettings.psd1`. Modifies files in place. + +**Lint** — runs `Invoke-ScriptAnalyzer` with project settings and custom rules from `build/analyzers/`. Fails the build if issues matching `LintFailOn` severities are found. + +**Test** — runs Pester 5 unit tests with code coverage. Fails if any test fails or coverage drops below `CoverageThreshold`. + +**Docs** — generates and updates platyPS markdown documentation in `docs/commands/`. Skips if `IncludeDocs` is `$false` or platyPS is not installed. + +**Build** — compiles all source files into a single `.psm1`, generates a clean manifest with `FunctionsToExport`, copies assets, and generates MAML help. + +**IntegrationTest** — runs tests from `tests/integration/` against the compiled output. + +**Package** — creates a ZIP archive of the staged module. + +**Version** — reports the current version and what `-NewVersion` or `-Prerelease` will apply. + +**Publish** — publishes to the PowerShell Gallery using `Publish-PSResource`. Requires the `PSGALLERY_API_KEY` environment variable. Refuses to publish version `0.0.0`. + +**DevCC** — generates a Coverage Gutters-compatible `coverage.xml` for VS Code inline coverage. Does not enforce the threshold. + +### Custom PSScriptAnalyzer rules + +The rules that ship with Anvil projects: + +| Rule | What it catches | +|------|----------------| +| AvoidProcessWithoutPipeline | `process` block in a function that doesn't accept pipeline input | +| AvoidNestedFunctions | Function definitions inside other functions | +| AvoidSmartQuotes | Curly/smart quote characters | +| AvoidEmptyNamedBlocks | Empty `begin`, `process`, `end`, or `dynamicparam` blocks | +| AvoidNewObjectPSObject | `New-Object PSObject` instead of `[PSCustomObject]@{}` | +| AvoidWriteOutput | Unnecessary `Write-Output` | + +To disable a rule, add it to `ExcludeRules` in `PSScriptAnalyzerSettings.psd1`. To add your own, drop a `.psm1` file in `build/analyzers/`. + +### Version management + +The source manifest always contains version `0.0.0`. Versions are injected at build time, not maintained in source. In CI, the version comes from the git tag: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +# CI runs: Invoke-Build -Task Release -NewVersion 1.0.0 +``` + +For prerelease labels: + +```powershell +Invoke-Build -Task Release -NewVersion 1.0.0 -Prerelease beta1 +``` + +### Types and formatting + +PowerShell supports custom type extensions and formatting views via `.ps1xml` files. Create them in `src/MyModule/Types/` and `src/MyModule/Formats/`, then reference them in the module manifest: + +```powershell +TypesToProcess = @('Types/MyModule.Types.ps1xml') +FormatsToProcess = @('Formats/MyModule.Format.ps1xml') +``` + +The Build task copies these directories to the staged module and carries the manifest properties through. + +### Adding build tasks + +Add tasks to `build/module.build.ps1`: + +```powershell +task Deploy { + Write-BuildHeader 'Deploy' + # your deployment logic + Write-BuildFooter 'Deploy complete' +} +``` + +Run standalone or add to a composite task. Modifying the build script means your pipeline has diverged from Anvil's default — future updates will require manual merging. + +## CI/CD integration + +Anvil generates CI/CD workflows that run the full pipeline on push/PR and publish on tagged releases. + +### How releases work + +All providers follow the same pattern: + +1. Push and merge as normal. CI runs the default pipeline on every push. +2. Tag a commit when ready to release: `git tag v1.0.0 && git push origin v1.0.0` +3. The release workflow extracts the version from the tag, passes it as `-NewVersion`, and runs the Release pipeline including Publish. + +The source manifest is never modified. The version exists only during the CI build. + +### GitHub Actions + +| File | Trigger | Purpose | +|------|---------|---------| +| `.github/workflows/ci.yml` | Push/PR to main | Default pipeline | +| `.github/workflows/release.yml` | Tags matching `v*` | Build + publish | + +**Setup:** Create a `psgallery` environment in Settings > Environments and add `PSGALLERY_API_KEY` as an environment secret. + +### Azure Pipelines + +| File | Trigger | Purpose | +|------|---------|---------| +| `azure-pipelines.yml` | Push/PR | CI pipeline | +| `azure-pipelines-release.yml` | Tags matching `v*` | Release pipeline | + +**Setup:** Create pipelines from both YAML files. Add `PSGALLERY_API_KEY` as a secret variable on the release pipeline. The `psgallery` environment is created automatically on first run — add approval checks under Pipelines > Environments if needed. + +### GitLab CI + +| File | Stages | Purpose | +|------|--------|---------| +| `.gitlab-ci.yml` | ci, publish | Combined CI and release | + +The publish stage runs only for `v*` tags. + +**Setup:** Create a `psgallery` environment under Operate > Environments. Add `PSGALLERY_API_KEY` as a protected, masked variable scoped to the environment. Add `v*` as a protected tag pattern. + +GitLab CI uses `mcr.microsoft.com/powershell:lts-ubuntu-22.04` on Linux. A Windows job is included but commented out (requires a self-hosted runner). + +### Testing CI locally + +```powershell +Invoke-AnvilBootstrapDeps +Invoke-AnvilBuild -Task Release -NewVersion 1.0.0-local +``` + +The Publish task will fail without an API key, but everything else runs. + +### Adding CI to an existing project + +If you scaffolded with `-CIProvider None`, scaffold a throwaway project with the desired provider and copy the workflow files: + +```powershell +New-AnvilModule -Name 'Temp' -DestinationPath $env:TEMP -Author 'x' -CIProvider GitHub +``` + +Then copy `.github/workflows/` (or equivalent) into your project. diff --git a/docs/template-authoring.md b/docs/template-authoring.md new file mode 100644 index 0000000..54956e2 --- /dev/null +++ b/docs/template-authoring.md @@ -0,0 +1,357 @@ +# Template Authoring Guide + +## Quick start + +Create a directory with a manifest and some template files: + +``` +MyTemplate/ + template.psd1 + README.md.tmpl + src/ + __Name__/ + __Name__.ps1.tmpl +``` + +Write the manifest: + +```powershell +@{ + Name = 'MyTemplate' + Description = 'A minimal project template' + Version = '1.0.0' + + Parameters = @( + @{ Name = 'Name'; Type = 'string'; Required = $true; Prompt = 'Project name' } + @{ Name = 'Author'; Type = 'string'; Required = $true; Prompt = 'Author' } + ) +} +``` + +Write a template file (`README.md.tmpl`): + +``` +# <%Name%> + +By <%Author%> +``` + +Scaffold from it: + +```powershell +New-AnvilModule -Name 'MyProject' -DestinationPath . -Template 'C:\path\to\MyTemplate' -Author 'Jane' +``` + +This produces: + +``` +MyProject/ + README.md # contains "# MyProject" and "By Jane" + src/ + MyProject/ + MyProject.ps1 +``` + +## Token syntax + +Templates use two token formats: + +### Content tokens: `<%TokenName%>` + +Used inside `.tmpl` file content. Replaced with the parameter or auto-token value during scaffolding. + +```powershell +# <%Name%>.psm1 +# Author: <%Author%> +``` + +Content tokens use literal string replacement, not regex. Values containing special characters are handled safely. + +### Path tokens: `__TokenName__` + +Used in file and directory names. Replaced during scaffolding. + +``` +src/__Name__/__Name__.psd1.tmpl +``` + +Becomes: + +``` +src/MyModule/MyModule.psd1 +``` + +### Processing order + +When a `.tmpl` file is processed, sections are resolved first, then token replacement runs on whatever content remains. This means section bodies can contain `<%Token%>` placeholders: + +``` +<%#section Greeting%> +Hello, <%Author%>! Welcome to <%Name%>. +<%#endsection%> +``` + +If the section is kept, the tokens are replaced normally. If the section is removed, the tokens inside it are never evaluated — they're discarded along with the section body. + +### Template vs static files + +Files with a `.tmpl` extension are processed for content token replacement. The `.tmpl` suffix is stripped from the output filename. + +Files without `.tmpl` are copied verbatim (binary-safe). No token replacement occurs in their content. Path tokens in their names are still replaced. + +## Manifest reference + +The manifest is a PowerShell data file (`template.psd1`) at the root of the template directory. + +### Metadata + +Every manifest requires `Name`, `Description`, and `Version`: + +```powershell +@{ + Name = 'Module' + Description = 'PowerShell module with build pipeline and tests' + Version = '1.0.0' +} +``` + +### Parameters + +An ordered array of parameter declarations. The order determines the interactive prompt sequence. + +```powershell +Parameters = @( + @{ + Name = 'Name' # Token name (used as <%Name%> and __Name__) + Type = 'string' # Parameter type (see below) + Required = $true # Whether a value must be provided + Prompt = 'Module name' # Text shown during interactive prompts + Default = $null # Default value when not provided + DefaultFrom = 'GitUserName' # Named default resolver (see below) + Validate = '^\w+$' # Regex pattern for validation (string type) + ValidateMessage = 'Alpha only.' # Error message when validation fails + Choices = @('A', 'B', 'C') # Valid options (choice type only) + Range = @(0, 100) # Min/max bounds (int type only) + Format = 'raw' # Output formatter (see below) + } +) +``` + +`Name`, `Type`, and `Prompt` are required. All other fields are optional. + +#### Parameter types + +| Type | Interactive behavior | Validation | Default format | +|---|---|---|---| +| `string` | Text prompt | Optional regex | `raw` | +| `choice` | Selection from `Choices` list | Must be in `Choices` | `raw` | +| `bool` | y/n prompt | Boolean | `lower-string` | +| `int` | Numeric prompt | Optional `Range` bounds | `raw` | +| `csv` | Comma-separated text, split into array | None | `raw` | +| `uri` | Text prompt with URI validation and retry | Absolute URI | `raw` | + +#### DefaultFrom resolvers + +Named resolvers that derive default values from the environment: + +| Resolver | Value | +|---|---| +| `GitUserName` | `git config user.name` | +| `CurrentDirectory` | Current working directory path | + +#### Formatters + +Formatters transform parameter values into strings for token replacement. Declared via the `Format` field. + +| Formatter | Input | Output | Use case | +|---|---|---|---| +| `raw` | Any | `.ToString()` | Most parameters | +| `psd1-array` | String array | `@('a', 'b')` or `@()` | Embedding arrays in `.psd1` files | +| `lower-string` | Boolean | `'true'` or `'false'` | Boolean values in config files | +| `quoted` | String | `'value'` | Values needing single quotes | + +### Auto-tokens + +Some tokens are computed by the engine rather than provided by the user. A module manifest needs a GUID, a license file needs the current year — these shouldn't be prompted for. + +```powershell +AutoTokens = @( + @{ Name = 'ModuleGuid'; Source = 'NewGuid' } + @{ Name = 'Year'; Source = 'CurrentYear' } +) +``` + +| Source | Value | +|---|---| +| `NewGuid` | A new random GUID string | +| `CurrentYear` | Four-digit year (e.g. `2026`) | +| `CurrentDate` | Date in `yyyy-MM-dd` format | + +### Conditions + +Not every file belongs in every scaffolded project. A project with `License = 'None'` shouldn't include a LICENSE file. A project without docs shouldn't include documentation templates. + +Conditions control which files are included or excluded based on parameter values. + +#### ExcludeWhen + +Exclude a file when the condition matches: + +```powershell +ExcludeWhen = @{ + 'LICENSE.tmpl' = @{ License = 'None' } +} +``` + +This skips `LICENSE.tmpl` when `License` equals `'None'`. + +#### IncludeWhen + +Include a file only when the condition matches: + +```powershell +IncludeWhen = @{ + 'docs/*' = @{ IncludeDocs = 'true' } +} +``` + +This only processes files under `docs/` when `IncludeDocs` equals `'true'`. + +#### Condition keys and values + +- Keys are wildcard patterns matched against file paths using `-like` +- Values are hashtables of `{ ParameterName = 'expected value' }` +- Multiple values for a key means OR: `@{ License = 'MIT', 'Apache2' }` +- Multiple keys in a condition means AND: `@{ A = 'x'; B = 'y' }` +- Conditions match against **formatted** token values (after formatters are applied) +- Files not matched by any pattern are always included + +#### Precedence + +`ExcludeWhen` is evaluated before `IncludeWhen`. If a file matches both, it is excluded. + +### Sections + +Conditions work at the file level — include or exclude an entire file. Sections handle the case where part of a file should vary based on a parameter, but the rest of the file is always needed. + +The built-in Module template uses sections to conditionally include the platyPS documentation task in the build script. When `IncludeDocs` is false, the Docs task definition, the MAML help build step, and the Docs entry in the composite task line are all removed — but the rest of the build script stays intact. + +#### Markers + +``` +<%#section SectionName%> +Content that may or may not appear in the output. +<%#endsection%> +``` + +- Markers must appear on their own line +- Marker lines are always stripped from the output (whether the section is kept or removed) +- Sections cannot nest + +#### Section conditions + +Declared in the manifest using the same condition format as file conditions: + +```powershell +Sections = @{ + DocsTask = @{ + IncludeWhen = @{ IncludeDocs = 'true' } + } + LicenseBadge = @{ + ExcludeWhen = @{ License = 'None' } + } +} +``` + +Each section must have exactly one of `IncludeWhen` or `ExcludeWhen`. + +A section marker in a template file without a corresponding manifest entry causes an error. + +#### Mutually exclusive sections + +When a line has two variants, use a pair of sections with opposite conditions: + +```powershell +Sections = @{ + DocsComposite = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + NoDocsComposite = @{ ExcludeWhen = @{ IncludeDocs = 'true' } } +} +``` + +In the template file: + +``` +<%#section DocsComposite%> +task . Clean, Validate, Format, Lint, Test, Docs, Build, IntegrationTest, Package +<%#endsection%> +<%#section NoDocsComposite%> +task . Clean, Validate, Format, Lint, Test, Build, IntegrationTest, Package +<%#endsection%> +``` + +Exactly one of the two blocks appears in the output, depending on `IncludeDocs`. + +### Layers + +A template might support multiple variants of the same concern. The Module template supports GitHub, Azure Pipelines, and GitLab CI — each with its own workflow files. Rather than bundling all three and conditionally excluding two, layers let each variant live in its own directory. The parameter value selects which one to apply. + +```powershell +Layers = @( + @{ + PathKey = 'CIProvider' # Parameter that selects the layer + BasePath = 'CI' # Directory containing layer subdirectories + Skip = 'None' # Value that means "no layer" + } +) +``` + +Given `CIProvider = 'GitHub'`, the engine processes the directory at `CI/GitHub/` after the base template. Layer directories sit alongside the template directory (as siblings, not children). + +Layer files use the same token replacement and are merged into the output. + +## Directory structure + +A typical template layout: + +``` +Templates/ + MyTemplate/ + template.psd1 # Manifest + README.md.tmpl # Processed for tokens + .gitignore # Copied verbatim + src/ + __Name__/ # Directory name has path token + __Name__.psd1.tmpl + __Name__.psm1.tmpl + CI/ # Layer directory (sibling) + GitHub/ + .github/ + workflows/ + ci.yml.tmpl + GitLab/ + .gitlab-ci.yml.tmpl +``` + +The `template.psd1` file itself is automatically excluded from the output. + +## Scaffolding from a custom template + +### By path + +```powershell +New-AnvilModule -Name 'MyProject' -DestinationPath . -Template 'C:\templates\MyTemplate' -Interactive +``` + +### By name (bundled templates) + +```powershell +New-AnvilModule -Name 'MyProject' -DestinationPath . -Template 'Module' +``` + +### Discovering templates + +```powershell +Get-AnvilTemplate +``` + +Returns template metadata including description, version, parameters, and available layers. diff --git a/src/Anvil/Private/Assert-ManifestConfiguration.ps1 b/src/Anvil/Private/Assert-ManifestConfiguration.ps1 new file mode 100644 index 0000000..7abbcd4 --- /dev/null +++ b/src/Anvil/Private/Assert-ManifestConfiguration.ps1 @@ -0,0 +1,96 @@ +function Assert-ManifestConfiguration { + <# + .SYNOPSIS + Validates resolved parameter values against manifest declarations. + + .DESCRIPTION + Walks the manifest's Parameters array and validates each resolved + value against the declared rules: + + - Required parameters must be non-empty. + - String parameters with a Validate regex must match. + - Choice parameters must be in the Choices array. + - Int parameters with a Range must be within bounds. + - Uri parameters must be valid absolute URIs (when non-empty). + - Version-format strings are validated when a version regex + is declared. + + All violations are collected and thrown as a single error so the + caller sees every problem at once. + + .PARAMETER Manifest + The template manifest hashtable (from Read-TemplateManifest). + + .PARAMETER Configuration + Hashtable of resolved parameter values (from Invoke-ManifestPrompt). + + .OUTPUTS + None. Throws on validation failure. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Manifest, + + [Parameter(Mandatory)] + [hashtable]$Configuration + ) + + $Errors = [System.Collections.Generic.List[string]]::new() + + foreach ($Param in $Manifest.Parameters) { + $Name = $Param.Name + $Value = $Configuration[$Name] + + if ($Param.Required) { + $IsEmpty = $false + if ($null -eq $Value) { $IsEmpty = $true } + elseif ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { $IsEmpty = $true } + if ($IsEmpty) { + $Errors.Add("'$Name' is required and must not be empty.") + continue + } + } + + if ($null -eq $Value -or ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value))) { + continue + } + + if ($Param.ContainsKey('Validate') -and -not [string]::IsNullOrWhiteSpace($Param.Validate)) { + $StringValue = if ($Value -is [string]) { $Value } else { "$Value" } + if ($StringValue -notmatch $Param.Validate) { + $Message = if ($Param.ContainsKey('ValidateMessage')) { + $Param.ValidateMessage + } else { + "'$Name' value '$StringValue' does not match required format." + } + $Errors.Add($Message) + } + } + + if ($Param.ContainsKey('Choices') -and $Param.Choices.Count -gt 0) { + if ($Value -notin $Param.Choices) { + $Errors.Add("'$Name' must be one of: $($Param.Choices -join ', '). Got '$Value'.") + } + } + + if ($Param.ContainsKey('Range')) { + $Range = @($Param.Range) + if ($Value -lt $Range[0] -or $Value -gt $Range[1]) { + $Errors.Add("'$Name' must be between $($Range[0]) and $($Range[1]). Got $Value.") + } + } + + if ($Param.Type -eq 'uri' -and -not [string]::IsNullOrWhiteSpace($Value)) { + $Parsed = $Value -as [System.Uri] + if (-not $Parsed -or -not $Parsed.IsAbsoluteUri) { + $Errors.Add("'$Name' must be a valid absolute URI. Got '$Value'.") + } + } + } + + if ($Errors.Count -gt 0) { + $Message = "Configuration validation failed:`n - " + ($Errors -join "`n - ") + throw $Message + } +} diff --git a/src/Anvil/Private/Assert-TemplateManifest.ps1 b/src/Anvil/Private/Assert-TemplateManifest.ps1 new file mode 100644 index 0000000..244dbb3 --- /dev/null +++ b/src/Anvil/Private/Assert-TemplateManifest.ps1 @@ -0,0 +1,203 @@ +function Assert-TemplateManifest { + <# + .SYNOPSIS + Validates the structure of a template manifest hashtable. + + .DESCRIPTION + Checks a hashtable loaded from template.psd1 against the expected + schema. All violations are collected and thrown as a single error + so the caller sees every problem at once. + + Validates: + - Required top-level keys: Name, Description, Version, Parameters + - Each parameter entry has Name, Type, and Prompt + - Type is one of: string, choice, bool, int, csv, uri + - Choice parameters have a non-empty Choices array + - Range (if present) is a two-element numeric array + - Format (if present) is a recognized formatter name + - DefaultFrom (if present) is a recognized resolver name + - Validate (if present) is a compilable regex + - Parameter names are unique across Parameters and AutoTokens + - AutoToken entries have Name and Source with valid source names + - Section entries have exactly one of IncludeWhen or ExcludeWhen + - Layer entries have PathKey and BasePath + + .PARAMETER Manifest + The hashtable to validate. + + .OUTPUTS + None. Throws on validation failure. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Manifest + ) + + $ValidTypes = @('string', 'choice', 'bool', 'int', 'csv', 'uri') + $ValidFormatters = @('raw', 'psd1-array', 'lower-string', 'quoted') + $ValidResolvers = @('GitUserName', 'CurrentDirectory') + $ValidSources = @('NewGuid', 'CurrentYear', 'CurrentDate') + + $Errors = [System.Collections.Generic.List[string]]::new() + + # Required top-level keys + foreach ($Key in @('Name', 'Description', 'Version')) { + if (-not $Manifest.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace($Manifest[$Key])) { + $Errors.Add("'$Key' is required and must not be empty.") + } + } + + if (-not $Manifest.ContainsKey('Parameters') -or $Manifest['Parameters'].Count -eq 0) { + $Errors.Add("'Parameters' is required and must contain at least one entry.") + } + + # Track all token names for uniqueness + $AllNames = [System.Collections.Generic.List[string]]::new() + + # Parameter entries + $Parameters = @($Manifest['Parameters']) + for ($i = 0; $i -lt $Parameters.Count; $i++) { + $P = $Parameters[$i] + $Prefix = "Parameters[$i]" + + if (-not $P -or $P -isnot [hashtable]) { + $Errors.Add("$Prefix must be a hashtable.") + continue + } + + # Required fields + if (-not $P.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($P['Name'])) { + $Errors.Add("${Prefix}: 'Name' is required.") + } else { + if ($AllNames -contains $P['Name']) { + $Errors.Add("${Prefix}: duplicate name '$($P['Name'])'.") + } + $AllNames.Add($P['Name']) + $Prefix = "Parameter '$($P['Name'])'" + } + + if (-not $P.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace($P['Type'])) { + $Errors.Add("${Prefix}: 'Type' is required.") + } elseif ($P['Type'] -notin $ValidTypes) { + $Errors.Add("${Prefix}: Type '$($P['Type'])' is not valid. Choose from: $($ValidTypes -join ', ').") + } + + if (-not $P.ContainsKey('Prompt') -or [string]::IsNullOrWhiteSpace($P['Prompt'])) { + $Errors.Add("${Prefix}: 'Prompt' is required.") + } + + # Type-specific checks + $Type = $P['Type'] + + if ($Type -eq 'choice') { + if (-not $P.ContainsKey('Choices') -or @($P['Choices']).Count -eq 0) { + $Errors.Add("${Prefix}: 'Choices' is required for choice type and must not be empty.") + } + } + + if ($P.ContainsKey('Range')) { + $Range = @($P['Range']) + if ($Range.Count -ne 2) { + $Errors.Add("${Prefix}: 'Range' must be a two-element array @(min, max).") + } elseif ($Range[0] -gt $Range[1]) { + $Errors.Add("${Prefix}: Range minimum ($($Range[0])) must not exceed maximum ($($Range[1])).") + } + } + + if ($P.ContainsKey('Format') -and $P['Format'] -notin $ValidFormatters) { + $Errors.Add("${Prefix}: Format '$($P['Format'])' is not valid. Choose from: $($ValidFormatters -join ', ').") + } + + if ($P.ContainsKey('DefaultFrom') -and $P['DefaultFrom'] -notin $ValidResolvers) { + $Errors.Add("${Prefix}: DefaultFrom '$($P['DefaultFrom'])' is not valid. Choose from: $($ValidResolvers -join ', ').") + } + + if ($P.ContainsKey('Validate') -and -not [string]::IsNullOrWhiteSpace($P['Validate'])) { + try { + [void][regex]::new($P['Validate']) + } catch { + $Errors.Add("${Prefix}: Validate pattern '$($P['Validate'])' is not a valid regex.") + } + } + } + + # AutoTokens + if ($Manifest.ContainsKey('AutoTokens')) { + $AutoTokens = @($Manifest['AutoTokens']) + for ($i = 0; $i -lt $AutoTokens.Count; $i++) { + $AT = $AutoTokens[$i] + $Prefix = "AutoTokens[$i]" + + if (-not $AT -or $AT -isnot [hashtable]) { + $Errors.Add("$Prefix must be a hashtable.") + continue + } + + if (-not $AT.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace($AT['Name'])) { + $Errors.Add("${Prefix}: 'Name' is required.") + } else { + if ($AllNames -contains $AT['Name']) { + $Errors.Add("${Prefix}: duplicate name '$($AT['Name'])'.") + } + $AllNames.Add($AT['Name']) + $Prefix = "AutoToken '$($AT['Name'])'" + } + + if (-not $AT.ContainsKey('Source') -or [string]::IsNullOrWhiteSpace($AT['Source'])) { + $Errors.Add("${Prefix}: 'Source' is required.") + } elseif ($AT['Source'] -notin $ValidSources) { + $Errors.Add("${Prefix}: Source '$($AT['Source'])' is not valid. Choose from: $($ValidSources -join ', ').") + } + } + } + + # Sections + if ($Manifest.ContainsKey('Sections')) { + foreach ($SectionName in $Manifest['Sections'].Keys) { + $Def = $Manifest['Sections'][$SectionName] + $Prefix = "Section '$SectionName'" + + if ($Def -isnot [hashtable]) { + $Errors.Add("${Prefix}: value must be a hashtable.") + continue + } + + $HasInclude = $Def.ContainsKey('IncludeWhen') + $HasExclude = $Def.ContainsKey('ExcludeWhen') + + if (-not $HasInclude -and -not $HasExclude) { + $Errors.Add("${Prefix}: must have either 'IncludeWhen' or 'ExcludeWhen'.") + } + if ($HasInclude -and $HasExclude) { + $Errors.Add("${Prefix}: must have only one of 'IncludeWhen' or 'ExcludeWhen', not both.") + } + } + } + + # Layers + if ($Manifest.ContainsKey('Layers')) { + $Layers = @($Manifest['Layers']) + for ($i = 0; $i -lt $Layers.Count; $i++) { + $L = $Layers[$i] + $Prefix = "Layers[$i]" + + if (-not $L -or $L -isnot [hashtable]) { + $Errors.Add("$Prefix must be a hashtable.") + continue + } + + if (-not $L.ContainsKey('PathKey') -or [string]::IsNullOrWhiteSpace($L['PathKey'])) { + $Errors.Add("${Prefix}: 'PathKey' is required.") + } + if (-not $L.ContainsKey('BasePath') -or [string]::IsNullOrWhiteSpace($L['BasePath'])) { + $Errors.Add("${Prefix}: 'BasePath' is required.") + } + } + } + + if ($Errors.Count -gt 0) { + $Message = "Template manifest validation failed:`n - " + ($Errors -join "`n - ") + throw $Message + } +} diff --git a/src/Anvil/Private/Assert-ValidConfiguration.ps1 b/src/Anvil/Private/Assert-ValidConfiguration.ps1 deleted file mode 100644 index 0b803eb..0000000 --- a/src/Anvil/Private/Assert-ValidConfiguration.ps1 +++ /dev/null @@ -1,91 +0,0 @@ -function Assert-ValidConfiguration { - <# - .SYNOPSIS - Validates the scaffolding configuration hashtable. Throws on any - invalid or missing required values. - - .DESCRIPTION - Checks the configuration hashtable built from New-AnvilModule - parameters against known constraints: - - - Required keys: ModuleName, Author, Description (non-empty). - - ModuleName format: starts with a letter, alphanumeric plus - dots/hyphens/underscores, max 128 characters. - - CIProvider and License must be from their allowed sets. - - CoverageThreshold must be an integer 0-100. - - MinPowerShellVersion must parse as a .NET Version. - - All violations are collected and thrown as a single error message - so the caller sees every problem at once. - - .PARAMETER Configuration - Hashtable of scaffolding options to validate. - - .OUTPUTS - None. Throws on validation failure. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Configuration - ) - - $Errors = [System.Collections.Generic.List[string]]::new() - - # Required keys - $RequiredKeys = @('ModuleName', 'Author', 'Description') - foreach ($Key in $RequiredKeys) { - if (-not $Configuration.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace($Configuration[$Key])) { - $Errors.Add("'$Key' is required and must not be empty.") - } - } - - # ModuleName format - if ($Configuration.ContainsKey('ModuleName') -and -not [string]::IsNullOrWhiteSpace($Configuration['ModuleName'])) { - $Name = $Configuration['ModuleName'] - if ($Name -notmatch '^[A-Za-z][A-Za-z0-9._-]*$') { - $Errors.Add("ModuleName '$Name' contains invalid characters. Use letters, digits, dots, hyphens, or underscores, starting with a letter.") - } - if ($Name.Length -gt 128) { - $Errors.Add("ModuleName must be 128 characters or fewer.") - } - } - - # CIProvider - $ValidProviders = @('GitHub', 'AzurePipelines', 'GitLab', 'None') - if ($Configuration.ContainsKey('CIProvider')) { - if ($Configuration['CIProvider'] -notin $ValidProviders) { - $Errors.Add("CIProvider '$($Configuration['CIProvider'])' is not valid. Choose from: $($ValidProviders -join ', ').") - } - } - - # License - $ValidLicenses = @('MIT', 'Apache2', 'None') - if ($Configuration.ContainsKey('License')) { - if ($Configuration['License'] -notin $ValidLicenses) { - $Errors.Add("License '$($Configuration['License'])' is not valid. Choose from: $($ValidLicenses -join ', ').") - } - } - - # Numeric thresholds - if ($Configuration.ContainsKey('CoverageThreshold')) { - $Ct = $Configuration['CoverageThreshold'] - if ($Ct -isnot [int] -or $Ct -lt 0 -or $Ct -gt 100) { - $Errors.Add("CoverageThreshold must be an integer between 0 and 100.") - } - } - - # PowerShell version - if ($Configuration.ContainsKey('MinPowerShellVersion')) { - try { - [void][Version]::new($Configuration['MinPowerShellVersion']) - } catch { - $Errors.Add("MinPowerShellVersion '$($Configuration['MinPowerShellVersion'])' is not a valid version string.") - } - } - - if ($Errors.Count -gt 0) { - $Message = "Configuration validation failed:`n - " + ($Errors -join "`n - ") - throw $Message - } -} diff --git a/src/Anvil/Private/Convert-PromptResult.ps1 b/src/Anvil/Private/Convert-PromptResult.ps1 new file mode 100644 index 0000000..b2b2dcf --- /dev/null +++ b/src/Anvil/Private/Convert-PromptResult.ps1 @@ -0,0 +1,59 @@ +function Convert-PromptResult { + <# + .SYNOPSIS + Normalizes a parameter value to the expected type. + + .DESCRIPTION + Converts a raw value (from bound parameters or manifest defaults) + into the type expected by the manifest parameter declaration. + Used by Invoke-ManifestPrompt to ensure consistent value types + regardless of input source. + + Type conversions: + csv Splits a comma-separated string into a string array. + Arrays pass through unchanged. Empty/whitespace returns @(). + int Casts to [int]. + bool Converts booleans, 'y', 'true', '1' to $true; else $false. + Actual [bool] values pass through unchanged. + * All other types return the value unchanged. + + .PARAMETER Value + The value to convert. + + .PARAMETER Type + The manifest parameter type name. + + .OUTPUTS + The converted value (type varies by Type parameter). + #> + [CmdletBinding()] + [OutputType([string], [int], [bool], [object[]])] + param( + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] + [AllowEmptyCollection()] + $Value, + + [Parameter(Mandatory)] + [string]$Type + ) + + switch ($Type) { + 'csv' { + if ($Value -is [array]) { return $Value } + if ([string]::IsNullOrWhiteSpace($Value)) { return @() } + return @($Value -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + 'int' { + return [int]$Value + } + 'bool' { + if ($Value -is [bool]) { return $Value } + return $Value -match '^([Yy]|[Tt]rue|1)$' + } + default { + return $Value + } + } +} diff --git a/src/Anvil/Private/Copy-CITemplates.ps1 b/src/Anvil/Private/Copy-CITemplates.ps1 deleted file mode 100644 index 6b88113..0000000 --- a/src/Anvil/Private/Copy-CITemplates.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -function Copy-CITemplates { - <# - .SYNOPSIS - Layers CI-provider-specific template files into a scaffolded project. - - .DESCRIPTION - Called after the base module template is expanded. Copies and - processes .tmpl files from Templates/CI// into the - destination, applying the same token replacement rules as the base - template engine. - - .PARAMETER Provider - CI platform whose templates should be applied. - Valid values: GitHub, AzurePipelines, GitLab. - - .PARAMETER DestinationPath - Root of the already-scaffolded project directory. - - .PARAMETER Tokens - Token hashtable passed through to Invoke-TemplateEngine. - - .OUTPUTS - System.Int32 - The number of CI template files processed. - #> - [CmdletBinding()] - [OutputType([int])] - param( - [Parameter(Mandatory)] - [ValidateSet('GitHub', 'AzurePipelines', 'GitLab')] - [string]$Provider, - - [Parameter(Mandatory)] - [string]$DestinationPath, - - [Parameter(Mandatory)] - [hashtable]$Tokens - ) - - $CiTemplatePath = Join-Path -Path $script:TemplateRoot -ChildPath 'CI' - $ProviderPath = Join-Path -Path $CiTemplatePath -ChildPath $Provider - - if (-not (Test-Path -Path $ProviderPath)) { - Write-Warning "CI template directory not found for provider '$Provider' at: $ProviderPath" - return 0 - } - - $Count = Invoke-TemplateEngine -SourcePath $ProviderPath -DestinationPath $DestinationPath -Tokens $Tokens - return $Count -} diff --git a/src/Anvil/Private/Format-TokenValue.ps1 b/src/Anvil/Private/Format-TokenValue.ps1 new file mode 100644 index 0000000..8e33453 --- /dev/null +++ b/src/Anvil/Private/Format-TokenValue.ps1 @@ -0,0 +1,70 @@ +function Format-TokenValue { + <# + .SYNOPSIS + Formats a parameter value for embedding in a template file using + a named formatter. + + .DESCRIPTION + Transforms a resolved parameter value into a string suitable for + token replacement in template content. The formatter name + determines the transformation applied. + + Supported formatters: + raw Returns the value as a string via .ToString(). + This is the default. + psd1-array Formats a string array as a PowerShell data + literal: @('a', 'b') or @() for empty. + lower-string Converts a boolean to its lowercase string + representation: 'true' or 'false'. + quoted Wraps the value in single quotes. + + .PARAMETER Value + The value to format. May be a string, string array, boolean, + integer, or $null. + + .PARAMETER Formatter + The name of the formatter to apply. + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] + [AllowEmptyCollection()] + $Value, + + [Parameter(Mandatory)] + [string]$Formatter + ) + + switch ($Formatter) { + 'raw' { + if ($null -eq $Value) { return '' } + return $Value.ToString() + } + 'psd1-array' { + $Items = @($Value | Where-Object { $_ }) + if ($Items.Count -eq 0) { + return '@()' + } + $Quoted = $Items | ForEach-Object { "'$_'" } + return "@($($Quoted -join ', '))" + } + 'lower-string' { + if ($Value -is [bool]) { + return $Value.ToString().ToLower() + } + return "$Value".ToLower() + } + 'quoted' { + return "'$Value'" + } + default { + throw "Unknown formatter '$Formatter'. Valid formatters: raw, psd1-array, lower-string, quoted." + } + } +} diff --git a/src/Anvil/Private/Invoke-InteractivePrompt.ps1 b/src/Anvil/Private/Invoke-InteractivePrompt.ps1 deleted file mode 100644 index c4e43b6..0000000 --- a/src/Anvil/Private/Invoke-InteractivePrompt.ps1 +++ /dev/null @@ -1,212 +0,0 @@ -function Invoke-InteractivePrompt { - <# - .SYNOPSIS - Resolves all New-AnvilModule parameters, prompting for missing values - when running in interactive mode. - - .DESCRIPTION - Collects all New-AnvilModule parameters from bound values, defaults, - or interactive prompts. Values already provided via bound parameters - are used as-is and not prompted for. - - When Interactive is $false, missing values are filled from Defaults. - Missing required values with no default cause a terminating error. - - Returns a hashtable of all resolved values. - - .PARAMETER BoundParams - Hashtable of parameters already provided by the caller. Keys - matching prompt fields are skipped. - - .PARAMETER Defaults - Hashtable of default values for optional parameters. Used as prompt - defaults in interactive mode and as silent fallbacks otherwise. - - .PARAMETER Interactive - When $true, prompts the user for any missing values. When $false, - applies defaults silently and throws on missing required values. - #> - [CmdletBinding()] - [OutputType([hashtable])] - param( - [Parameter(Mandatory)] - [hashtable]$BoundParams, - - [Parameter(Mandatory)] - [hashtable]$Defaults, - - [Parameter()] - [bool]$Interactive = $false - ) - - $GitAuthor = Resolve-AuthorName - - if ($Interactive) { - Write-Host '' - Write-Host ' Anvil - New Module Project' -ForegroundColor Cyan - Write-Host ' Press Enter to accept defaults shown in [brackets].' -ForegroundColor DarkGray - Write-Host '' - } - - $Result = @{} - - # Required values (no defaults - must be bound or prompted) - $Result.Name = if ($BoundParams.ContainsKey('Name')) { - $BoundParams.Name - } elseif ($Interactive) { - Read-PromptValue -Prompt ' Module name' -Required - } else { - throw "'Name' is required. Use -Interactive for the guided wizard." - } - - $Result.DestinationPath = if ($BoundParams.ContainsKey('DestinationPath')) { - $BoundParams.DestinationPath - } elseif ($Interactive) { - Read-PromptValue -Prompt ' Destination path' -Default $PWD.Path - } else { - throw "'DestinationPath' is required. Use -Interactive for the guided wizard." - } - - $AuthorDefault = if ($GitAuthor) { $GitAuthor } else { $null } - $Result.Author = if ($BoundParams.ContainsKey('Author')) { - $BoundParams.Author - } elseif ($Interactive) { - Read-PromptValue -Prompt ' Author' -Default $AuthorDefault -Required - } elseif ($AuthorDefault) { - $AuthorDefault - } else { - throw "'Author' is required. Use -Interactive for the guided wizard." - } - - # Optional values - fall back to Defaults - $Result.Description = if ($BoundParams.ContainsKey('Description')) { - $BoundParams.Description - } elseif ($Interactive) { - Read-PromptValue -Prompt ' Description' -Default $Defaults.Description - } else { - $Defaults.Description - } - - $Result.CompanyName = if ($BoundParams.ContainsKey('CompanyName')) { - $BoundParams.CompanyName - } elseif ($Interactive) { - Read-PromptValue -Prompt ' Company name' -Default $Defaults.CompanyName - } else { - $Defaults.CompanyName - } - - $Result.MinPowerShellVersion = if ($BoundParams.ContainsKey('MinPowerShellVersion')) { - $BoundParams.MinPowerShellVersion - } elseif ($Interactive) { - Read-PromptValue -Prompt ' Minimum PowerShell version' -Default $Defaults.MinPowerShellVersion - } else { - $Defaults.MinPowerShellVersion - } - - $Result.CompatiblePSEditions = if ($BoundParams.ContainsKey('CompatiblePSEditions')) { - $BoundParams.CompatiblePSEditions - } elseif ($Interactive) { - $EditionInput = Read-PromptValue -Prompt ' Compatible PS editions (Desktop,Core / Core)' -Default ($Defaults.CompatiblePSEditions -join ',') - @($EditionInput -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) - } else { - $Defaults.CompatiblePSEditions - } - - $Result.CIProvider = if ($BoundParams.ContainsKey('CIProvider')) { - $BoundParams.CIProvider - } elseif ($Interactive) { - Read-PromptChoice -Prompt ' CI provider' -Choices @('GitHub', 'AzurePipelines', 'GitLab', 'None') -Default $Defaults.CIProvider - } else { - $Defaults.CIProvider - } - - $Result.License = if ($BoundParams.ContainsKey('License')) { - $BoundParams.License - } elseif ($Interactive) { - Read-PromptChoice -Prompt ' License' -Choices @('MIT', 'Apache2', 'None') -Default $Defaults.License - } else { - $Defaults.License - } - - $Result.CoverageThreshold = if ($BoundParams.ContainsKey('CoverageThreshold')) { - $BoundParams.CoverageThreshold - } elseif ($Interactive) { - [int](Read-PromptValue -Prompt ' Code coverage threshold (0-100)' -Default $Defaults.CoverageThreshold.ToString()) - } else { - $Defaults.CoverageThreshold - } - - $Result.IncludeDocs = if ($BoundParams.ContainsKey('IncludeDocs')) { - [bool]$BoundParams.IncludeDocs - } elseif ($Interactive) { - $DocsDefault = if ($Defaults.IncludeDocs) { 'y' } else { 'n' } - $DocsInput = Read-PromptValue -Prompt ' Include PlatyPS docs generation? (y/n)' -Default $DocsDefault - $DocsInput -match '^[Yy]' - } else { - $Defaults.IncludeDocs - } - - $Result.Tags = if ($BoundParams.ContainsKey('Tags')) { - $BoundParams.Tags - } elseif ($Interactive) { - $TagDefault = if ($Defaults.Tags.Count -gt 0) { $Defaults.Tags -join ',' } else { '' } - $TagInput = Read-PromptValue -Prompt ' Tags (comma-separated)' -Default $TagDefault - if ($TagInput) { - @($TagInput -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) - } else { - @() - } - } else { - $Defaults.Tags - } - - $Result.ProjectUri = if ($BoundParams.ContainsKey('ProjectUri')) { - $BoundParams.ProjectUri - } elseif ($Interactive) { - do { - $UriInput = Read-PromptValue -Prompt ' Project URI' -Default $Defaults.ProjectUri - if ([string]::IsNullOrWhiteSpace($UriInput)) { break } - $Parsed = $UriInput -as [System.Uri] - if ($Parsed -and $Parsed.IsAbsoluteUri) { break } - Write-Host ' Must be a valid absolute URI (e.g. https://github.com/user/repo).' -ForegroundColor Yellow - } while ($true) - $UriInput - } else { - $Defaults.ProjectUri - } - - $Result.LicenseUri = if ($BoundParams.ContainsKey('LicenseUri')) { - $BoundParams.LicenseUri - } elseif ($Interactive) { - do { - $UriInput = Read-PromptValue -Prompt ' License URI' -Default $Defaults.LicenseUri - if ([string]::IsNullOrWhiteSpace($UriInput)) { break } - $Parsed = $UriInput -as [System.Uri] - if ($Parsed -and $Parsed.IsAbsoluteUri) { break } - Write-Host ' Must be a valid absolute URI (e.g. https://github.com/user/repo/blob/main/LICENSE).' -ForegroundColor Yellow - } while ($true) - $UriInput - } else { - $Defaults.LicenseUri - } - - $Result.GitInit = if ($BoundParams.ContainsKey('GitInit')) { - [bool]$BoundParams.GitInit - } elseif ($Interactive) { - $GitDefault = if ($Defaults.GitInit) { 'y' } else { 'n' } - $GitInput = Read-PromptValue -Prompt ' Initialize git repository? (y/n)' -Default $GitDefault - $GitInput -match '^[Yy]' - } else { - $Defaults.GitInit - } - - # Pass through flags - $Result.Force = if ($BoundParams.ContainsKey('Force')) { [bool]$BoundParams.Force } else { $false } - $Result.PassThru = if ($BoundParams.ContainsKey('PassThru')) { [bool]$BoundParams.PassThru } else { $false } - - if ($Interactive) { - Write-Host '' - } - - return $Result -} diff --git a/src/Anvil/Private/Invoke-ManifestPrompt.ps1 b/src/Anvil/Private/Invoke-ManifestPrompt.ps1 new file mode 100644 index 0000000..983dd37 --- /dev/null +++ b/src/Anvil/Private/Invoke-ManifestPrompt.ps1 @@ -0,0 +1,127 @@ +function Invoke-ManifestPrompt { + <# + .SYNOPSIS + Resolves template parameters using manifest declarations, bound + values, defaults, or interactive prompts. + + .DESCRIPTION + Walks the manifest's Parameters array and resolves each value + from one of these sources (in priority order): + + 1. BoundParams - values already provided by the caller. + 2. Interactive prompt - when Interactive is $true and the value + is not bound. + 3. DefaultFrom resolver - named default (e.g. GitUserName). + 4. Default - static default from the manifest. + + When Interactive is $false, missing required values with no + default cause a terminating error. + + Returns a hashtable of raw (unformatted) resolved values keyed + by parameter name. + + .PARAMETER Manifest + The template manifest hashtable (from Read-TemplateManifest). + + .PARAMETER BoundParams + Hashtable of values already provided by the caller. + + .PARAMETER Interactive + When $true, prompts the user for missing values. + + .OUTPUTS + System.Collections.Hashtable + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [hashtable]$Manifest, + + [Parameter(Mandatory)] + [hashtable]$BoundParams, + + [Parameter()] + [bool]$Interactive = $false + ) + + if ($Interactive) { + Write-Host '' + Write-Host " Anvil - $($Manifest.Name)" -ForegroundColor Cyan + Write-Host ' Press Enter to accept defaults shown in [brackets].' -ForegroundColor DarkGray + Write-Host '' + } + + $Result = @{} + + foreach ($Param in $Manifest.Parameters) { + $Name = $Param.Name + $Type = $Param.Type + + if ($BoundParams.ContainsKey($Name)) { + $Result[$Name] = Convert-PromptResult -Value $BoundParams[$Name] -Type $Type + continue + } + + $Default = $null + $HasDefault = $Param.ContainsKey('Default') + if ($HasDefault) { + $Default = $Param.Default + } + if ($Param.ContainsKey('DefaultFrom')) { + $Resolved = Resolve-DefaultFrom -ResolverName $Param.DefaultFrom + if ($null -ne $Resolved -and $Resolved -ne '') { + $Default = $Resolved + $HasDefault = $true + } + } + + if (-not $Interactive) { + if ($HasDefault) { + $Result[$Name] = Convert-PromptResult -Value $Default -Type $Type + continue + } + if ($Param.Required) { + throw "'$Name' is required. Use -Interactive for the guided wizard." + } + $Result[$Name] = $Default + continue + } + + $Result[$Name] = switch ($Type) { + 'string' { + Read-PromptValue -Prompt " $($Param.Prompt)" -Default $Default -Required:([bool]$Param.Required) + } + 'choice' { + Read-PromptChoice -Prompt " $($Param.Prompt)" -Choices $Param.Choices -Default $Default + } + 'bool' { + $BoolDefault = if ($Default) { 'y' } else { 'n' } + $BoolInput = Read-PromptValue -Prompt " $($Param.Prompt) (y/n)" -Default $BoolDefault + $BoolInput -match '^[Yy]' + } + 'int' { + $DefaultStr = if ($null -ne $Default) { $Default.ToString() } else { '' } + [int](Read-PromptValue -Prompt " $($Param.Prompt)" -Default $DefaultStr) + } + 'csv' { + $CsvDefault = if ($Default -is [array]) { $Default -join ',' } else { "$Default" } + $CsvInput = Read-PromptValue -Prompt " $($Param.Prompt)" -Default $CsvDefault + if ($CsvInput) { + @($CsvInput -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } else { + @() + } + } + 'uri' { + Read-PromptUri -Prompt " $($Param.Prompt)" -Default $Default + } + } + } + + if ($Interactive) { + Write-Host '' + } + + return $Result +} diff --git a/src/Anvil/Private/Invoke-TemplateEngine.ps1 b/src/Anvil/Private/Invoke-TemplateEngine.ps1 index bca7026..b7984e7 100644 --- a/src/Anvil/Private/Invoke-TemplateEngine.ps1 +++ b/src/Anvil/Private/Invoke-TemplateEngine.ps1 @@ -31,6 +31,21 @@ function Invoke-TemplateEngine { Optional array of relative path patterns to skip. Supports simple wildcards via the -like operator (*, ?, []). + .PARAMETER IncludeWhen + Optional hashtable of manifest conditions for conditional file + inclusion. Keys are wildcard path patterns; values are condition + hashtables evaluated by Test-ManifestCondition. + + .PARAMETER ExcludeWhen + Optional hashtable of manifest conditions for conditional file + exclusion. Keys are wildcard path patterns; values are condition + hashtables evaluated by Test-ManifestCondition. + + .PARAMETER Sections + Optional hashtable of section conditions from a template manifest. + Keys are section names matching <%#section Name%> markers in + template files. Each value has an IncludeWhen or ExcludeWhen key. + .OUTPUTS System.Int32 The number of files processed (excluding skipped files). @@ -53,7 +68,13 @@ function Invoke-TemplateEngine { [Parameter(Mandatory)] [hashtable]$Tokens, - [string[]]$ExcludePatterns = @() + [string[]]$ExcludePatterns = @(), + + [hashtable]$IncludeWhen = @{}, + + [hashtable]$ExcludeWhen = @{}, + + [hashtable]$Sections = @{} ) if (-not (Test-Path -Path $SourcePath)) { @@ -74,6 +95,10 @@ function Invoke-TemplateEngine { continue } + if (-not (Test-FileCondition -RelativePath $ResolvedPath -IncludeWhen $IncludeWhen -ExcludeWhen $ExcludeWhen -Tokens $Tokens)) { + continue + } + $TargetDir = Join-Path -Path $DestinationPath -ChildPath $ResolvedPath if (-not (Test-Path -Path $TargetDir)) { New-Item -Path $TargetDir -ItemType Directory -Force | Out-Null @@ -91,6 +116,10 @@ function Invoke-TemplateEngine { continue } + if (-not (Test-FileCondition -RelativePath $ResolvedPath -IncludeWhen $IncludeWhen -ExcludeWhen $ExcludeWhen -Tokens $Tokens)) { + continue + } + $IsTemplate = $File.Extension -eq '.tmpl' if ($IsTemplate) { @@ -106,6 +135,9 @@ function Invoke-TemplateEngine { if ($IsTemplate) { $Content = Get-Content -Path $File.FullName -Raw -ErrorAction Stop + if ($Sections.Count -gt 0) { + $Content = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + } $Content = Resolve-ContentTokens -Content $Content -Tokens $Tokens Set-Content -Path $TargetPath -Value $Content -NoNewline -ErrorAction Stop } else { diff --git a/src/Anvil/Private/Read-PromptUri.ps1 b/src/Anvil/Private/Read-PromptUri.ps1 new file mode 100644 index 0000000..030ad7f --- /dev/null +++ b/src/Anvil/Private/Read-PromptUri.ps1 @@ -0,0 +1,41 @@ +function Read-PromptUri { + <# + .SYNOPSIS + Prompts the user for an absolute URI with validation and retry. + + .DESCRIPTION + Displays a prompt for a URI value. If the user enters a non-empty + string that is not a valid absolute URI, a warning is shown and + the prompt repeats. An empty response returns the default value. + + .PARAMETER Prompt + The prompt text displayed to the user. + + .PARAMETER Default + The value returned when the user presses Enter without typing. + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string]$Prompt, + + [Parameter()] + [string]$Default + ) + + do { + $UriInput = Read-PromptValue -Prompt $Prompt -Default $Default + if ([string]::IsNullOrWhiteSpace($UriInput)) { + return $UriInput + } + $Parsed = $UriInput -as [System.Uri] + if ($Parsed -and $Parsed.IsAbsoluteUri) { + return $UriInput + } + Write-Host ' Must be a valid absolute URI (e.g. https://github.com/user/repo).' -ForegroundColor Yellow + } while ($true) +} diff --git a/src/Anvil/Private/Read-TemplateManifest.ps1 b/src/Anvil/Private/Read-TemplateManifest.ps1 new file mode 100644 index 0000000..042e54e --- /dev/null +++ b/src/Anvil/Private/Read-TemplateManifest.ps1 @@ -0,0 +1,38 @@ +function Read-TemplateManifest { + <# + .SYNOPSIS + Loads and validates a template manifest from a template directory. + + .DESCRIPTION + Reads a template.psd1 file from the specified directory using + Import-PowerShellDataFile, then validates the resulting hashtable + with Assert-TemplateManifest. + + Returns the validated manifest hashtable on success. Throws if + the file is missing, cannot be parsed, or fails schema validation. + + .PARAMETER TemplatePath + Path to the template directory containing a template.psd1 file. + + .OUTPUTS + System.Collections.Hashtable + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$TemplatePath + ) + + $ManifestFile = Join-Path -Path $TemplatePath -ChildPath 'template.psd1' + + if (-not (Test-Path -Path $ManifestFile)) { + throw "Template manifest not found: $ManifestFile" + } + + $Manifest = Import-PowerShellDataFile -Path $ManifestFile -ErrorAction Stop + + Assert-TemplateManifest -Manifest $Manifest + + return $Manifest +} diff --git a/src/Anvil/Private/Resolve-AutoToken.ps1 b/src/Anvil/Private/Resolve-AutoToken.ps1 new file mode 100644 index 0000000..23c883a --- /dev/null +++ b/src/Anvil/Private/Resolve-AutoToken.ps1 @@ -0,0 +1,43 @@ +function Resolve-AutoToken { + <# + .SYNOPSIS + Resolves a named auto-token source to a string value. + + .DESCRIPTION + Auto-tokens are values computed by the engine rather than provided + by the user. Each source name maps to a deterministic or generated + value. + + Supported sources: + NewGuid A new random GUID string. + CurrentYear The current four-digit year. + CurrentDate The current date in yyyy-MM-dd format. + + .PARAMETER Source + The name of the auto-token source to resolve. + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string]$Source + ) + + switch ($Source) { + 'NewGuid' { + return [guid]::NewGuid().ToString() + } + 'CurrentYear' { + return (Get-Date).Year.ToString() + } + 'CurrentDate' { + return (Get-Date).ToString('yyyy-MM-dd') + } + default { + throw "Unknown auto-token source '$Source'. Valid sources: NewGuid, CurrentYear, CurrentDate." + } + } +} diff --git a/src/Anvil/Private/Resolve-DefaultFrom.ps1 b/src/Anvil/Private/Resolve-DefaultFrom.ps1 new file mode 100644 index 0000000..4e1d447 --- /dev/null +++ b/src/Anvil/Private/Resolve-DefaultFrom.ps1 @@ -0,0 +1,33 @@ +function Resolve-DefaultFrom { + <# + .SYNOPSIS + Resolves a named default-value resolver to its value. + + .DESCRIPTION + Maps a DefaultFrom resolver name from a template manifest parameter + to its resolved value. Used by Invoke-ManifestPrompt to populate + defaults for parameters that derive their value from the environment. + + Supported resolvers: + GitUserName Returns git config user.name via Resolve-AuthorName. + CurrentDirectory Returns the current working directory path. + + .PARAMETER ResolverName + The name of the resolver to execute. + + .OUTPUTS + System.String or $null + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string]$ResolverName + ) + + switch ($ResolverName) { + 'GitUserName' { return Resolve-AuthorName } + 'CurrentDirectory' { return $PWD.Path } + default { return $null } + } +} diff --git a/src/Anvil/Private/Resolve-TemplateSections.ps1 b/src/Anvil/Private/Resolve-TemplateSections.ps1 new file mode 100644 index 0000000..b0e2387 --- /dev/null +++ b/src/Anvil/Private/Resolve-TemplateSections.ps1 @@ -0,0 +1,77 @@ +function Resolve-TemplateSections { + <# + .SYNOPSIS + Processes conditional section markers in template content. + + .DESCRIPTION + Scans template content for <%#section Name%> / <%#endsection%> + marker pairs and evaluates the corresponding manifest condition + to determine whether to keep or strip each block. + + When a section's condition is met, the block content is kept and + the marker lines are removed. When the condition is not met, the + entire block including markers is removed. + + Sections cannot nest. A section name appearing in the content + without a matching entry in the Sections hashtable causes a + terminating error. + + Content with no section markers passes through unchanged. + + .PARAMETER Content + The raw template file content to process. + + .PARAMETER Sections + Hashtable from the template manifest mapping section names to + condition definitions. Each value is a hashtable with exactly + one key: either 'IncludeWhen' or 'ExcludeWhen', whose value is + a condition hashtable compatible with Test-ManifestCondition. + + .PARAMETER Tokens + Hashtable of resolved token values used to evaluate conditions. + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Sections', Justification = 'Used inside regex Replace scriptblock')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Tokens', Justification = 'Used inside regex Replace scriptblock')] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Content, + + [Parameter(Mandatory)] + [hashtable]$Sections, + + [Parameter(Mandatory)] + [hashtable]$Tokens + ) + + $Pattern = '(?m)^[ \t]*<%#section\s+(\w+)%>\s*\r?\n([\s\S]*?)^[ \t]*<%#endsection%>\s*\r?\n?' + + $Result = [regex]::Replace($Content, $Pattern, { + param($Match) + + $SectionName = $Match.Groups[1].Value + $SectionBody = $Match.Groups[2].Value + + if (-not $Sections.ContainsKey($SectionName)) { + throw "Section '$SectionName' found in template but not declared in manifest Sections." + } + + $SectionDef = $Sections[$SectionName] + $Keep = $false + + if ($SectionDef.ContainsKey('IncludeWhen')) { + $Keep = Test-ManifestCondition -Condition $SectionDef['IncludeWhen'] -Tokens $Tokens + } elseif ($SectionDef.ContainsKey('ExcludeWhen')) { + $Keep = -not (Test-ManifestCondition -Condition $SectionDef['ExcludeWhen'] -Tokens $Tokens) + } + + if ($Keep) { $SectionBody } else { '' } + }) + + return $Result +} diff --git a/src/Anvil/Private/Test-FileCondition.ps1 b/src/Anvil/Private/Test-FileCondition.ps1 new file mode 100644 index 0000000..a143553 --- /dev/null +++ b/src/Anvil/Private/Test-FileCondition.ps1 @@ -0,0 +1,72 @@ +function Test-FileCondition { + <# + .SYNOPSIS + Determines whether a template file should be included based on + manifest conditions. + + .DESCRIPTION + Evaluates a resolved file path against IncludeWhen and ExcludeWhen + condition tables from a template manifest. + + Matching rules: + - Condition table keys are wildcard patterns tested against the + file path with the -like operator. + - ExcludeWhen is evaluated first. If a matching pattern's + condition is satisfied, the file is excluded. + - IncludeWhen is evaluated second. If a matching pattern's + condition is satisfied, the file is included. If the + condition is NOT satisfied, the file is excluded. + - Files not matched by any pattern are included by default. + + .PARAMETER RelativePath + The resolved relative file path (after path-token replacement, + before .tmpl stripping). + + .PARAMETER IncludeWhen + Hashtable mapping wildcard path patterns to condition hashtables. + A file matching a pattern is included only when the condition is + satisfied. + + .PARAMETER ExcludeWhen + Hashtable mapping wildcard path patterns to condition hashtables. + A file matching a pattern is excluded when the condition is + satisfied. + + .PARAMETER Tokens + Hashtable of resolved token values used to evaluate conditions. + + .OUTPUTS + System.Boolean + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [string]$RelativePath, + + [hashtable]$IncludeWhen = @{}, + + [hashtable]$ExcludeWhen = @{}, + + [Parameter(Mandatory)] + [hashtable]$Tokens + ) + + $NormalizedPath = $RelativePath -replace '\\', '/' + + foreach ($Pattern in $ExcludeWhen.Keys) { + if ($NormalizedPath -like $Pattern) { + if (Test-ManifestCondition -Condition $ExcludeWhen[$Pattern] -Tokens $Tokens) { + return $false + } + } + } + + foreach ($Pattern in $IncludeWhen.Keys) { + if ($NormalizedPath -like $Pattern) { + return (Test-ManifestCondition -Condition $IncludeWhen[$Pattern] -Tokens $Tokens) + } + } + + return $true +} diff --git a/src/Anvil/Private/Test-ManifestCondition.ps1 b/src/Anvil/Private/Test-ManifestCondition.ps1 new file mode 100644 index 0000000..1aab752 --- /dev/null +++ b/src/Anvil/Private/Test-ManifestCondition.ps1 @@ -0,0 +1,50 @@ +function Test-ManifestCondition { + <# + .SYNOPSIS + Tests whether a set of token values satisfies a manifest condition. + + .DESCRIPTION + A condition is a hashtable mapping parameter names to allowed values. + Each key must match for the condition to pass (AND logic across keys). + Values may be a single string or an array of strings (OR logic within + a key). + + Returns $true when every key in the condition has a matching token + value, $false otherwise. An empty condition always returns $true. + + This is the core evaluator used by Test-FileCondition and section + processing to decide whether files or content blocks should be + included or excluded during scaffolding. + + .PARAMETER Condition + Hashtable of parameter-name-to-allowed-value(s) entries. + + .PARAMETER Tokens + Hashtable of resolved token values to test against. + + .OUTPUTS + System.Boolean + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [hashtable]$Condition, + + [Parameter(Mandatory)] + [hashtable]$Tokens + ) + + foreach ($Key in $Condition.Keys) { + if (-not $Tokens.ContainsKey($Key)) { + return $false + } + + $Allowed = @($Condition[$Key]) + if ($Tokens[$Key] -notin $Allowed) { + return $false + } + } + + return $true +} diff --git a/src/Anvil/Public/Get-AnvilTemplate.ps1 b/src/Anvil/Public/Get-AnvilTemplate.ps1 index 0e03599..3209bf3 100644 --- a/src/Anvil/Public/Get-AnvilTemplate.ps1 +++ b/src/Anvil/Public/Get-AnvilTemplate.ps1 @@ -1,15 +1,18 @@ function Get-AnvilTemplate { <# .SYNOPSIS - Lists the available Anvil templates and CI providers. + Lists the available Anvil templates. .DESCRIPTION Inspects the bundled template directories shipped with Anvil and - returns objects describing each template and CI provider. Use this - to discover what New-AnvilModule can generate. + returns objects describing each template. Templates must contain + a template.psd1 manifest to be discovered. - A summary is also written to the information stream. Pipe to - Format-Table or use -InformationAction Continue to see it. + Each template object includes metadata from the manifest + (Description, Version, Parameters) and a Layers property listing + any layer options declared by the template (e.g. CI providers). + + A summary is also written to the information stream. .INPUTS None @@ -23,12 +26,12 @@ function Get-AnvilTemplate { .EXAMPLE Get-AnvilTemplate - Lists all base templates and CI providers with file counts. + Lists all available templates with metadata and layers. .EXAMPLE - Get-AnvilTemplate | Where-Object Type -eq 'CIProvider' + (Get-AnvilTemplate | Where-Object Name -eq 'Module').Layers - Returns only the CI provider entries. + Shows the available layers (e.g. CI providers) for the Module template. #> [CmdletBinding()] [OutputType([PSCustomObject])] @@ -36,46 +39,60 @@ function Get-AnvilTemplate { $TemplateRoot = $script:TemplateRoot - # Discover base templates - $BaseTemplates = Get-ChildItem -Path $TemplateRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -ne 'CI' } | + $Templates = Get-ChildItem -Path $TemplateRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'template.psd1') } | ForEach-Object { - $FileCount = (Get-ChildItem -Path $_.FullName -File -Recurse -ErrorAction SilentlyContinue).Count - [PSCustomObject]@{ - Name = $_.Name - Type = 'BaseTemplate' - FileCount = $FileCount - Path = $_.FullName - } - } - - # Discover CI providers - $CiRoot = Join-Path -Path $TemplateRoot -ChildPath 'CI' - $CiProviders = @() - if (Test-Path -Path $CiRoot) { - $CiProviders = Get-ChildItem -Path $CiRoot -Directory -ErrorAction SilentlyContinue | - ForEach-Object { - $FileCount = (Get-ChildItem -Path $_.FullName -File -Recurse -ErrorAction SilentlyContinue).Count + $TemplatePath = $_.FullName + $Manifest = Import-PowerShellDataFile -Path (Join-Path $TemplatePath 'template.psd1') -ErrorAction SilentlyContinue + if (-not $Manifest) { return } + + $FileCount = (Get-ChildItem -Path $TemplatePath -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne 'template.psd1' }).Count + + $Layers = @() + if ($Manifest.ContainsKey('Layers')) { + foreach ($Layer in $Manifest.Layers) { + $LayerRoot = Join-Path -Path $TemplateRoot -ChildPath $Layer.BasePath + if (Test-Path -Path $LayerRoot) { + $LayerDirs = Get-ChildItem -Path $LayerRoot -Directory -ErrorAction SilentlyContinue + foreach ($Dir in $LayerDirs) { + $Skip = if ($Layer.ContainsKey('Skip')) { $Layer.Skip } else { $null } + if ($Dir.Name -eq $Skip) { continue } + $LayerFileCount = (Get-ChildItem -Path $Dir.FullName -File -Recurse -ErrorAction SilentlyContinue).Count + $Layers += [PSCustomObject]@{ + Name = $Dir.Name + PathKey = $Layer.PathKey + FileCount = $LayerFileCount + Path = $Dir.FullName + } + } + } + } + } + + $Parameters = @($Manifest.Parameters | ForEach-Object { $_.Name }) + [PSCustomObject]@{ - Name = $_.Name - Type = 'CIProvider' - FileCount = $FileCount - Path = $_.FullName + Name = $Manifest.Name + Type = 'BaseTemplate' + Description = $Manifest.Description + Version = $Manifest.Version + Parameters = $Parameters + FileCount = $FileCount + Layers = $Layers + Path = $TemplatePath } } - } - - $All = @($BaseTemplates) + @($CiProviders) Write-Information '' Write-Information 'Anvil Templates' Write-Information '' - foreach ($T in $All) { - Write-Information " $($T.Type.PadRight(14)) $($T.Name.PadRight(20)) ($($T.FileCount) files)" + foreach ($T in $Templates) { + $LayerNames = if ($T.Layers.Count -gt 0) { " [Layers: $($T.Layers.Name -join ', ')]" } else { '' } + Write-Information " $($T.Name.PadRight(20)) v$($T.Version) ($($T.FileCount) files)$LayerNames" + Write-Information " $(' ' * 20) $($T.Description)" } Write-Information '' - Write-Information 'Supported licenses: MIT, Apache2, None' - Write-Information '' - return $All + return $Templates } diff --git a/src/Anvil/Public/Invoke-AnvilBuild.ps1 b/src/Anvil/Public/Invoke-AnvilBuild.ps1 new file mode 100644 index 0000000..f9abcdd --- /dev/null +++ b/src/Anvil/Public/Invoke-AnvilBuild.ps1 @@ -0,0 +1,87 @@ +function Invoke-AnvilBuild { + <# + .SYNOPSIS + Runs the InvokeBuild pipeline in an Anvil project. + + .DESCRIPTION + Locates build/module.build.ps1 in the project root and invokes it + with the specified tasks. If no tasks are specified, runs the + default pipeline. + + .PARAMETER Task + One or more build tasks to run. When omitted, runs the default + pipeline (Clean, Validate, Format, Lint, Test, Build, + IntegrationTest, Package). + + .PARAMETER NewVersion + Version number to inject into the compiled module manifest. + + .PARAMETER Prerelease + Prerelease label to set on the compiled module manifest. + + .PARAMETER Path + The project root directory. If not provided, walks up from the + current directory to find the project root. + + .EXAMPLE + Invoke-AnvilBuild + + .EXAMPLE + Invoke-AnvilBuild -Task Lint, Test + + .EXAMPLE + Invoke-AnvilBuild -Task Release -NewVersion 1.0.0 + + .INPUTS + None + + .OUTPUTS + None + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Justification = 'Invoke-Build is the canonical entry point for InvokeBuild')] + param( + [Parameter(Position = 0)] + [string[]]$Task, + + [Parameter()] + [string]$NewVersion, + + [Parameter()] + [string]$Prerelease, + + [Parameter()] + [string]$Path + ) + + if ($Path) { + $StartPath = $Path + } else { + $StartPath = $PWD.Path + } + + $Resolved = Resolve-AnvilProjectRoot -StartPath $StartPath + if (-not $Resolved) { return } + + $BuildScript = Join-Path $Resolved.ProjectRoot 'build' | Join-Path -ChildPath 'module.build.ps1' + + if (-not (Test-Path $BuildScript)) { + Write-Error "Build script not found: $BuildScript" + return + } + + if (-not (Get-Command -Name 'Invoke-Build' -ErrorAction SilentlyContinue)) { + Write-Error "Invoke-Build is not installed. Run Invoke-AnvilBootstrapDeps first." + return + } + + $BuildParams = @{ + File = $BuildScript + } + if ($Task) { $BuildParams['Task'] = $Task } + if ($NewVersion) { $BuildParams['NewVersion'] = $NewVersion } + if ($Prerelease) { $BuildParams['Prerelease'] = $Prerelease } + + $Script = [scriptblock]::Create('param($Params) Invoke-Build @Params') + $PSCmdlet.InvokeCommand.InvokeScript($PSCmdlet.SessionState, $Script, @($BuildParams)) +} diff --git a/src/Anvil/Public/New-AnvilModule.ps1 b/src/Anvil/Public/New-AnvilModule.ps1 index 18eaf85..4d9962a 100644 --- a/src/Anvil/Public/New-AnvilModule.ps1 +++ b/src/Anvil/Public/New-AnvilModule.ps1 @@ -77,6 +77,12 @@ function New-AnvilModule { License URI for the module manifest. Default: empty string. + .PARAMETER Template + Template to scaffold from. Accepts a template name (looked up + under the bundled Templates directory) or an absolute path to a + directory containing a template.psd1 manifest. + Default: Module. + .PARAMETER Force Removes and re-creates the destination directory if it already exists. @@ -193,6 +199,9 @@ function New-AnvilModule { })] [string]$LicenseUri, + [Parameter()] + [string]$Template = 'Module', + [Parameter()] [switch]$Force, @@ -206,44 +215,54 @@ function New-AnvilModule { [switch]$PassThru ) - $Defaults = @{ - Description = 'A PowerShell module scaffolded by Anvil.' - CompanyName = '' - MinPowerShellVersion = '5.1' - CompatiblePSEditions = @('Desktop', 'Core') - CIProvider = 'GitHub' - License = 'MIT' - CoverageThreshold = 80 - IncludeDocs = $false - Tags = @() - ProjectUri = '' - LicenseUri = '' - GitInit = $false + # Resolve template path + if (Test-Path -Path $Template -PathType Container) { + $BaseTemplatePath = $Template + } else { + $BaseTemplatePath = Join-Path -Path $script:TemplateRoot -ChildPath $Template + } + + $Manifest = Read-TemplateManifest -TemplatePath $BaseTemplatePath + + # Map cmdlet parameters to manifest parameter names + $OperationalParams = @('DestinationPath', 'Force', 'GitInit', 'Interactive', 'PassThru', 'Template') + $ManifestBound = @{} + foreach ($Key in $PSBoundParameters.Keys) { + if ($Key -notin $OperationalParams) { + $ManifestBound[$Key] = $PSBoundParameters[$Key] + } } - $PromptParams = @{ - BoundParams = $PSBoundParameters - Defaults = $Defaults - Interactive = [bool]$Interactive + # Resolve template parameters via manifest + $Resolved = Invoke-ManifestPrompt -Manifest $Manifest -BoundParams $ManifestBound -Interactive ([bool]$Interactive) + + # Resolve operational parameters + $ResolvedDestination = if ($PSBoundParameters.ContainsKey('DestinationPath')) { + $DestinationPath + } elseif ($Interactive) { + Read-PromptValue -Prompt ' Destination path' -Default $PWD.Path + } else { + if (-not $DestinationPath) { + throw "'DestinationPath' is required. Use -Interactive for the guided wizard." + } + $DestinationPath } - $Resolved = Invoke-InteractivePrompt @PromptParams - - $Config = @{ - ModuleName = $Resolved.Name - Author = $Resolved.Author - Description = $Resolved.Description - CIProvider = $Resolved.CIProvider - License = $Resolved.License - CoverageThreshold = $Resolved.CoverageThreshold - MinPowerShellVersion = $Resolved.MinPowerShellVersion + + $ResolvedGitInit = if ($PSBoundParameters.ContainsKey('GitInit')) { + [bool]$GitInit + } elseif ($Interactive) { + $GitInput = Read-PromptValue -Prompt ' Initialize git repository? (y/n)' -Default 'n' + $GitInput -match '^[Yy]' + } else { + $false } - Assert-ValidConfiguration -Configuration $Config + Assert-ManifestConfiguration -Manifest $Manifest -Configuration $Resolved - $ProjectRoot = Join-Path -Path $Resolved.DestinationPath -ChildPath $Resolved.Name + $ProjectRoot = Join-Path -Path $ResolvedDestination -ChildPath $Resolved.Name if (Test-Path -Path $ProjectRoot) { - if ($Resolved.Force) { + if ($Force) { if ($PSCmdlet.ShouldProcess($ProjectRoot, 'Remove existing project directory')) { Remove-Item -Path $ProjectRoot -Recurse -Force } else { @@ -254,71 +273,62 @@ function New-AnvilModule { } } - $ModuleGuid = [guid]::NewGuid().ToString() - $Year = (Get-Date).Year.ToString() - $DocsEnabled = if ($Resolved.IncludeDocs) { 'true' } else { 'false' } - - # Format array values for .psd1 embedding - $EditionsString = "@('" + ($Resolved.CompatiblePSEditions -join "', '") + "')" - $TagsString = if ($Resolved.Tags.Count -gt 0) { - "@('" + ($Resolved.Tags -join "', '") + "')" - } else { - "@()" + # Build token table from manifest parameters + $Tokens = @{} + foreach ($Param in $Manifest.Parameters) { + $ParamName = $Param.Name + $Value = $Resolved[$ParamName] + $Formatter = if ($Param.ContainsKey('Format')) { $Param.Format } else { 'raw' } + $Tokens[$ParamName] = Format-TokenValue -Value $Value -Formatter $Formatter } - $Tokens = @{ - ModuleName = $Resolved.Name - Author = $Resolved.Author - Description = $Resolved.Description - CompanyName = $Resolved.CompanyName - ModuleGuid = $ModuleGuid - Year = $Year - CoverageThreshold = $Resolved.CoverageThreshold.ToString() - MinPowerShellVersion = $Resolved.MinPowerShellVersion - CompatiblePSEditions = $EditionsString - License = $Resolved.License - CIProvider = $Resolved.CIProvider - IncludeDocs = $DocsEnabled - Tags = $TagsString - ProjectUri = $Resolved.ProjectUri - LicenseUri = $Resolved.LicenseUri + # Resolve auto-tokens + foreach ($Auto in $Manifest.AutoTokens) { + $Tokens[$Auto.Name] = Resolve-AutoToken -Source $Auto.Source } + # Extract conditions from manifest + $IncludeWhen = if ($Manifest.ContainsKey('IncludeWhen')) { $Manifest.IncludeWhen } else { @{} } + $ExcludeWhen = if ($Manifest.ContainsKey('ExcludeWhen')) { $Manifest.ExcludeWhen } else { @{} } + $Sections = if ($Manifest.ContainsKey('Sections')) { $Manifest.Sections } else { @{} } + if ($PSCmdlet.ShouldProcess($ProjectRoot, "Scaffold module project '$($Resolved.Name)'")) { Write-Host "[Anvil] Creating project: $($Resolved.Name)" -ForegroundColor Cyan Write-Host "[Anvil] Destination: $ProjectRoot" -ForegroundColor White - # 1. Expand base module template - $BaseTemplatePath = Join-Path -Path $script:TemplateRoot -ChildPath 'Module' - $FileCount = Invoke-TemplateEngine -SourcePath $BaseTemplatePath -DestinationPath $ProjectRoot -Tokens $Tokens - - Write-Host "[Anvil] Base template: $FileCount files" -ForegroundColor DarkGray - - # 2. Layer CI-specific templates - if ($Resolved.CIProvider -ne 'None') { - $CiCount = Copy-CITemplates -Provider $Resolved.CIProvider -DestinationPath $ProjectRoot -Tokens $Tokens - Write-Host "[Anvil] CI ($($Resolved.CIProvider)): $CiCount files" -ForegroundColor DarkGray + # 1. Expand base module template with conditions + $EngineParams = @{ + SourcePath = $BaseTemplatePath + DestinationPath = $ProjectRoot + Tokens = $Tokens + ExcludePatterns = @('template.psd1') + IncludeWhen = $IncludeWhen + ExcludeWhen = $ExcludeWhen + Sections = $Sections } + $FileCount = Invoke-TemplateEngine @EngineParams - # 3. Remove license file if 'None' - if ($Resolved.License -eq 'None') { - $LicPath = Join-Path -Path $ProjectRoot -ChildPath 'LICENSE' - if (Test-Path -Path $LicPath) { - Remove-Item -Path $LicPath -Force - } - } + Write-Host "[Anvil] Base template: $FileCount files" -ForegroundColor DarkGray - # 4. Remove docs template files if docs not requested - if (-not $Resolved.IncludeDocs) { - $DocsDir = Join-Path -Path $ProjectRoot -ChildPath 'docs' - if (Test-Path -Path $DocsDir) { - Get-ChildItem -Path $DocsDir -File -Recurse -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue + # 2. Process layers from manifest + if ($Manifest.ContainsKey('Layers')) { + foreach ($Layer in $Manifest.Layers) { + $LayerValue = $Resolved[$Layer.PathKey] + if ($Layer.ContainsKey('Skip') -and $LayerValue -eq $Layer.Skip) { + continue + } + $LayerRoot = Split-Path -Path $BaseTemplatePath -Parent + $LayerPath = Join-Path -Path $LayerRoot -ChildPath $Layer.BasePath | + Join-Path -ChildPath $LayerValue + if (Test-Path -Path $LayerPath) { + $LayerCount = Invoke-TemplateEngine -SourcePath $LayerPath -DestinationPath $ProjectRoot -Tokens $Tokens + Write-Host "[Anvil] Layer ($LayerValue): $LayerCount files" -ForegroundColor DarkGray + } } } - # 5. Write Anvil version stamp + # 3. Write Anvil version stamp $AnvilVersion = (Get-Module -Name 'Anvil').Version.ToString() Set-Content -Path (Join-Path $ProjectRoot '.ANVIL_VERSION') -Value $AnvilVersion -NoNewline @@ -326,20 +336,18 @@ function New-AnvilModule { Write-Host "[Anvil] Project '$($Resolved.Name)' scaffolded successfully!" -ForegroundColor Green Write-Host "[Anvil] Next steps:" -ForegroundColor White Write-Host " cd $ProjectRoot" -ForegroundColor White - Write-Host " ./build/bootstrap.ps1" -ForegroundColor White - Write-Host " Invoke-Build -File ./build/module.build.ps1" -ForegroundColor White + Write-Host " Invoke-AnvilBootstrapDeps" -ForegroundColor White + Write-Host " Invoke-AnvilBuild" -ForegroundColor White Write-Host '' - # 6. Optionally initialise a git repository - if ($Resolved.GitInit) { + # 4. Optionally initialise a git repository + if ($ResolvedGitInit) { $GitCmd = Get-Command -Name git -ErrorAction SilentlyContinue if ($GitCmd) { Push-Location -Path $ProjectRoot try { & git init --quiet - & git add -A - & git commit --quiet -m 'Initial scaffold via Anvil' - Write-Host "[Anvil] Git repository initialised with initial commit." -ForegroundColor DarkGray + Write-Host "[Anvil] Git repository initialised." -ForegroundColor DarkGray } finally { Pop-Location } @@ -348,7 +356,7 @@ function New-AnvilModule { } } - if ($Resolved.PassThru) { + if ($PassThru) { return $ProjectRoot } } diff --git a/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines-release.yml.tmpl b/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines-release.yml.tmpl index 0e6449d..a555661 100644 --- a/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines-release.yml.tmpl +++ b/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines-release.yml.tmpl @@ -1,4 +1,4 @@ -# Release Pipeline -- <%ModuleName%> +# Release Pipeline -- <%Name%> # Triggered by version tags (v*). # Validates on all platforms, then publishes. # Setup: add PSGALLERY_API_KEY as a secret pipeline variable. diff --git a/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines.yml.tmpl b/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines.yml.tmpl index 9a82320..b14d820 100644 --- a/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines.yml.tmpl +++ b/src/Anvil/Templates/CI/AzurePipelines/azure-pipelines.yml.tmpl @@ -1,4 +1,4 @@ -# CI Pipeline -- <%ModuleName%> +# CI Pipeline -- <%Name%> trigger: branches: diff --git a/src/Anvil/Templates/CI/GitHub/.github/workflows/ci.yml.tmpl b/src/Anvil/Templates/CI/GitHub/.github/workflows/ci.yml.tmpl index b861fdd..3355467 100644 --- a/src/Anvil/Templates/CI/GitHub/.github/workflows/ci.yml.tmpl +++ b/src/Anvil/Templates/CI/GitHub/.github/workflows/ci.yml.tmpl @@ -1,4 +1,4 @@ -# CI Pipeline -- <%ModuleName%> +# CI Pipeline -- <%Name%> name: CI diff --git a/src/Anvil/Templates/CI/GitHub/.github/workflows/release.yml.tmpl b/src/Anvil/Templates/CI/GitHub/.github/workflows/release.yml.tmpl index def8b26..076fa90 100644 --- a/src/Anvil/Templates/CI/GitHub/.github/workflows/release.yml.tmpl +++ b/src/Anvil/Templates/CI/GitHub/.github/workflows/release.yml.tmpl @@ -1,4 +1,4 @@ -# Release Pipeline -- <%ModuleName%> +# Release Pipeline -- <%Name%> # Triggered by version tags (v*). # Validates on all platforms, then publishes from Ubuntu. # Setup: add PSGALLERY_API_KEY as a secret in the 'psgallery' environment. diff --git a/src/Anvil/Templates/CI/GitLab/.gitlab-ci.yml.tmpl b/src/Anvil/Templates/CI/GitLab/.gitlab-ci.yml.tmpl index 5536991..5398264 100644 --- a/src/Anvil/Templates/CI/GitLab/.gitlab-ci.yml.tmpl +++ b/src/Anvil/Templates/CI/GitLab/.gitlab-ci.yml.tmpl @@ -1,4 +1,4 @@ -# CI/CD Pipeline -- <%ModuleName%> +# CI/CD Pipeline -- <%Name%> stages: - ci diff --git a/src/Anvil/Templates/Module/.gitattributes b/src/Anvil/Templates/Module/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/src/Anvil/Templates/Module/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/src/Anvil/Templates/Module/CONTRIBUTING.md.tmpl b/src/Anvil/Templates/Module/CONTRIBUTING.md.tmpl index 05003fa..1edc15e 100644 --- a/src/Anvil/Templates/Module/CONTRIBUTING.md.tmpl +++ b/src/Anvil/Templates/Module/CONTRIBUTING.md.tmpl @@ -1,34 +1,49 @@ -# Contributing to <%ModuleName%> +# Contributing to <%Name%> ## Prerequisites -- **PowerShell 7.4+** (recommended) or **Windows PowerShell 5.1** -- No other tooling required — the bootstrap script handles everything. +- **PowerShell 7.2+** +- [Anvil](https://github.com/f0oster/Anvil) (`Install-Module -Name Anvil -Scope CurrentUser`) ## Setup ```powershell git clone -cd <%ModuleName%> -./build/bootstrap.ps1 +cd <%Name%> +Invoke-AnvilBootstrapDeps ``` -## Development loop +## Development ```powershell -Invoke-Build -File ./build/module.build.ps1 -Task Lint, Test +Invoke-AnvilBuild -Task Lint, Test +``` + +Run the full pipeline before submitting: + +```powershell +Invoke-AnvilBuild +``` + +To test your changes interactively: + +```powershell +Import-AnvilModule ``` ## Conventions -- One function per file, filename matches function name. -- Public functions in `src/<%ModuleName%>/Public/`, private in `Private/`. -- Always use `Join-Path` — never backslash concatenation. -- Pester 5 syntax only (`New-PesterConfiguration`, `BeforeAll`, `BeforeDiscovery`). -- Tag tests with `'Unit'` or `'Integration'`. +- One function per file, filename matches function name +- Public functions in `src/<%Name%>/Public/`, private in `Private/` + +## Testing + +Unit tests cover individual functions. Integration tests validate the compiled build output. Both must pass before merging. + +When adding a new private function, include an "is not exported" test in the test file. ## Pull requests 1. Branch from `main` -2. Run `Invoke-Build -File ./build/module.build.ps1` — ensure it passes +2. Run `Invoke-AnvilBuild` and ensure it passes 3. Open a PR against `main` diff --git a/src/Anvil/Templates/Module/LICENSE.tmpl b/src/Anvil/Templates/Module/LICENSE.tmpl index 106f38a..a711b5e 100644 --- a/src/Anvil/Templates/Module/LICENSE.tmpl +++ b/src/Anvil/Templates/Module/LICENSE.tmpl @@ -1,3 +1,4 @@ +<%#section LicenseMIT%> MIT License Copyright (c) <%Year%> <%Author%> @@ -19,3 +20,195 @@ 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. +<%#endsection%> +<%#section LicenseApache2%> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright <%Year%> <%Author%> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +<%#endsection%> diff --git a/src/Anvil/Templates/Module/README.md.tmpl b/src/Anvil/Templates/Module/README.md.tmpl index 22811e3..91ec9e1 100644 --- a/src/Anvil/Templates/Module/README.md.tmpl +++ b/src/Anvil/Templates/Module/README.md.tmpl @@ -1,50 +1,167 @@ -# <%ModuleName%> +# <%Name%> <%Description%> -## Quick start +## Prerequisites + +PowerShell 7.2 or later is required for the build tooling. The module itself can target any version down to 5.1. + +You do not need to install build tools manually. The bootstrap script handles everything. + +## First build and bootstrap + +```powershell +Invoke-AnvilBootstrapDeps +Invoke-AnvilBuild +``` + +`Invoke-AnvilBootstrapDeps` installs pinned versions of InvokeBuild, Pester, PSScriptAnalyzer, and platyPS via [ModuleFast](https://github.com/JustinGrote/ModuleFast). `Invoke-AnvilBuild` runs the full pipeline: format, lint, test, compile, and package. + +The initial build is expected to pass. + +## Project layout + +``` +<%Name%>/ +├── src/<%Name%>/ Module source +│ ├── <%Name%>.psd1 Manifest +│ ├── <%Name%>.psm1 Module loader +│ ├── Imports.ps1 Module-scoped init +│ ├── Public/ Exported functions +│ ├── Private/ Internal helpers +│ └── PrivateClasses/ PowerShell classes +├── tests/ +│ ├── unit/ Pester 5 unit tests +│ └── integration/ Post-build validation +├── build/ +│ ├── module.build.ps1 Build tasks +│ ├── bootstrap.ps1 Dependency installer +│ ├── build.settings.psd1 Build configuration +│ └── build.requires.psd1 Build tool versions +├── requirements.psd1 Runtime module dependencies +└── docs/ Documentation +``` + +The module source is organized into three directories: + +**`Public/`** contains exported functions. These are the commands users see after `Import-Module <%Name%>`. Each function lives in its own file, and the filename must match the function name. The build system discovers all `.ps1` files in this directory and populates `FunctionsToExport` in the published manifest automatically. + +**`Private/`** contains internal helper functions. These are loaded into the module's scope but not exported. Users cannot call them directly. Use private functions to break up complex logic without expanding the public API. + +**`PrivateClasses/`** contains PowerShell classes. These are loaded before any functions, so both Public and Private code can use them. Classes are primarily for internal logic and data structures. See the section below for constraints and usage patterns. + +The convention is one function per file, with the filename matching the function name. Class files should contain related types. All three directories support nested subdirectories for organization. + +## Development workflow + +### Running the development module + +During development, the `.psm1` dot-sources each file individually: + +1. `Imports.ps1` loads first so that module-scoped variables and assemblies are available +2. `PrivateClasses/` loads next so that classes are defined before any functions that reference them +3. `Public/` and `Private/` load last, with access to both classes and module-scoped state + +After making changes, reload the module with `Import-AnvilModule` to re-import it from source. + +When you run the build, all source files are compiled into a single `.psm1` in the same order for distribution. + +### Module-wide state + +`Imports.ps1` is the place for module-scoped variables, assembly loading, and any setup that your module depend on. It is the entry point for your module when it is imported. ```powershell -git clone -cd <%ModuleName%> -./build/bootstrap.ps1 -Invoke-Build -File ./build/module.build.ps1 +$script:DefaultTimeout = 30 +$script:DataPath = Join-Path -Path $PSScriptRoot -ChildPath 'Data' ``` -## Development +Any `$script:` variable defined here is accessible from all Public and Private functions. Do *not* define functions in this file. Use `Public/` or `Private/` for that. -Add functions, classes, and dependencies using Anvil commands: +### Adding functions ```powershell New-AnvilFunction -FunctionName 'Get-Widget' -Scope Public New-AnvilFunction -FunctionName 'Format-Row' -Scope Private -New-AnvilClass -ClassName 'WidgetResult' -Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0' -Invoke-AnvilBootstrapDeps -Import-AnvilModule ``` -Lint and test: +Each command creates the source file and a matching Pester test. Implement the function, then replace placeholder assertions in the test. + +Use `-Location` to organize functions into subdirectories: ```powershell -Invoke-Build -File ./build/module.build.ps1 -Task Lint, Test +New-AnvilFunction -FunctionName 'Get-DnsRecord' -Scope Public -Location 'Dns' +``` + +### Adding classes + +```powershell +New-AnvilClass -ClassName 'ConnectionResult' +``` + +Class files in `PrivateClasses/` are loaded before module functions, so your class types are available to all module code. Define tightly coupled classes in the same file, in dependency order. + +**Class changes require a new session.** +PowerShell does not reload class definitions. Re-importing the module does not update class definitions. Restart the session to pick up changes. + +**No access to module scope.** +Class methods cannot use `$script:` variables. Pass any required state via constructors or method parameters. + +**Class visibility.** +PowerShell does not support explicitly exporting classes, and `Export-ModuleMember` has no effect on them. Use classes primarily for internal logic and type modeling. When functionality needs to be exposed to the module user, wrap class usage behind public module functions. + +### Writing tests + +Tests mirror the source structure. A function at `src/<%Name%>/Public/Get-Widget.ps1` has a test at `tests/unit/Public/Get-Widget.Tests.ps1`. + +Public function tests call functions by name. Private functions and classes are tested using `InModuleScope`: + +```powershell +It 'formats the output correctly' { + InModuleScope '<%Name%>' { + Format-Internal -Name 'test' | Should -Be 'expected' + } +} +``` + +Variables from the test scope are not visible inside `InModuleScope`. Use `-ArgumentList` with a matching `param()` block: + +```powershell +It 'validates the configuration' { + $Config = @{ Name = 'Test'; Timeout = 30 } + InModuleScope '<%Name%>' -ArgumentList $Config { + param($Config) + Assert-ValidConfig -Configuration $Config | Should -Not -Throw + } +} +``` + +### Running the build + +```powershell +Invoke-AnvilBuild # full pipeline +Invoke-AnvilBuild -Task Test # unit tests only +Invoke-AnvilBuild -Task Lint # lint only +Invoke-AnvilBuild -Task DevCC # coverage for VS Code +``` + +The full pipeline runs: Clean, Validate, Format, Lint, Test, Build, IntegrationTest, Package. During development, run individual tasks as needed. Run the full pipeline before committing. + +The DevCC task generates a `coverage.xml` file in Coverage Gutters format. Install the [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) extension to view coverage in VS Code. + +### Managing dependencies + +```powershell +Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0' +Invoke-AnvilBootstrapDeps ``` -## Guides +This updates `requirements.psd1` and the module manifest's `RequiredModules`, then installs the dependency. Version specs follow [ModuleFast](https://github.com/JustinGrote/ModuleFast) syntax. -This project was scaffolded with [Anvil](https://github.com/f0oster/Anvil). The following guides are included in the `getting-started/` directory: +Build tools are managed separately in `build/build.requires.psd1`. Do not include them in `requirements.psd1`. -| Guide | What it covers | -|-------|---------------| -| [Getting Started](getting-started/getting-started.md) | Scaffold a project, bootstrap, first build | -| [Development](getting-started/development.md) | Adding functions, classes, dependencies, testing, the daily workflow | -| [Project Structure](getting-started/project-structure.md) | What every file and directory does | -| [Build Pipeline](getting-started/build-pipeline.md) | Every build task explained, settings reference | -| [CI/CD Integration](getting-started/cicd-integration.md) | GitHub Actions, Azure Pipelines, GitLab CI setup | -| [Customization](getting-started/customization.md) | Custom lint rules, types, formats, build tasks | -| [FAQ](getting-started/faq.md) | Common questions and troubleshooting | +### Version management -For the full Anvil command reference, see the [Anvil documentation](https://github.com/f0oster/Anvil/tree/main/docs). +The development module does not carry a version. The build process assigns the version for distributable artifacts. ## License diff --git a/src/Anvil/Templates/Module/build/build.settings.psd1.tmpl b/src/Anvil/Templates/Module/build/build.settings.psd1.tmpl index 6c4f913..03dfe02 100644 --- a/src/Anvil/Templates/Module/build/build.settings.psd1.tmpl +++ b/src/Anvil/Templates/Module/build/build.settings.psd1.tmpl @@ -1,10 +1,10 @@ -# Build settings for <%ModuleName%> +# Build settings for <%Name%> # # Edit these values to customise the build pipeline. Any setting removed # or set to an invalid type will fall back to the default in # build.settings_DEFAULTS_DO_NOT_EDIT.psd1. @{ - ModuleName = '<%ModuleName%>' + ModuleName = '<%Name%>' CoverageThreshold = <%CoverageThreshold%> IncludeDocs = $<%IncludeDocs%> TestOutputFormat = 'NUnitXml' diff --git a/src/Anvil/Templates/Module/build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1.tmpl b/src/Anvil/Templates/Module/build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1.tmpl index 5f6e7e4..075324e 100644 --- a/src/Anvil/Templates/Module/build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1.tmpl +++ b/src/Anvil/Templates/Module/build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1.tmpl @@ -5,7 +5,7 @@ # Any setting missing or invalid in build.settings.psd1 will # fall back to the value defined here. @{ - ModuleName = '<%ModuleName%>' + ModuleName = '<%Name%>' CoverageThreshold = <%CoverageThreshold%> IncludeDocs = $<%IncludeDocs%> TestOutputFormat = 'NUnitXml' diff --git a/src/Anvil/Templates/Module/build/module.build.ps1.tmpl b/src/Anvil/Templates/Module/build/module.build.ps1.tmpl index 4a23eea..38319a8 100644 --- a/src/Anvil/Templates/Module/build/module.build.ps1.tmpl +++ b/src/Anvil/Templates/Module/build/module.build.ps1.tmpl @@ -1,11 +1,16 @@ #Requires -Version <%MinPowerShellVersion%> <# .SYNOPSIS - InvokeBuild build script for <%ModuleName%>. + InvokeBuild build script for <%Name%>. .DESCRIPTION Task graph: +<%#section DocsTaskGraph%> . (default) -> Clean, Validate, Format, Lint, Test, Docs, Build, IntegrationTest, Package +<%#endsection%> +<%#section NoDocsTaskGraph%> + . (default) -> Clean, Validate, Format, Lint, Test, Build, IntegrationTest, Package +<%#endsection%> Release -> Version + . + Publish DevCC -> generate Coverage Gutters output for VS Code @@ -198,14 +203,10 @@ task Test { Write-BuildFooter 'Unit tests complete' } +<%#section DocsTask%> task Docs { Write-BuildHeader 'Documentation (platyPS)' - if (-not $script:IncludeDocs) { - Write-Build DarkGray ' Docs disabled in build.settings.psd1 -- skipping' - return - } - if (-not (Get-Module -ListAvailable -Name 'platyPS' -ErrorAction SilentlyContinue)) { Write-Build Yellow ' platyPS not installed -- skipping docs generation' return @@ -239,6 +240,7 @@ task Docs { Write-BuildFooter 'Docs generation complete' } +<%#endsection%> task Build { Write-BuildHeader 'Build (compile module)' @@ -349,6 +351,7 @@ task Build { New-ModuleManifest @ManifestParams +<%#section DocsBuildStep%> # Generate MAML help from markdown docs $MdDir = Join-Path -Path $script:DocsDir -ChildPath 'commands' if ((Test-Path -Path $MdDir) -and (Get-Module -ListAvailable -Name 'platyPS' -ErrorAction SilentlyContinue)) { @@ -357,6 +360,7 @@ task Build { New-ExternalHelp -Path $MdDir -OutputPath $HelpDir -Force | Out-Null Write-Build White ' Generated MAML help' } +<%#endsection%> Write-Build White " Compiled $($PublicFunctions.Count) public + private functions into .psm1" Write-BuildFooter 'Build complete' @@ -447,5 +451,10 @@ task DevCC { } # Composite tasks +<%#section DocsComposite%> task . Clean, Validate, Format, Lint, Test, Docs, Build, IntegrationTest, Package +<%#endsection%> +<%#section NoDocsComposite%> +task . Clean, Validate, Format, Lint, Test, Build, IntegrationTest, Package +<%#endsection%> task Release Version, ., Publish diff --git a/src/Anvil/Templates/Module/docs/README.md.tmpl b/src/Anvil/Templates/Module/docs/README.md.tmpl index 72c14bf..1d80a40 100644 --- a/src/Anvil/Templates/Module/docs/README.md.tmpl +++ b/src/Anvil/Templates/Module/docs/README.md.tmpl @@ -2,5 +2,5 @@ Generated by the `Docs` build task using [platyPS](https://github.com/PowerShell/platyPS). -Run `Invoke-Build -File ./build/module.build.ps1 -Task Docs` to generate help stubs -under `cmdlets/` and compiled XML help under `src/<%ModuleName%>/en-US/`. +Run `Invoke-AnvilBuild -Task Docs` to generate help stubs +under `cmdlets/` and compiled XML help under `src/<%Name%>/en-US/`. diff --git a/src/Anvil/Templates/Module/getting-started/build-pipeline.md b/src/Anvil/Templates/Module/getting-started/build-pipeline.md deleted file mode 100644 index dec9952..0000000 --- a/src/Anvil/Templates/Module/getting-started/build-pipeline.md +++ /dev/null @@ -1,168 +0,0 @@ -# Build Pipeline - -The build is powered by [InvokeBuild](https://github.com/nightroman/Invoke-Build). All tasks are defined in `build/module.build.ps1` and configured via `build/build.settings.psd1`. Default values are stored in `build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1` — any missing or invalid setting in your config falls back to its default automatically. - -## Pipelines - -There are two composite tasks. The default pipeline runs everything except publishing: - -``` -. (default) → Clean, Validate, Format, Lint, Test, Docs, Build, IntegrationTest, Package -``` - -The release pipeline adds Version at the start and Publish at the end: - -``` -Release → Version, ., Publish -``` - -## Running the build - -```powershell -# Full default pipeline -Invoke-Build -File ./build/module.build.ps1 - -# Specific tasks -Invoke-Build -File ./build/module.build.ps1 -Task Lint, Test - -# Release with version injection -Invoke-Build -File ./build/module.build.ps1 -Task Release -NewVersion 1.0.0 - -# Release with prerelease label -Invoke-Build -File ./build/module.build.ps1 -Task Release -NewVersion 1.0.0 -Prerelease beta1 -``` - -During development, `Invoke-Build -Task Lint, Test` is the fastest feedback loop. It skips formatting, docs, compilation, and packaging — just checks your code and runs tests. - -## Build settings - -All settings live in `build/build.settings.psd1`. Edit this file to customise the build — you never need to modify `module.build.ps1`. - -| Setting | Default | Valid values | What it controls | -|---------|---------|-------------|-----------------| -| `ModuleName` | *(your module)* | Non-empty string | Module to build | -| `CoverageThreshold` | `80` | `0`–`100` | Minimum code coverage percentage (0 to disable) | -| `IncludeDocs` | `$true` or `$false` | Boolean | Whether the Docs task generates platyPS documentation | -| `TestOutputFormat` | `'NUnitXml'` | `NUnitXml`, `NUnit2.5`, `NUnit3`, `JUnitXml` | Test result format for CI reporting | -| `TestVerbosity` | `'Detailed'` | `None`, `Normal`, `Detailed`, `Diagnostic` | Pester output verbosity | -| `LintFailOn` | `@('Warning', 'Error')` | `Error`, `Warning`, `Information`, `ParseError` | Severity levels that fail the build | -| `AssetDirectories` | `@('Types', 'Formats', 'Assemblies')` | Array of strings | Extra directories copied to the staged module | - -If you remove a setting or set it to an invalid value, the build uses the default from `build/build.settings_DEFAULTS_DO_NOT_EDIT.psd1` and prints a warning. - -## Task reference - -### Clean - -Deletes and recreates the `artifacts/` directory with subdirectories for `package/`, `testResults/`, and `archive/`. This ensures every build starts from a clean state. - -### Validate - -Sanity checks before doing real work. Verifies the module manifest exists and is valid, confirms the `.psm1` exists, and reports the PowerShell version. If the manifest is malformed, the build fails here with a clear error rather than somewhere deeper in the pipeline. - -### Format - -Runs `Invoke-Formatter` on every `.ps1` file in the module source directory using the rules from `PSScriptAnalyzerSettings.psd1`. This auto-fixes formatting issues — indentation, whitespace around operators, brace placement — so the Lint task only reports substantive problems. - -This task modifies your source files in place. If you're tracking formatting changes, commit before running the build. - -### Lint - -Runs `Invoke-ScriptAnalyzer` against the module source with the settings from `PSScriptAnalyzerSettings.psd1`. It also loads any `.psm1` files found in `build/analyzers/` as custom rules. - -The build fails if any issues matching the `LintFailOn` severities are found (default: Warning and Error). Information-level findings are reported but don't fail the build unless you add `'Information'` to `LintFailOn`. - -The custom rules that ship with Anvil projects catch: - -| Rule | What it catches | -|------|----------------| -| AvoidProcessWithoutPipeline | `process` block in a function that doesn't accept pipeline input | -| AvoidNestedFunctions | Function definitions inside other functions | -| AvoidSmartQuotes | Curly/smart quote characters copied from word processors | -| AvoidEmptyNamedBlocks | Empty `begin`, `process`, `end`, or `dynamicparam` blocks | -| AvoidNewObjectPSObject | `New-Object PSObject` instead of `[PSCustomObject]@{}` | -| AvoidWriteOutput | Unnecessary `Write-Output` (output flows implicitly in PowerShell) | - -To disable any rule, add its name to `ExcludeRules` in `PSScriptAnalyzerSettings.psd1`. To add your own rules, drop a `.psm1` file in `build/analyzers/`. - -### Test - -Runs Pester 5 unit tests from `tests/unit/` with code coverage enabled. Coverage is measured against `.ps1` files in `PrivateClasses/`, `Public/`, and `Private/`. - -The test result format and verbosity are controlled by the `TestOutputFormat` and `TestVerbosity` settings. The `PESTER_OUTPUT_FORMAT` environment variable overrides `TestOutputFormat` if set (useful for CI systems that need a specific format without changing the settings file). - -The test task fails the build if any test fails or if coverage drops below `CoverageThreshold` (default: 80%). Set the threshold to 0 to disable coverage enforcement while keeping the report. - -### Docs - -This task is controlled by the `IncludeDocs` setting. When set to `$false`, the task skips automatically. You can toggle it at any time in `build/build.settings.psd1` without editing the build script. - -Generates and maintains platyPS markdown documentation in `docs/commands/`. The behavior depends on whether documentation already exists: - -- **First run** (no `docs/commands/` directory): generates markdown for every exported function from the module's comment-based help using `New-MarkdownHelp` -- **Subsequent runs**: updates existing markdown with `Update-MarkdownHelp`, which refreshes parameter metadata and syntax blocks while preserving any manual edits you've made to descriptions, examples, and notes - -The generated markdown is meant to be committed and maintained as source. Edit the files to add richer descriptions, usage notes, or links — the update process won't overwrite your changes. - -If platyPS isn't installed, the Docs task skips gracefully. - -### Build - -Produces the compiled module in `artifacts/package//`. This is the most complex task and does several things: - -1. **Copies static assets** — directories listed in the `AssetDirectories` setting (default: `Types/`, `Formats/`, `Assemblies/`), if they exist in source -2. **Compiles the `.psm1`** — merges `Imports.ps1`, then `PrivateClasses/*.ps1`, `Private/*.ps1`, and `Public/*.ps1` into a single file in that order. The compiled module loads faster than dot-sourcing individual files at import time. -3. **Generates the manifest** — creates a fresh `.psd1` with `FunctionsToExport` set to the Public function names. If `requirements.psd1` exists, populates `RequiredModules` from it. -4. **Generates MAML help** — if `docs/commands/` has markdown and platyPS is available, converts it to MAML XML in the staged module's `en-US/` directory for `Get-Help` support. -5. **Injects version** — if `-NewVersion` was passed, the staged manifest gets that version instead of the source's `0.0.0`. - -### IntegrationTest - -Runs the built-in integration tests from `tests/integration/` against the compiled output in `artifacts/package/`. These tests verify that the module was built correctly and can be imported. They're included in every scaffolded project — you don't need to write or maintain them. - -### Package - -Creates a ZIP archive of the staged module in `artifacts/archive/`. The archive is named `-.zip`. - -### Version - -Reports the current version and confirms what `-NewVersion` or `-Prerelease` will apply. - -### Publish - -Publishes the staged module to the PowerShell Gallery using `Publish-PSResource`. Requires the `PSGALLERY_API_KEY` environment variable. - -The task has two safety checks: -- Refuses to run without an API key -- Refuses to publish version `0.0.0` (the placeholder), with a message telling you to pass `-NewVersion` - -### DevCC - -Generates a Coverage Gutters-compatible `coverage.xml` at the project root for VS Code inline coverage display. Unlike the Test task, DevCC doesn't fail on coverage threshold — it's meant for iterative local use, not enforcement. - -Install the [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) extension in VS Code to see green/red inline markers. - -## Version management - -The source manifest always contains version `0.0.0`. This is deliberate. Versions are injected at build time, not maintained in source. - -For local development, `0.0.0` artifacts are clearly not releases. For CI releases, the version comes from the git tag: - -```bash -git tag v1.2.0 -git push origin v1.2.0 -# CI runs: Invoke-Build -Task Release -NewVersion 1.2.0 -``` - -The source `.psd1` is never modified. This means: -- No "bump version" commits cluttering your history -- No drift between tags and manifest versions -- CI is the single source of truth for release versions - -For prerelease labels: - -```powershell -Invoke-Build -Task Release -NewVersion 1.2.0 -Prerelease beta1 -``` - -This produces a module that the Gallery treats as prerelease (requires `-AllowPrerelease` to install). diff --git a/src/Anvil/Templates/Module/getting-started/cicd-integration.md b/src/Anvil/Templates/Module/getting-started/cicd-integration.md deleted file mode 100644 index 59a5e53..0000000 --- a/src/Anvil/Templates/Module/getting-started/cicd-integration.md +++ /dev/null @@ -1,108 +0,0 @@ -# CI/CD Integration - -Anvil generates CI/CD workflows that run the full build pipeline on push/PR and publish to the PowerShell Gallery on tagged releases. The generated workflows are starting points — you'll likely need to adjust them for your environment. - -## Choosing a provider - -```powershell -New-AnvilModule -Name 'MyModule' -DestinationPath . -Author 'Dev' -CIProvider GitHub -``` - -Available providers: `GitHub`, `AzurePipelines`, `GitLab`, `None`. Use `None` if you handle CI yourself or don't need it yet. You can always add CI files later by scaffolding a second project and copying the workflow files. - -## How releases work - -All three providers follow the same pattern. Understanding this flow is important before configuring anything. - -1. You develop on a branch, push, and merge. CI runs the full default pipeline on every push. No publishing happens. - -2. When you're ready to release, you tag the commit: - - ```bash - git tag v1.0.0 - git push origin v1.0.0 - ``` - -3. The release workflow triggers on the `v*` tag pattern. It extracts the version number from the tag name (strips the `v` prefix), passes it to the build script as `-NewVersion`, and runs the full Release pipeline including Publish. - -4. The Publish task pushes the module to the PowerShell Gallery. It refuses to publish version `0.0.0` (the source placeholder), so if the version extraction fails, the build fails safely rather than publishing garbage. - -The source `.psd1` is never modified. The version exists only in the CI workspace during the build. There are no "version bump" commits. - -## GitHub Actions - -### Generated files - -| File | Trigger | Purpose | -|------|---------|---------| -| `.github/workflows/ci.yml` | Push/PR to main | Runs the default pipeline | -| `.github/workflows/release.yml` | Tags matching `v*` | Builds with version injection, publishes | - -### Setup - -1. Go to your repository **Settings > Environments** and create an environment called `psgallery` -2. Under the `psgallery` environment, add `PSGALLERY_API_KEY` as an environment secret with your PowerShell Gallery API key -3. Optionally, add required reviewers to the environment for manual approval before publishing -4. Optionally, restrict the environment to the `main` branch under **Deployment branches** - -## Azure Pipelines - -### Generated files - -| File | Trigger | Purpose | -|------|---------|---------| -| `azure-pipelines.yml` | Push/PR | CI pipeline | -| `azure-pipelines-release.yml` | Tags matching `v*` | Release pipeline | - -### Setup - -1. Go to **Pipelines > New pipeline** and create a pipeline from `azure-pipelines.yml`. This is your CI pipeline — name it something like `CI`. -2. Create a second pipeline from `azure-pipelines-release.yml`. This is your release pipeline — name it something like `Release`. -3. On the release pipeline, go to **Variables** and add `PSGALLERY_API_KEY` as a secret variable with your PowerShell Gallery API key. - -The release pipeline references a `psgallery` environment, which is created automatically on the first run. In Azure DevOps, environments don't hold secrets — secrets are pipeline variables. Environments are used for approval gates and deployment tracking. If you want manual sign-off before publishing, add an approval check under **Pipelines > Environments > psgallery**. - -## GitLab CI - -### Generated file - -| File | Stages | Purpose | -|------|--------|---------| -| `.gitlab-ci.yml` | ci, publish | Combined CI and release | - -The publish stage only runs for tags matching `v*` (controlled by a `rules` clause). - -### Setup - -1. Go to **Operate > Environments** and create an environment called `psgallery` -2. Go to **Settings > CI/CD > Variables** and add `PSGALLERY_API_KEY` as a protected, masked variable scoped to the `psgallery` environment -3. Go to **Settings > Repository > Protected tags** and add `v*` as a protected tag pattern (required for protected variables to be injected) - -For approval gates on the free tier, add `when: manual` to the publish job. Protected environments with role-based approvals require GitLab Premium. - -### Notes - -GitLab CI uses the `mcr.microsoft.com/powershell:lts-ubuntu-22.04` Docker image and runs on Linux by default. A Windows CI job is included but commented out — it requires a self-hosted runner tagged `windows`. GitLab shared runners on gitlab.com are Linux only. - -Test results use JUnit format for GitLab's test report integration. - -## Testing CI locally - -You can simulate what CI does without pushing: - -```powershell -./build/bootstrap.ps1 -Invoke-Build -File ./build/module.build.ps1 -Task Release -NewVersion 1.0.0-local -``` - -The Publish task will fail (no API key), but everything else runs. This is useful for verifying the full pipeline before tagging a release. The `-local` suffix is arbitrary — it just makes the version obviously non-production. - -## Adding CI to an existing project - -If you scaffolded with `-CIProvider None` and want to add CI later, the simplest approach is to scaffold a throwaway project with the desired provider and copy the workflow files: - -```powershell -New-AnvilModule -Name 'Temp' -DestinationPath $env:TEMP -Author 'x' -CIProvider GitHub -Force -``` - -Then copy `.github/workflows/` (or the equivalent) into your real project. The workflows reference `./build/bootstrap.ps1` and `./build/module.build.ps1`, which already exist in your project. diff --git a/src/Anvil/Templates/Module/getting-started/customization.md b/src/Anvil/Templates/Module/getting-started/customization.md deleted file mode 100644 index 1a4c4ac..0000000 --- a/src/Anvil/Templates/Module/getting-started/customization.md +++ /dev/null @@ -1,137 +0,0 @@ -# Customization - -Anvil projects are convention-based. The build system discovers files automatically — you extend the project by adding files in the right places, not by editing configuration. - -For day-to-day tasks like adding functions, classes, and dependencies, see [Development](development.md). This page covers advanced extension points. - -## Custom PSScriptAnalyzer rules - -The Lint task automatically discovers `.psm1` files in `build/analyzers/` and loads them as custom rule sources. To add a rule, create a new `.psm1` file: - -```powershell -# build/analyzers/MyProjectRules.psm1 -using namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic -using namespace System.Management.Automation.Language - -function AvoidHardcodedPaths { - [CmdletBinding()] - [OutputType([DiagnosticRecord])] - param( - [StringConstantExpressionAst]$ast - ) - - if ($ast.Value -match '^[A-Z]:\\') { - [DiagnosticRecord]@{ - Message = 'Avoid hardcoded Windows paths. Use Join-Path or environment variables.' - Extent = $ast.Extent - RuleName = $myinvocation.MyCommand.Name - Severity = 'Warning' - } - } -} -``` - -Each rule is a function that receives an AST node and returns `DiagnosticRecord` objects for violations. The function name becomes the rule name. PSSA calls your function once per matching AST node in each file being analyzed. - -Common AST parameter types: -- `[ScriptBlockAst]` — entire function/script bodies -- `[FunctionDefinitionAst]` — function definitions -- `[CommandAst]` — command invocations -- `[StringConstantExpressionAst]` — string literals - -To disable any rule (built-in or custom), add it to `ExcludeRules` in `PSScriptAnalyzerSettings.psd1`: - -```powershell -ExcludeRules = @( - 'PSAvoidUsingWriteHost' - 'AvoidHardcodedPaths' -) -``` - -## Types and formatting - -PowerShell supports custom type extensions and formatting views via `.ps1xml` files. These are useful when your module returns custom objects and you want to control how they display. - -### Type extensions - -Create `src/MyModule/Types/MyModule.Types.ps1xml` to add calculated properties, methods, or default display property sets to your types: - -```xml - - - MyModule.ConnectionResult - - - IsSuccess - $this.StatusCode -lt 400 - - - - -``` - -### Formatting views - -Create `src/MyModule/Formats/MyModule.Format.ps1xml` to define table or list views: - -```xml - - - - MyModule.ConnectionResult - - MyModule.ConnectionResult - - - - - - - - - - - Host - StatusCode - LatencyMs - - - - - - - -``` - -### Wiring them up - -Reference both in your module manifest: - -```powershell -TypesToProcess = @('Types/MyModule.Types.ps1xml') -FormatsToProcess = @('Formats/MyModule.Format.ps1xml') -``` - -The Build task copies `Types/` and `Formats/` directories to the staged module and carries these manifest properties through to the published manifest. - -**Important:** Type and format files affect the entire PowerShell session, not just your module. If you add a formatting view for `System.IO.FileInfo`, it changes how files display everywhere. Keep your type extensions scoped to types your module owns. - -## Adding build tasks - -InvokeBuild tasks are PowerShell scriptblocks. Add them to `build/module.build.ps1`: - -```powershell -task Deploy { - Write-BuildHeader 'Deploy' - # your deployment logic here - Write-BuildFooter 'Deploy complete' -} -``` - -Add the task name to a composite task to include it in a pipeline, or run it standalone: - -```powershell -Invoke-Build -File ./build/module.build.ps1 -Task Deploy -``` - -Note that modifying `module.build.ps1` means your project's build pipeline has diverged from Anvil's default. Future Anvil versions may ship updated build scripts, and migrating will require manually merging your changes. diff --git a/src/Anvil/Templates/Module/getting-started/development.md b/src/Anvil/Templates/Module/getting-started/development.md deleted file mode 100644 index c4d9a99..0000000 --- a/src/Anvil/Templates/Module/getting-started/development.md +++ /dev/null @@ -1,178 +0,0 @@ -# Development - -This page covers the day-to-day workflow of building a module with Anvil — adding functions, writing tests, managing dependencies, and running the build. If you haven't scaffolded a project yet, start with [Getting Started](getting-started.md). - -## The development loop - -Once your project is scaffolded and bootstrapped, the development cycle looks like this: - -1. **Scaffold a new function** with `New-AnvilFunction -FunctionName 'Get-Widget' -Scope Public`. This creates the function file with boilerplate and a matching test file. - -2. **Write your implementation** in `src//Public/Get-Widget.ps1`. Public functions get comment-based help scaffolding. Private functions get a minimal template. - -3. **Write tests** in `tests/unit/Public/Get-Widget.Tests.ps1`. The generated test file already imports the module and has the right `BeforeAll`/`AfterAll` pattern — just add your assertions. - -4. **Add dependencies** if needed with `Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0'`, then run `Invoke-AnvilBootstrapDeps` to install them. - -5. **Reload the module** with `Import-AnvilModule`. This finds and re-imports the development version of your module from anywhere in the project tree, so you can test interactively in the terminal without typing out manifest paths. - -6. **Lint and test** with `Invoke-Build -Task Lint, Test`. This runs PSScriptAnalyzer and your Pester unit tests and reports on test coverage. For integration tests, run a full build. - -7. **Run the full pipeline** before committing: `Invoke-Build -File ./build/module.build.ps1`. This adds docs generation, module compilation, integration tests, and packaging on top of lint and test. - -## Adding functions - -### Public functions - -```powershell -New-AnvilFunction -FunctionName 'Test-NetworkConnection' -Scope Public -``` - -This creates two files: - -- `src//Public/Test-NetworkConnection.ps1` — a function scaffold with `[CmdletBinding()]`, `[OutputType()]`, and a comment-based help block -- `tests/unit/Public/Test-NetworkConnection.Tests.ps1` — a Pester test scaffold with module import, a placeholder test, and the standard `BeforeAll`/`AfterAll` pattern - -Open the function file, replace the placeholder logic, then open the test file and write real assertions. The scaffolds are starting points, not finished code. - -Public function names must use an [approved PowerShell verb](https://learn.microsoft.com/en-us/powershell/scripting/developer/cmdlet/approved-verbs-for-windows-powershell-commands). Anvil validates this and rejects names like `Fetch-Data`. If you have a good reason to use a non-standard verb, pass `-SkipVerbCheck`. - -### Private functions - -```powershell -New-AnvilFunction -FunctionName 'Resolve-HostAddress' -Scope Private -``` - -Private functions don't need approved verbs, don't get comment-based help by default, and their tests use `InModuleScope` to reach inside the module. They're internal helpers — not visible to users of your module. - -### Organizing with subdirectories - -As your module grows, you can organize functions into subdirectories: - -```powershell -New-AnvilFunction -FunctionName 'Get-DnsRecord' -Scope Public -Location 'Dns' -``` - -This creates `src//Public/Dns/Get-DnsRecord.ps1` and `tests/unit/Public/Dns/Get-DnsRecord.Tests.ps1`. The module loader and build system discover files recursively, so nesting is purely organizational — it doesn't affect behavior. - -## Adding classes - -```powershell -New-AnvilClass -ClassName 'ConnectionResult' -``` - -Classes go in `PrivateClasses/` and are loaded before any functions, so your functions can use them. The generated test uses `InModuleScope` to instantiate the class and includes a test verifying it's not accessible outside the module. - -### Things to know about PowerShell classes - -**Type updates require a new session.** When you change a class definition and run `Import-Module -Force`, PowerShell reloads the functions but the class definition is pinned to the .NET type system from the first load. You must close and reopen your PowerShell session to pick up class changes. There's no workaround — this is a PowerShell engine limitation. - -**Load order is alphabetical.** Anvil authored products process the files in `PrivateClasses/` in filename order. If class `B` inherits from class `A`, make sure `A.ps1` sorts before `B.ps1`. A common convention is to prefix with numbers (`01-BaseClass.ps1`, `02-DerivedClass.ps1`) when inheritance order matters, or to always group classes that depend on each other together in a single file, in the required order. - -**Classes can't see `$script:` variables.** Unlike functions, class methods don't have access to module-scoped variables. If a class needs configuration or state from the module, pass it through the constructor or a method parameter. - -**Classes are not easily exported from modules.** PowerShell has no `ClassesToExport` mechanism. It's recommended to not try expose classes for module consumers to use directly. Classes are best used for organizing internal logic and acting as DTOs. For public module APIs, it's best to stick to exported functions. If you need to expose behavior from a class, wrap its methods in Public functions instead. - -For a full list of PowerShell class limitations, see the [Microsoft documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_classes?view=powershell-7.6#limitations). - -## Module initialization (Imports.ps1) - -`Imports.ps1` runs before any classes or functions load. Use it for module-scoped variables, assembly loading, or any initialization your code depends on: - -```powershell -$script:ResourcePath = Join-Path -Path $PSScriptRoot -ChildPath 'Resources' -$script:ApiBaseUrl = 'https://api.example.com/v1' -$script:DefaultTimeout = 30 -Add-Type -Path "$PSScriptRoot\lib\MyLibrary.dll" -``` - -Any `$script:` variable defined here is accessible from all Public and Private functions within the module. During development, the `.psm1` dot-sources this file. At build time, its content is merged into the top of the compiled module — so behavior is identical in both modes. - -Don't put function definitions here — use `Public/` or `Private/` for that. - -## Managing dependencies - -If your module depends on other modules at runtime, declare them with `Add-AnvilDependency`: - -```powershell -Add-AnvilDependency -Name 'Az.Storage' -Version '>=5.0.0' -Add-AnvilDependency -Name 'ImportExcel' -Version '7.8.6' -Add-AnvilDependency -Name 'PSFramework' -``` - -This updates two files: `requirements.psd1` (used by the bootstrap and build) and the source module manifest's `RequiredModules`. Version specs follow ModuleFast syntax: `'>=5.0.0'` for a minimum version, `'5.7.1'` for an exact pin, or `'latest'` (the default) for any version. - -After adding a dependency, install it: - -```powershell -Invoke-AnvilBootstrapDeps -``` - -To remove a dependency: - -```powershell -Remove-AnvilDependency -Name 'Az.Storage' -Force -``` - -Build tools (InvokeBuild, Pester, PSScriptAnalyzer) are managed separately in `build/build.requires.psd1` as module consumers will never need these installed. Don't add them to `requirements.psd1`. - -## Testing - -### Running tests - -```powershell -# Run all unit tests -Invoke-Build -File ./build/module.build.ps1 -Task Test - -# Run a single test file directly -Invoke-Pester -Path tests/unit/Public/Test-NetworkConnection.Tests.ps1 - -# Run tests with inline coverage in VS Code -Invoke-Build -File ./build/module.build.ps1 -Task DevCC -``` - -The DevCC task generates a `coverage.xml` file in Coverage Gutters format. Install the [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) VS Code extension to see coverage inline in your editor. - -### Testing private functions - -Use Pester's [`InModuleScope`](https://pester.dev/docs/usage/modules#testing-private-functions) inside individual `It` blocks: - -```powershell -It 'formats the output correctly' { - InModuleScope 'MyModule' { - Format-Internal -Name 'test' | Should -Be 'expected' - } -} -``` - -Don't wrap `Describe` or `Context` in `InModuleScope` — only `It` blocks. - -### Passing data into InModuleScope - -Variables from the test scope aren't visible inside `InModuleScope`. Use [`-ArgumentList`](https://pester.dev/docs/commands/InModuleScope) with a matching `param()` block: - -```powershell -It 'validates the configuration' { - $Config = @{ Name = 'Test'; Timeout = 30 } - InModuleScope 'MyModule' -ArgumentList $Config { - param($Config) - Assert-ValidConfig -Configuration $Config | Should -Not -Throw - } -} -``` - -### Coverage threshold - -The default is 80%. Pester fails the Test task if coverage drops below this. Change `CoverageThreshold` in `build/build.settings.psd1` to any value from 0 to 100. Set it to 0 to disable coverage enforcement. - -## Reloading the module - -After making changes, reload the development module: - -```powershell -Import-AnvilModule -``` - -This walks up the directory tree to find your project root, locates the source manifest, and imports it with `-Force`. You can run it from anywhere inside the project. - -Note that class changes require a new PowerShell session — `Import-Module -Force` (which `Import-AnvilModule` uses) reloads functions but not class definitions. diff --git a/src/Anvil/Templates/Module/getting-started/faq.md b/src/Anvil/Templates/Module/getting-started/faq.md deleted file mode 100644 index 55f4dc4..0000000 --- a/src/Anvil/Templates/Module/getting-started/faq.md +++ /dev/null @@ -1,53 +0,0 @@ -# FAQ - -## Why create Anvil when similar projects like ModuleBuilder, Catesta, Stucco, etc. exist? - -Anvil grew out of using Catesta across several projects. Eventually I decided to build something in line with my own preferences. A simple build system I could understand and modify without the complexity of Plaster, and authoring tools that streamline some of the more tedious areas of module authoring. - - -## Why is the version in my build 0.0.0? - -This is actually by design. The source manifest (the development manifest) uses `0.0.0` as a placeholder. The version is injected at build time: - -```powershell -Invoke-Build -Task Release -NewVersion x.x.x -``` - -In CI, the version is extracted from the git tag automatically, and no references to the modules version is referenced anywhere in the repository itself. This avoids "version bump" commits and means that git tags on your repository are the single source of truth for module versions. See [Build Pipeline > Version Management](build-pipeline.md#version-management). - -## The first build after scaffolding fails - -This shouldn't happen. The scaffolded project includes sample functions and tests that should pass out of the box. If the build fails, check: - -- **Are you running PowerShell 7.2+?** The bootstrap script requires it because ModuleFast does. Run `$PSVersionTable.PSVersion` to check. -- **Are you running from the project root?** InvokeBuild expects to be invoked from the directory containing the build script, or with an explicit `-File` path. - -If none of these help, this is a bug in Anvil — please report it. - -## The Publish task refuses to run - -It checks two things: -1. The `PSGALLERY_API_KEY` environment variable must be set -2. The staged manifest version must not be `0.0.0` - -If you see "Cannot publish placeholder version 0.0.0", pass `-NewVersion`: - -```powershell -Invoke-Build -Task Release -NewVersion 1.0.0 -``` - -## My class tests fail after changing the class - -PowerShell classes are tied to the .NET type system. `Import-Module -Force` reloads functions but does not update class definitions. You must start a new PowerShell session to pick up class changes. This is a PowerShell limitation, not a Pester or Anvil issue. See [Development > Adding classes](development.md#adding-classes) for more on class quirks. - -## Why is there a `{{ Fill ProgressAction Description }}` in my docs? - -The `-ProgressAction` common parameter was introduced in PowerShell 7.4. platyPS v0.14.2 predates this parameter and doesn't know how to describe it. This placeholder appears in every function's documentation. - -You can safely replace it with a description like "Determines how the cmdlet responds to progress updates" or leave it as-is — it doesn't affect `Get-Help` output. - -## Can I target Windows PowerShell 5.1? - -Yes. Set `-MinPowerShellVersion 5.1` and `-CompatiblePSEditions @('Desktop', 'Core')` when scaffolding. The generated module will work on both Windows PowerShell and PowerShell 7+. - -The build tooling itself requires 7.2+ (because ModuleFast does), but the module you produce can target 5.1. You build on modern PowerShell and ship for whatever version your users need. diff --git a/src/Anvil/Templates/Module/getting-started/getting-started.md b/src/Anvil/Templates/Module/getting-started/getting-started.md deleted file mode 100644 index 746ddbe..0000000 --- a/src/Anvil/Templates/Module/getting-started/getting-started.md +++ /dev/null @@ -1,96 +0,0 @@ -# Getting Started - -This guide walks through creating a module from scratch, adding real functionality, running the build, and understanding the development loop. - -## Prerequisites - -You need **PowerShell 7.2 or later** for building. This is a firm requirement because ModuleFast (the dependency installer) needs it. The module you create can target any version down to 5.1 — the build tooling and the runtime target are separate concerns. - -You don't need to install InvokeBuild, Pester, PSScriptAnalyzer, or platyPS manually. The bootstrap script handles all of that. - -Git is optional but recommended. If you pass `-GitInit`, Anvil creates a repository with an initial commit. If git is on your PATH, the interactive wizard also detects your name from `git config user.name`. - -## Creating a module - -### The interactive way - -The `-Interactive` switch starts a guided wizard: - -```powershell -New-AnvilModule -Interactive -``` - -You'll see prompts for module name, destination, author, description, CI provider, license, and more. Each prompt shows a default in brackets — press Enter to accept it. The author name is pulled from your git config if available. - -You can also pre-fill some parameters and let the wizard prompt for the rest: - -```powershell -New-AnvilModule -Interactive -Name 'NetworkTools' -Author 'Jane Doe' -``` - -This is the fastest way to get started if you're exploring. Every value can be overridden later by editing the generated files. - -### The scripted way - -For repeatable scaffolding (or CI-driven project creation), pass parameters directly: - -```powershell -$Params = @{ - Name = 'NetworkTools' - DestinationPath = '~/Projects' - Author = 'Jane Doe' - Description = 'Cmdlets for network diagnostics and monitoring.' - CompanyName = 'Contoso' - CIProvider = 'GitHub' - License = 'MIT' - MinPowerShellVersion = '7.2' - CompatiblePSEditions = @('Core') - Tags = @('Network', 'Diagnostics') - IncludeDocs = $true - GitInit = $true -} -New-AnvilModule @Params -``` - -Without `-Interactive`, Anvil applies defaults silently for any optional parameters not specified. `-Name`, `-DestinationPath`, and `-Author` are required. - -### What happens next - -Anvil creates a `NetworkTools/` directory with the full project structure, prints a summary, and (if `-GitInit` was set) commits everything. You'll see output like: - -``` -[Anvil] Creating project: NetworkTools -[Anvil] Destination: ~/Projects/NetworkTools -[Anvil] Base template: 34 files -[Anvil] CI (GitHub): 2 files - -[Anvil] Project 'NetworkTools' scaffolded successfully! -[Anvil] Next steps: - cd ~/Projects/NetworkTools - ./build/bootstrap.ps1 - Invoke-Build -File ./build/module.build.ps1 -``` - -## First build - -```powershell -cd ~/Projects/NetworkTools -./build/bootstrap.ps1 -Invoke-Build -File ./build/module.build.ps1 -``` - -The bootstrap script uses [ModuleFast](https://github.com/JustinGrote/ModuleFast) to install pinned versions of InvokeBuild, Pester, PSScriptAnalyzer, and platyPS into your user module path. This takes a few seconds on first run and is near-instant on subsequent runs. - -The build pipeline then runs: Clean, Validate, Format, Lint, Test, Docs, Build, IntegrationTest, Package. - -The scaffolded project comes with a sample public function (`Get-Greeting`), a sample private function (`Format-GreetingText`), a sample class (`GreetingBuilder`), and tests for all three. The first build should pass out of the box — if it doesn't, that's a bug in Anvil. - -## What to do next - -At this point you have a working module with sample code and a green build. Read [Development](development.md) to learn the day-to-day workflow — adding functions, managing dependencies, running tests, and building. - -Other useful references: - -- [Project Structure](project-structure.md) — what every file and directory does -- [Build Pipeline](build-pipeline.md) — every build task explained -- [CI/CD Integration](cicd-integration.md) — setting up GitHub Actions, Azure Pipelines, or GitLab CI diff --git a/src/Anvil/Templates/Module/getting-started/project-structure.md b/src/Anvil/Templates/Module/getting-started/project-structure.md deleted file mode 100644 index 39fc140..0000000 --- a/src/Anvil/Templates/Module/getting-started/project-structure.md +++ /dev/null @@ -1,171 +0,0 @@ -# Project Structure - -This page explains what Anvil generates and why each piece exists. Understanding the structure helps when you need to customize or debug the build. - -## Overview - -``` -MyModule/ -├── .github/workflows/ CI/CD workflows (if CIProvider is GitHub) -├── src/MyModule/ Module source code -│ ├── MyModule.psd1 Module manifest -│ ├── MyModule.psm1 Module loader (dot-sources everything) -│ ├── Imports.ps1 Module-scoped variables and setup -│ ├── PrivateClasses/ PowerShell classes -│ ├── Public/ Exported functions (one per file) -│ └── Private/ Internal helper functions (one per file) -├── requirements.psd1 Module dependencies (managed by Add-AnvilDependency) -├── build/ -│ ├── module.build.ps1 InvokeBuild task definitions -│ ├── bootstrap.ps1 ModuleFast dependency installer -│ ├── build.settings.psd1 Module name and coverage threshold -│ ├── build.requires.psd1 Anvil build toolchain versions -│ └── analyzers/ Custom PSScriptAnalyzer rules -├── tests/ -│ ├── unit/ Pester 5 unit tests -│ │ ├── MyModule.Module.Tests.ps1 -│ │ ├── Public/ -│ │ ├── Private/ -│ │ └── PrivateClasses/ -│ └── integration/ Post-build validation tests -├── docs/ Documentation -│ └── commands/ platyPS command reference (generated) -├── PSScriptAnalyzerSettings.psd1 -├── .editorconfig -├── .vscode/ -├── .gitignore -├── CONTRIBUTING.md -├── LICENSE -└── README.md -``` - -## Module source - -### The manifest (`MyModule.psd1`) - -The module manifest declares metadata (author, description, version, tags) and runtime properties (PowerShell version, compatible editions, required modules). During development, `FunctionsToExport` is commented out — the Build task generates this automatically from the files in `Public/`. - -The source version is always `0.0.0`. This is a placeholder. Real versions are injected at build time (see [Build Pipeline](build-pipeline.md#version-management)). - -### The module loader (`MyModule.psm1`) - -During development, the `.psm1` dot-sources files in a specific order: - -1. **`Imports.ps1`** — module-scoped variables and initialization -2. **`PrivateClasses/*.ps1`** — classes, loaded first because functions may depend on them -3. **`Public/*.ps1`** — exported functions -4. **`Private/*.ps1`** — internal helpers - -It exports only the Public functions. During compilation, this file is replaced with a single merged `.psm1`. - -### Imports.ps1 - -Runs before any classes or functions load. Use it for `$script:` variables, assembly loading, or other module-wide initialization. See [Development > Module initialization](development.md#module-initialization-importsps1) for details and examples. - -### Public, Private, PrivateClasses - -The convention is one function or class per file, with the filename matching the function/class name. All three directories support nested subdirectories — the module loader discovers `.ps1` files recursively. - -- **Public** functions are exported and visible to users after `Import-Module` -- **Private** functions are loaded but not exported — they're internal helpers -- **PrivateClasses** are loaded before functions so they can be used by both Public and Private code - -## Build system - -### bootstrap.ps1 - -A self-contained script that installs [ModuleFast](https://github.com/JustinGrote/ModuleFast) (if not present) and then uses it to install build tools from `build.requires.psd1` and module dependencies from `requirements.psd1`. It requires PowerShell 7.2+ because ModuleFast does. - -Modules are installed to the user-scoped module path, not globally. The bootstrap is safe to run repeatedly — it's fast when dependencies are already installed. - -You can also run it via `Invoke-AnvilBootstrapDeps` from anywhere inside the project. - -### build.requires.psd1 - -Declares Anvil's build toolchain versions grouped by scope. This file is for build tools only — module dependencies belong in `requirements.psd1`. - -```powershell -@{ - Build = @{ - 'InvokeBuild' = '5.12.1' - 'PSScriptAnalyzer' = '1.23.0' - } - Test = @{ - 'Pester' = '5.7.1' - } - Docs = @{ - 'platyPS' = '0.14.2' - } -} -``` - -Install selectively with `./build/bootstrap.ps1 -Scope Build,Test`. Versions are pinned for reproducible builds — update them deliberately, not accidentally. - -### requirements.psd1 - -Declares module dependencies that your module needs at runtime. Managed by `Add-AnvilDependency` and `Remove-AnvilDependency`: - -```powershell -@{ - 'Az.Storage' = '>=5.0.0' - 'ImportExcel' = '7.8.6' -} -``` - -The bootstrap installs these alongside the build tools. The Build task reads this file and populates `RequiredModules` in the published manifest automatically. - -### build.settings.psd1 - -User-editable build configuration. Controls module name, test output, linting strictness, documentation generation, and more. See [Build Pipeline > Build settings](build-pipeline.md#build-settings) for the full list of available settings. - -### build.settings_DEFAULTS_DO_NOT_EDIT.psd1 - -Default values for all build settings. Do not edit this file — it serves as a fallback when settings are missing or invalid in `build.settings.psd1`. The build script merges both files at startup: your settings take precedence, invalid or missing values fall back to defaults with a warning. - -### module.build.ps1 - -The InvokeBuild task graph. See [Build Pipeline](build-pipeline.md) for a detailed explanation of every task. - -### analyzers/ - -Custom PSScriptAnalyzer rules shipped with the project. The Lint task automatically discovers every `.psm1` file in this directory and loads it as a rule source. You can add your own rules by dropping files here and disable any rule (built-in or custom) via `ExcludeRules` in `PSScriptAnalyzerSettings.psd1`. - -## Tests - -The test structure mirrors the source structure: - -| Source | Tests | -|--------|-------| -| `Public/Get-Widget.ps1` | `tests/unit/Public/Get-Widget.Tests.ps1` | -| `Private/Format-Row.ps1` | `tests/unit/Private/Format-Row.Tests.ps1` | -| `PrivateClasses/MyClass.ps1` | `tests/unit/PrivateClasses/MyClass.Tests.ps1` | - -**Unit tests** (`tests/unit/`) test source code directly by importing the module from `src/`. Public function tests call functions by name. Private function and class tests use `InModuleScope` to reach inside the module. - -**Integration tests** (`tests/integration/`) run after the Build task and validate that the compiled module was built correctly and can be imported. - -Each test file imports the module in `BeforeAll` and cleans up in `AfterAll`. - -## Configuration files - -**`PSScriptAnalyzerSettings.psd1`** — linter rules and formatting configuration. Controls brace style (OTBS), indentation (4 spaces), whitespace rules, and which rules to exclude. - -**`.editorconfig`** — editor-agnostic formatting (indent style, line endings, trailing whitespace). Respected by VS Code, JetBrains, and most other editors. - -**`.vscode/`** — VS Code workspace settings (PowerShell extension configuration), build tasks (Bootstrap, Build, Lint, Test, Coverage), and recommended extensions. - -## Build output - -After a successful build, `artifacts/` contains: - -``` -artifacts/ -├── package/MyModule/ Staged module (ready to publish) -│ ├── MyModule.psd1 Clean manifest with explicit exports -│ ├── MyModule.psm1 Compiled single-file module -│ └── en-US/ MAML help (generated from docs/commands/) -├── testResults/ NUnit XML + JaCoCo coverage XML -└── archive/ ZIP of the staged module -``` - -The `en-US/` directory contains generated MAML help for `Get-Help` support. diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/Imports.ps1 b/src/Anvil/Templates/Module/src/__Name__/Imports.ps1 similarity index 100% rename from src/Anvil/Templates/Module/src/__ModuleName__/Imports.ps1 rename to src/Anvil/Templates/Module/src/__Name__/Imports.ps1 diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/Private/Format-GreetingText.ps1.tmpl b/src/Anvil/Templates/Module/src/__Name__/Private/Format-GreetingText.ps1.tmpl similarity index 100% rename from src/Anvil/Templates/Module/src/__ModuleName__/Private/Format-GreetingText.ps1.tmpl rename to src/Anvil/Templates/Module/src/__Name__/Private/Format-GreetingText.ps1.tmpl diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/Private/README.md b/src/Anvil/Templates/Module/src/__Name__/Private/README.md similarity index 100% rename from src/Anvil/Templates/Module/src/__ModuleName__/Private/README.md rename to src/Anvil/Templates/Module/src/__Name__/Private/README.md diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/PrivateClasses/GreetingBuilder.ps1 b/src/Anvil/Templates/Module/src/__Name__/PrivateClasses/GreetingBuilder.ps1 similarity index 100% rename from src/Anvil/Templates/Module/src/__ModuleName__/PrivateClasses/GreetingBuilder.ps1 rename to src/Anvil/Templates/Module/src/__Name__/PrivateClasses/GreetingBuilder.ps1 diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/PrivateClasses/README.md b/src/Anvil/Templates/Module/src/__Name__/PrivateClasses/README.md similarity index 100% rename from src/Anvil/Templates/Module/src/__ModuleName__/PrivateClasses/README.md rename to src/Anvil/Templates/Module/src/__Name__/PrivateClasses/README.md diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/Public/Get-Greeting.ps1.tmpl b/src/Anvil/Templates/Module/src/__Name__/Public/Get-Greeting.ps1.tmpl similarity index 100% rename from src/Anvil/Templates/Module/src/__ModuleName__/Public/Get-Greeting.ps1.tmpl rename to src/Anvil/Templates/Module/src/__Name__/Public/Get-Greeting.ps1.tmpl diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/Public/README.md b/src/Anvil/Templates/Module/src/__Name__/Public/README.md similarity index 100% rename from src/Anvil/Templates/Module/src/__ModuleName__/Public/README.md rename to src/Anvil/Templates/Module/src/__Name__/Public/README.md diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/__ModuleName__.psd1.tmpl b/src/Anvil/Templates/Module/src/__Name__/__Name__.psd1.tmpl similarity index 88% rename from src/Anvil/Templates/Module/src/__ModuleName__/__ModuleName__.psd1.tmpl rename to src/Anvil/Templates/Module/src/__Name__/__Name__.psd1.tmpl index 5487ddb..0130712 100644 --- a/src/Anvil/Templates/Module/src/__ModuleName__/__ModuleName__.psd1.tmpl +++ b/src/Anvil/Templates/Module/src/__Name__/__Name__.psd1.tmpl @@ -2,14 +2,15 @@ # Changes to RequiredModules will be overwritten by Add-AnvilDependency / Remove-AnvilDependency. # The build process regenerates this manifest from source when packaging. @{ - RootModule = '<%ModuleName%>.psm1' + RootModule = '<%Name%>.psm1' ModuleVersion = '0.0.0' GUID = '<%ModuleGuid%>' Author = '<%Author%>' CompanyName = '<%CompanyName%>' Copyright = '(c) <%Year%> <%Author%>. All rights reserved.' Description = '<%Description%>' - PowerShellVersion = '<%MinPowerShellVersion%>' + PowerShellVersion = '<%MinPowerShellVersion%>' + CompatiblePSEditions = <%CompatiblePSEditions%> RequiredModules = @() RequiredAssemblies = @() diff --git a/src/Anvil/Templates/Module/src/__ModuleName__/__ModuleName__.psm1.tmpl b/src/Anvil/Templates/Module/src/__Name__/__Name__.psm1.tmpl similarity index 97% rename from src/Anvil/Templates/Module/src/__ModuleName__/__ModuleName__.psm1.tmpl rename to src/Anvil/Templates/Module/src/__Name__/__Name__.psm1.tmpl index 0bce645..215ae8a 100644 --- a/src/Anvil/Templates/Module/src/__ModuleName__/__ModuleName__.psm1.tmpl +++ b/src/Anvil/Templates/Module/src/__Name__/__Name__.psm1.tmpl @@ -4,7 +4,7 @@ #Requires -Version <%MinPowerShellVersion%> <# .SYNOPSIS - Module loader for <%ModuleName%>. + Module loader for <%Name%>. .DESCRIPTION Dot-sources Imports.ps1 for module-scoped variables, then loads diff --git a/src/Anvil/Templates/Module/template.psd1 b/src/Anvil/Templates/Module/template.psd1 new file mode 100644 index 0000000..47c1c79 --- /dev/null +++ b/src/Anvil/Templates/Module/template.psd1 @@ -0,0 +1,122 @@ +@{ + Name = 'Module' + Description = 'PowerShell module with build pipeline, tests, and CI/CD' + Version = '1.0.0' + + Parameters = @( + @{ + Name = 'Name' + Type = 'string' + Required = $true + Prompt = 'Module name' + Validate = '^[A-Za-z][A-Za-z0-9._\-]{0,127}$' + ValidateMessage = 'Must start with a letter. Letters, digits, dots, hyphens, underscores only. Max 128 characters.' + } + @{ + Name = 'Author' + Type = 'string' + Required = $true + Prompt = 'Author' + DefaultFrom = 'GitUserName' + } + @{ + Name = 'Description' + Type = 'string' + Prompt = 'Description' + Default = 'A PowerShell module scaffolded by Anvil.' + } + @{ + Name = 'CompanyName' + Type = 'string' + Prompt = 'Company name' + Default = '' + } + @{ + Name = 'MinPowerShellVersion' + Type = 'string' + Prompt = 'Minimum PowerShell version' + Default = '5.1' + Validate = '^\d+\.\d+(\.\d+(\.\d+)?)?$' + ValidateMessage = 'Must be a valid version string (e.g. 5.1, 7.2).' + } + @{ + Name = 'CompatiblePSEditions' + Type = 'csv' + Prompt = 'Compatible PS editions (Desktop,Core / Core)' + Default = 'Desktop,Core' + Format = 'psd1-array' + } + @{ + Name = 'CIProvider' + Type = 'choice' + Prompt = 'CI provider' + Choices = @('GitHub', 'AzurePipelines', 'GitLab', 'None') + Default = 'GitHub' + } + @{ + Name = 'License' + Type = 'choice' + Prompt = 'License' + Choices = @('MIT', 'Apache2', 'None') + Default = 'MIT' + } + @{ + Name = 'CoverageThreshold' + Type = 'int' + Prompt = 'Code coverage threshold (0-100)' + Default = 80 + Range = @(0, 100) + } + @{ + Name = 'IncludeDocs' + Type = 'bool' + Prompt = 'Include platyPS docs generation?' + Default = $false + Format = 'lower-string' + } + @{ + Name = 'Tags' + Type = 'csv' + Prompt = 'Tags (comma-separated)' + Default = '' + Format = 'psd1-array' + } + @{ + Name = 'ProjectUri' + Type = 'uri' + Prompt = 'Project URI' + Default = '' + } + @{ + Name = 'LicenseUri' + Type = 'uri' + Prompt = 'License URI' + Default = '' + } + ) + + AutoTokens = @( + @{ Name = 'ModuleGuid'; Source = 'NewGuid' } + @{ Name = 'Year'; Source = 'CurrentYear' } + ) + + Sections = @{ + DocsTask = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + DocsBuildStep = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + DocsTaskGraph = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + DocsComposite = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + NoDocsTaskGraph = @{ ExcludeWhen = @{ IncludeDocs = 'true' } } + NoDocsComposite = @{ ExcludeWhen = @{ IncludeDocs = 'true' } } + LicenseMIT = @{ IncludeWhen = @{ License = 'MIT' } } + LicenseApache2 = @{ IncludeWhen = @{ License = 'Apache2' } } + } + + ExcludeWhen = @{ + 'LICENSE.tmpl' = @{ License = 'None' } + 'docs/*' = @{ IncludeDocs = 'false' } + } + + Layers = @( + @{ PathKey = 'CIProvider'; BasePath = 'CI'; Skip = 'None' } + ) +} diff --git a/src/Anvil/Templates/Module/tests/integration/BuildArtifacts.Tests.ps1.tmpl b/src/Anvil/Templates/Module/tests/integration/BuildArtifacts.Tests.ps1.tmpl index 0a3ac05..cb8edbc 100644 --- a/src/Anvil/Templates/Module/tests/integration/BuildArtifacts.Tests.ps1.tmpl +++ b/src/Anvil/Templates/Module/tests/integration/BuildArtifacts.Tests.ps1.tmpl @@ -5,7 +5,7 @@ BeforeAll { while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { $ProjectRoot = Split-Path $ProjectRoot -Parent } - $ModuleName = '<%ModuleName%>' + $ModuleName = '<%Name%>' $ArtifactsDir = Join-Path -Path $ProjectRoot -ChildPath 'artifacts' $PackageDir = Join-Path -Path $ArtifactsDir -ChildPath 'package' $StagedModule = Join-Path -Path $PackageDir -ChildPath $ModuleName @@ -54,5 +54,5 @@ Describe 'Build Artifacts' -Tag 'Integration' { } AfterAll { - Get-Module -Name '<%ModuleName%>' -ErrorAction SilentlyContinue | Remove-Module -Force + Get-Module -Name '<%Name%>' -ErrorAction SilentlyContinue | Remove-Module -Force } diff --git a/src/Anvil/Templates/Module/tests/integration/README.md b/src/Anvil/Templates/Module/tests/integration/README.md index 2979251..1278a23 100644 --- a/src/Anvil/Templates/Module/tests/integration/README.md +++ b/src/Anvil/Templates/Module/tests/integration/README.md @@ -1,55 +1,7 @@ # Integration Tests -Integration tests validate the build pipeline's output. Unlike unit tests (which test source code from `src/`), these tests exercise the compiled artifacts in `artifacts/package/` -- the same files that would be published to the PowerShell Gallery. +Integration tests validate the compiled build output in `artifacts/package/`, not the source code. They run after the Build task and verify that the compiled module is valid and importable. -## Directory layout +Integration tests require a successful build first. They run automatically as part of the full pipeline, or individually with `Invoke-AnvilBuild -Task Build, IntegrationTest`. -``` -tests/integration/ - README.md - BuildArtifacts.Tests.ps1 # Validates compiled module structure -``` - -## Running tests - -Integration tests require a successful build first: - -```powershell -# Build then run integration tests -Invoke-Build Build, IntegrationTest - -# Or run the full pipeline (includes integration tests) -Invoke-Build -``` - -To run integration tests directly (after building): - -```powershell -Invoke-Pester -Path tests/integration -Tag 'Integration' -``` - -## How integration tests differ from unit tests - -| | Unit tests | Integration tests | -|---|---|---| -| **Import from** | `src/YourModule/` | `artifacts/package/YourModule/` | -| **Test target** | Individual functions | Compiled build output | -| **InModuleScope** | Yes, for private functions | No -- only public interface | -| **When they run** | `Invoke-Build Test` | `Invoke-Build IntegrationTest` (after Build) | -| **Tag** | `Unit` | `Integration` | - -## What to test here - -Integration tests should verify things that only matter after compilation: - -- The compiled `.psm1` is a single file (no dot-sourcing of individual `.ps1` files) -- The staged manifest is valid and importable -- `Export-ModuleMember` is present in the compiled module -- Required assemblies or nested modules are included -- Help XML files are generated (if using PlatyPS) - -Do **not** test individual function behavior here -- that belongs in unit tests. Integration tests answer: "did the build produce a valid, publishable module?" - -## Extending - -Add new `Context` blocks to `BuildArtifacts.Tests.ps1` for additional artifact checks, or create new `.Tests.ps1` files for distinct integration scenarios (e.g. testing against a real API, database, or service). +Add new checks to `BuildArtifacts.Tests.ps1` or create additional test files for scenarios that need the compiled module (e.g. verifying help content, testing against external services). diff --git a/src/Anvil/Templates/Module/tests/unit/Private/Format-GreetingText.Tests.ps1.tmpl b/src/Anvil/Templates/Module/tests/unit/Private/Format-GreetingText.Tests.ps1.tmpl index 40c7c9d..411f13a 100644 --- a/src/Anvil/Templates/Module/tests/unit/Private/Format-GreetingText.Tests.ps1.tmpl +++ b/src/Anvil/Templates/Module/tests/unit/Private/Format-GreetingText.Tests.ps1.tmpl @@ -5,7 +5,7 @@ BeforeAll { while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { $ProjectRoot = Split-Path $ProjectRoot -Parent } - $ModuleName = '<%ModuleName%>' + $ModuleName = '<%Name%>' $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" @@ -14,19 +14,19 @@ BeforeAll { } AfterAll { - Get-Module -Name '<%ModuleName%>' -ErrorAction SilentlyContinue | Remove-Module -Force + Get-Module -Name '<%Name%>' -ErrorAction SilentlyContinue | Remove-Module -Force } Describe 'Format-GreetingText' -Tag 'Unit' { It 'formats a greeting for the given name' { - InModuleScope '<%ModuleName%>' { + InModuleScope '<%Name%>' { Format-GreetingText -Name 'Alice' | Should -Be 'Hello, Alice!' } } It 'is not exported' { - $Exported = (Get-Module '<%ModuleName%>').ExportedFunctions.Keys + $Exported = (Get-Module '<%Name%>').ExportedFunctions.Keys $Exported | Should -Not -Contain 'Format-GreetingText' } } diff --git a/src/Anvil/Templates/Module/tests/unit/Private/README.md b/src/Anvil/Templates/Module/tests/unit/Private/README.md index aea00e5..56535d4 100644 --- a/src/Anvil/Templates/Module/tests/unit/Private/README.md +++ b/src/Anvil/Templates/Module/tests/unit/Private/README.md @@ -1,91 +1,9 @@ # Private Function Tests -Tests in this directory validate your module's internal (private) functions -- commands that are not exported and cannot be called by users directly. +One test file per private function. Private functions are not exported, so tests use `InModuleScope` inside `It` blocks to access them. -## Adding a new test file +Each test file should include an "is not exported" assertion to verify the function stays private. -1. Create `.Tests.ps1` in this directory -2. Use `Format-GreetingText.Tests.ps1` as a starting point -3. Always include an "is not exported" test to verify the function stays private +Variables from the test scope are not visible inside `InModuleScope`. Use `-ArgumentList` with a `param()` block to pass data in. -## When to test private functions - -Private functions with meaningful logic (parsing, formatting, validation) benefit from direct tests. If a private function is a trivial wrapper or one-liner, testing the public function that calls it is usually sufficient. - -## Structure - -Every private function test uses `InModuleScope` to reach inside the module: - -```powershell -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } - -BeforeAll { - $ProjectRoot = $PSScriptRoot - while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { - $ProjectRoot = Split-Path $ProjectRoot -Parent - } - - $ModuleName = 'YourModule' - $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName - $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" - - Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ManifestPath -Force -ErrorAction Stop -} - -AfterAll { - Get-Module -Name 'YourModule' -ErrorAction SilentlyContinue | Remove-Module -Force -} - -Describe 'Some-PrivateFunction' -Tag 'Unit' { - It 'does the expected thing' { - InModuleScope 'YourModule' { - Some-PrivateFunction -Name 'test' | Should -Be 'expected' - } - } - - It 'is not exported' { - $Exported = (Get-Module 'YourModule').ExportedFunctions.Keys - $Exported | Should -Not -Contain 'Some-PrivateFunction' - } -} -``` - -## Key concepts - -### InModuleScope - -`InModuleScope` executes a scriptblock inside the module's session state, where private functions are visible. Always place it **inside individual `It` blocks** -- never around `Describe` or `Context`. - -Wrapping outer blocks in `InModuleScope` causes problems: -- Prevents Pester from verifying that public functions are actually exported -- Slows down test discovery by forcing module loading during the Discovery phase - -See: https://pester.dev/docs/usage/modules#testing-private-functions - -### Passing data into InModuleScope - -Variables from the test scope are **not automatically visible** inside `InModuleScope`. Use `-ArgumentList` with a matching `param()` block: - -```powershell -It 'validates the configuration' { - $Config = @{ Name = 'Test'; Value = 42 } - InModuleScope 'YourModule' -ArgumentList $Config { - param($Config) - Assert-ValidConfig -Configuration $Config | Should -Not -Throw - } -} -``` - -### Verifying a function is not exported - -Always include a test that confirms the function stays private. This catches accidental additions to `FunctionsToExport` in the manifest: - -```powershell -It 'is not exported' { - $Exported = (Get-Module 'YourModule').ExportedFunctions.Keys - $Exported | Should -Not -Contain 'Some-PrivateFunction' -} -``` - -This test does **not** need `InModuleScope` because it checks the module's public interface from the outside. +Use `Format-GreetingText.Tests.ps1` as a starting point for new tests. diff --git a/src/Anvil/Templates/Module/tests/unit/PrivateClasses/GreetingBuilder.Tests.ps1.tmpl b/src/Anvil/Templates/Module/tests/unit/PrivateClasses/GreetingBuilder.Tests.ps1.tmpl index afeb237..07da972 100644 --- a/src/Anvil/Templates/Module/tests/unit/PrivateClasses/GreetingBuilder.Tests.ps1.tmpl +++ b/src/Anvil/Templates/Module/tests/unit/PrivateClasses/GreetingBuilder.Tests.ps1.tmpl @@ -5,7 +5,7 @@ BeforeAll { while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { $ProjectRoot = Split-Path $ProjectRoot -Parent } - $ModuleName = '<%ModuleName%>' + $ModuleName = '<%Name%>' $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" @@ -14,21 +14,21 @@ BeforeAll { } AfterAll { - Get-Module -Name '<%ModuleName%>' -ErrorAction SilentlyContinue | Remove-Module -Force + Get-Module -Name '<%Name%>' -ErrorAction SilentlyContinue | Remove-Module -Force } Describe 'GreetingBuilder' -Tag 'Unit' { Context 'Default constructor' { It 'uses Hello as the default prefix' { - InModuleScope '<%ModuleName%>' { + InModuleScope '<%Name%>' { $Builder = [GreetingBuilder]::new() $Builder.Prefix | Should -Be 'Hello' } } It 'uses ! as the default suffix' { - InModuleScope '<%ModuleName%>' { + InModuleScope '<%Name%>' { $Builder = [GreetingBuilder]::new() $Builder.Suffix | Should -Be '!' } @@ -37,7 +37,7 @@ Describe 'GreetingBuilder' -Tag 'Unit' { Context 'Parameterized constructor' { It 'accepts a custom prefix and suffix' { - InModuleScope '<%ModuleName%>' { + InModuleScope '<%Name%>' { $Builder = [GreetingBuilder]::new('Hi', '.') $Builder.Prefix | Should -Be 'Hi' $Builder.Suffix | Should -Be '.' @@ -47,14 +47,14 @@ Describe 'GreetingBuilder' -Tag 'Unit' { Context 'Build method' { It 'returns a greeting with the default settings' { - InModuleScope '<%ModuleName%>' { + InModuleScope '<%Name%>' { $Builder = [GreetingBuilder]::new() $Builder.Build('World') | Should -Be 'Hello, World!' } } It 'returns a greeting with custom prefix and suffix' { - InModuleScope '<%ModuleName%>' { + InModuleScope '<%Name%>' { $Builder = [GreetingBuilder]::new('Hey', '?') $Builder.Build('Alice') | Should -Be 'Hey, Alice?' } @@ -63,7 +63,7 @@ Describe 'GreetingBuilder' -Tag 'Unit' { Context 'ToString method' { It 'returns a summary of the builder' { - InModuleScope '<%ModuleName%>' { + InModuleScope '<%Name%>' { $Builder = [GreetingBuilder]::new('Hi', '!') $Builder.ToString() | Should -Be 'Hi...!' } diff --git a/src/Anvil/Templates/Module/tests/unit/PrivateClasses/README.md b/src/Anvil/Templates/Module/tests/unit/PrivateClasses/README.md index f123fe8..6bc1c9f 100644 --- a/src/Anvil/Templates/Module/tests/unit/PrivateClasses/README.md +++ b/src/Anvil/Templates/Module/tests/unit/PrivateClasses/README.md @@ -1,52 +1,7 @@ # Private Class Tests -Tests in this directory validate your module's PowerShell classes. Classes defined in `PrivateClasses/` are internal to the module and not accessible to users directly. +One test file per class. Classes defined in `PrivateClasses/` are not accessible outside the module, so tests use `InModuleScope` to construct and test them. -## Adding a new test file +Each test file should include an assertion that verifies the class is not accessible outside the module. -1. Create `.Tests.ps1` in this directory -2. Use `GreetingBuilder.Tests.ps1` as a starting point - -## Key concepts - -### Classes are loaded before functions - -The module loader dot-sources `PrivateClasses/` before `Public/` and `Private/`, so your functions can depend on classes without worrying about load order. - -### Accessing private classes in tests - -Classes defined inside a module are not visible outside it. Use `InModuleScope` to construct and test them: - -```powershell -It 'creates an instance' { - InModuleScope 'YourModule' { - $Obj = [MyClass]::new() - $Obj.SomeProperty | Should -Be 'expected' - } -} -``` - -### Verifying a class is not accessible outside the module - -Include a test that confirms the class stays internal: - -```powershell -It 'is not accessible outside the module' { - { [MyClass]::new() } | Should -Throw -} -``` - -### Passing data into InModuleScope - -Variables from the test scope are not visible inside `InModuleScope`. Use `-ArgumentList`: - -```powershell -It 'accepts external input' { - $Input = 'test value' - InModuleScope 'YourModule' -ArgumentList $Input { - param($Input) - $Obj = [MyClass]::new($Input) - $Obj.Value | Should -Be 'test value' - } -} -``` +Use `GreetingBuilder.Tests.ps1` as a starting point for new tests. diff --git a/src/Anvil/Templates/Module/tests/unit/Public/Get-Greeting.Tests.ps1.tmpl b/src/Anvil/Templates/Module/tests/unit/Public/Get-Greeting.Tests.ps1.tmpl index e902d9d..c4f395a 100644 --- a/src/Anvil/Templates/Module/tests/unit/Public/Get-Greeting.Tests.ps1.tmpl +++ b/src/Anvil/Templates/Module/tests/unit/Public/Get-Greeting.Tests.ps1.tmpl @@ -5,7 +5,7 @@ BeforeAll { while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { $ProjectRoot = Split-Path $ProjectRoot -Parent } - $ModuleName = '<%ModuleName%>' + $ModuleName = '<%Name%>' $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" @@ -14,7 +14,7 @@ BeforeAll { } AfterAll { - Get-Module -Name '<%ModuleName%>' -ErrorAction SilentlyContinue | Remove-Module -Force + Get-Module -Name '<%Name%>' -ErrorAction SilentlyContinue | Remove-Module -Force } Describe 'Get-Greeting' -Tag 'Unit' { diff --git a/src/Anvil/Templates/Module/tests/unit/Public/README.md b/src/Anvil/Templates/Module/tests/unit/Public/README.md index e3063a6..1a19930 100644 --- a/src/Anvil/Templates/Module/tests/unit/Public/README.md +++ b/src/Anvil/Templates/Module/tests/unit/Public/README.md @@ -1,92 +1,7 @@ # Public Function Tests -Tests in this directory validate your module's exported (public) functions -- the commands your users call directly. +One test file per exported function. Public functions are available by name after `Import-Module` — no `InModuleScope` needed. -## Adding a new test file +When mocking commands that your function calls internally, use `Mock -ModuleName 'YourModule'` to inject the mock into the module's scope. -1. Create `.Tests.ps1` in this directory -2. Use `Get-Greeting.Tests.ps1` as a starting point - -## Structure - -Every public function test file follows this pattern: - -```powershell -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } - -BeforeAll { - # Walk up to find the project root - $ProjectRoot = $PSScriptRoot - while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { - $ProjectRoot = Split-Path $ProjectRoot -Parent - } - - $ModuleName = 'YourModule' - $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName - $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" - - # Import the module fresh - Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ManifestPath -Force -ErrorAction Stop -} - -AfterAll { - Get-Module -Name 'YourModule' -ErrorAction SilentlyContinue | Remove-Module -Force -} - -Describe 'Get-Something' -Tag 'Unit' { - It 'does the expected thing' { - Get-Something | Should -Be 'expected value' - } -} -``` - -## Key concepts - -### BeforeAll / AfterAll - -`BeforeAll` runs once before all tests in the file. Import your module here so every `It` block can call the function by name. Variables defined in `BeforeAll` are visible (read-only) to all child blocks. - -`AfterAll` cleans up after all tests so the module does not leak into other test files. - -### No special scoping needed - -Public functions are exported and available by name after `Import-Module`. Call them exactly as a user would -- no `InModuleScope` required. - -### Mocking - -When your function calls other commands internally, use `Mock` with `-ModuleName` to inject the mock into the module's scope: - -```powershell -It 'uses today''s date' { - Mock -ModuleName 'YourModule' Get-Date { [datetime]'2025-01-01' } - Get-Something | Should -Be '2025-01-01' -} -``` - -Without `-ModuleName`, the mock only exists in the test scope and the module's internal call to `Get-Date` will hit the real command. - -See: https://pester.dev/docs/usage/mocking - -### Assertions - -Pester provides many assertion operators: - -- `Should -Be` -- exact equality -- `Should -BeExactly` -- case-sensitive equality -- `Should -BeLike` -- wildcard match -- `Should -Match` -- regex match -- `Should -Throw` -- expects an error -- `Should -Exist` -- file/directory exists -- `Should -BeNullOrEmpty` / `Should -Not -BeNullOrEmpty` - -Full reference: https://pester.dev/docs/assertions - -### Tags - -`-Tag 'Unit'` on the `Describe` block allows selective execution: - -```powershell -Invoke-Pester -Tag 'Unit' # run only unit tests -Invoke-Pester -ExcludeTag 'Slow' # skip slow tests -``` +Use `Get-Greeting.Tests.ps1` as a starting point for new tests. diff --git a/src/Anvil/Templates/Module/tests/unit/README.md b/src/Anvil/Templates/Module/tests/unit/README.md index 76406eb..9aeb8a5 100644 --- a/src/Anvil/Templates/Module/tests/unit/README.md +++ b/src/Anvil/Templates/Module/tests/unit/README.md @@ -1,108 +1,15 @@ # Unit Tests -Unit tests validate your module's source code directly. They import the module from `src/` and test individual functions in isolation. - -## Directory layout +Unit tests validate source code from `src/`. Each function and class has a matching test file in the corresponding subdirectory. ``` tests/unit/ - YourModule.Module.Tests.ps1 # Module-level tests (manifest, imports, exports) - PrivateClasses/ # One test file per class - README.md - GreetingBuilder.Tests.ps1 - Public/ # One test file per public function - README.md - Get-Greeting.Tests.ps1 - Private/ # One test file per private function - README.md - Format-GreetingText.Tests.ps1 -``` - -## Running tests - -```powershell -# Run all unit tests -Invoke-Pester -Path tests/unit -Tag 'Unit' - -# Run a single test file -Invoke-Pester -Path tests/unit/Public/Get-Greeting.Tests.ps1 -``` - -Or through the build pipeline: - -```powershell -Invoke-Build Test + YourModule.Module.Tests.ps1 Module-level tests (manifest, imports, exports) + Public/ One test file per public function + Private/ One test file per private function + PrivateClasses/ One test file per class ``` -## Pester 5 concepts - -### Two-phase execution: Discovery and Run - -Pester 5 runs your test files twice. Understanding this is critical for writing correct tests. - -**Discovery phase** -- Pester reads the file to find all `Describe`, `Context`, and `It` blocks. Code at the top level of the file (outside `BeforeAll`/`BeforeEach`) runs during this phase. `BeforeDiscovery` is a named wrapper that signals "this code intentionally runs during Discovery." - -**Run phase** -- Pester executes the tests it found. `BeforeAll`, `BeforeEach`, `It`, `AfterEach`, and `AfterAll` all run during this phase. - -**Why it matters:** A variable defined during Discovery (in `BeforeDiscovery` or at script level) is not available inside `It` blocks during the Run phase. If you need data for both test generation (`-ForEach`) and assertions (`It`), define it in both places. - -### BeforeDiscovery vs BeforeAll - -| | BeforeDiscovery | BeforeAll | -|---|---|---| -| **When** | Discovery phase | Run phase | -| **Purpose** | Generate test data for `-ForEach` | Set up state for `It` blocks | -| **Variables visible in** | `-ForEach` parameters only | All child `It` blocks (read-only) | - -Example from `YourModule.Module.Tests.ps1`: - -```powershell -BeforeDiscovery { - # $DeclaredFunctions drives -ForEach to generate one It per function - $PublicDir = Join-Path -Path $ModuleDir -ChildPath 'Public' - $DeclaredFunctions = @((Get-ChildItem -Path $PublicDir -Filter '*.ps1' -Recurse).BaseName | Sort-Object) -} - -BeforeAll { - # $ExpectedFunctionCount is used inside an It block for a count assertion - $PublicDir = Join-Path -Path $ModuleDir -ChildPath 'Public' - $ExpectedFunctionCount = @((Get-ChildItem -Path $PublicDir -Filter '*.ps1' -Recurse).BaseName).Count -} -``` - -See: https://pester.dev/docs/usage/data-driven-tests - -### Data-driven tests with -ForEach - -`-ForEach` generates one test per item in an array. Use `$_` to reference the current item, and `<_>` in the test name: - -```powershell -It 'exports <_>' -ForEach $DeclaredFunctions { - $Exported = (Get-Module 'YourModule').ExportedFunctions.Keys - $_ | Should -BeIn $Exported -} -``` - -With hashtable arrays, keys become variables: - -```powershell -It 'converts to ' -ForEach @( - @{ Input = 'hello'; Expected = 'HELLO' } - @{ Input = 'world'; Expected = 'WORLD' } -) { - ConvertTo-Upper $Input | Should -Be $Expected -} -``` - -### Project root discovery - -All test files use a walk-up pattern to find the project root, making tests resilient to directory restructuring: - -```powershell -$ProjectRoot = $PSScriptRoot -while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { - $ProjectRoot = Split-Path $ProjectRoot -Parent -} -``` +Add tests with `New-AnvilFunction` (creates both function and test) or `New-AnvilTest` (creates a test only). -This searches up from the test file's location until it finds `build/build.settings.psd1`, which anchors the project root regardless of how deeply nested the test file is. +Run with `Invoke-AnvilBuild -Task Test` or directly with `Invoke-Pester -Path tests/unit`. diff --git a/src/Anvil/Templates/Module/tests/unit/__ModuleName__.Module.Tests.ps1.tmpl b/src/Anvil/Templates/Module/tests/unit/__Name__.Module.Tests.ps1.tmpl similarity index 83% rename from src/Anvil/Templates/Module/tests/unit/__ModuleName__.Module.Tests.ps1.tmpl rename to src/Anvil/Templates/Module/tests/unit/__Name__.Module.Tests.ps1.tmpl index f46e2d0..aaf1a0f 100644 --- a/src/Anvil/Templates/Module/tests/unit/__ModuleName__.Module.Tests.ps1.tmpl +++ b/src/Anvil/Templates/Module/tests/unit/__Name__.Module.Tests.ps1.tmpl @@ -5,7 +5,7 @@ BeforeDiscovery { while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { $ProjectRoot = Split-Path $ProjectRoot -Parent } - $ModuleName = '<%ModuleName%>' + $ModuleName = '<%Name%>' $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName $PublicDir = Join-Path -Path $ModuleDir -ChildPath 'Public' @@ -17,7 +17,7 @@ BeforeAll { while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { $ProjectRoot = Split-Path $ProjectRoot -Parent } - $ModuleName = '<%ModuleName%>' + $ModuleName = '<%Name%>' $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" $PublicDir = Join-Path -Path $ModuleDir -ChildPath 'Public' @@ -29,10 +29,10 @@ BeforeAll { } AfterAll { - Get-Module -Name '<%ModuleName%>' -ErrorAction SilentlyContinue | Remove-Module -Force + Get-Module -Name '<%Name%>' -ErrorAction SilentlyContinue | Remove-Module -Force } -Describe 'Module: <%ModuleName%>' -Tag 'Unit' { +Describe 'Module: <%Name%>' -Tag 'Unit' { Context 'Manifest' { It 'has a valid manifest' { @@ -56,18 +56,18 @@ Describe 'Module: <%ModuleName%>' -Tag 'Unit' { } It 'is loaded after import' { - Get-Module -Name '<%ModuleName%>' | Should -Not -BeNullOrEmpty + Get-Module -Name '<%Name%>' | Should -Not -BeNullOrEmpty } } Context 'Exports' { It 'exports the expected number of functions' { - $Exported = (Get-Module -Name '<%ModuleName%>').ExportedFunctions.Keys + $Exported = (Get-Module -Name '<%Name%>').ExportedFunctions.Keys $Exported.Count | Should -Be $ExpectedFunctionCount } It 'exports <_>' -ForEach $DeclaredFunctions { - $Exported = (Get-Module -Name '<%ModuleName%>').ExportedFunctions.Keys + $Exported = (Get-Module -Name '<%Name%>').ExportedFunctions.Keys $_ | Should -BeIn $Exported } } diff --git a/tests/integration/GoldenTemplate.Tests.ps1 b/tests/integration/GoldenTemplate.Tests.ps1 index 30d3b52..7d26703 100644 --- a/tests/integration/GoldenTemplate.Tests.ps1 +++ b/tests/integration/GoldenTemplate.Tests.ps1 @@ -108,7 +108,7 @@ Describe 'New-AnvilModule golden template' -Tag 'Integration' { Join-Path -ChildPath "$($script:ProjectName).psd1" $Content = Get-Content -Path $P -Raw $Content | Should -Match $script:ProjectName - $Content | Should -Not -Match '<%ModuleName%>' + $Content | Should -Not -Match '<%Name%>' } It 'manifest contains the correct author' { $P = Join-Path -Path $script:ProjectPath -ChildPath 'src' | @@ -123,7 +123,7 @@ Describe 'New-AnvilModule golden template' -Tag 'Integration' { Join-Path -ChildPath 'module.build.ps1' $Content = Get-Content -Path $P -Raw $Content | Should -Match $script:ProjectName - $Content | Should -Not -Match '<%ModuleName%>' + $Content | Should -Not -Match '<%Name%>' } It 'build settings contains the correct coverage threshold' { $P = Join-Path -Path $script:ProjectPath -ChildPath 'build' | @@ -209,8 +209,6 @@ Describe 'New-AnvilModule golden template' -Tag 'Integration' { $OriginalHash = @{ SomeKey = 'SomeValue' } $HashBefore = $OriginalHash.Clone() - # Call a second scaffold to a different path — the module should - # never touch $OriginalHash $Dest2 = Join-Path -Path $script:TempRoot -ChildPath 'MutationTest' New-Item -Path $Dest2 -ItemType Directory -Force | Out-Null New-AnvilModule -Name 'MutCheck' -DestinationPath $Dest2 -Author 'X' -Confirm:$false @@ -220,3 +218,214 @@ Describe 'New-AnvilModule golden template' -Tag 'Integration' { } } } + +Describe 'New-AnvilModule manifest conditions' -Tag 'Integration' { + + BeforeAll { + $script:CondRoot = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath "AnvilCondTest_$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $script:CondRoot -ItemType Directory -Force | Out-Null + } + + AfterAll { + if ($script:CondRoot -and (Test-Path -Path $script:CondRoot)) { + Remove-Item -Path $script:CondRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Context 'License = MIT' { + + BeforeAll { + $Params = @{ + Name = 'MitLicense' + DestinationPath = $script:CondRoot + Author = 'Test' + License = 'MIT' + PassThru = $true + Confirm = $false + } + $script:MitPath = New-AnvilModule @Params + $script:MitLicenseContent = Get-Content -Path (Join-Path $script:MitPath 'LICENSE') -Raw + } + + It 'creates a LICENSE file' { + Join-Path $script:MitPath 'LICENSE' | Should -Exist + } + + It 'contains the MIT license text' { + $script:MitLicenseContent | Should -Match 'MIT License' + $script:MitLicenseContent | Should -Match 'Permission is hereby granted' + } + + It 'does not contain Apache license text' { + $script:MitLicenseContent | Should -Not -Match 'Apache License' + } + + It 'has no section markers' { + $script:MitLicenseContent | Should -Not -Match '<%#section' + } + } + + Context 'License = Apache2' { + + BeforeAll { + $Params = @{ + Name = 'ApacheLicense' + DestinationPath = $script:CondRoot + Author = 'Test' + License = 'Apache2' + PassThru = $true + Confirm = $false + } + $script:ApachePath = New-AnvilModule @Params + $script:ApacheLicenseContent = Get-Content -Path (Join-Path $script:ApachePath 'LICENSE') -Raw + } + + It 'creates a LICENSE file' { + Join-Path $script:ApachePath 'LICENSE' | Should -Exist + } + + It 'contains the Apache 2.0 license text' { + $script:ApacheLicenseContent | Should -Match 'Apache License' + $script:ApacheLicenseContent | Should -Match 'Version 2\.0' + } + + It 'does not contain MIT license text' { + $script:ApacheLicenseContent | Should -Not -Match 'MIT License' + } + + It 'has no section markers' { + $script:ApacheLicenseContent | Should -Not -Match '<%#section' + } + } + + Context 'License = None' { + + BeforeAll { + $Params = @{ + Name = 'NoLicense' + DestinationPath = $script:CondRoot + Author = 'Test' + License = 'None' + PassThru = $true + Confirm = $false + } + $script:NoLicensePath = New-AnvilModule @Params + } + + It 'does not create a LICENSE file' { + Join-Path $script:NoLicensePath 'LICENSE' | Should -Not -Exist + } + + It 'still creates the module manifest' { + Join-Path $script:NoLicensePath "src/NoLicense/NoLicense.psd1" | Should -Exist + } + } + + Context 'IncludeDocs = $false' { + + BeforeAll { + $Params = @{ + Name = 'NoDocs' + DestinationPath = $script:CondRoot + Author = 'Test' + PassThru = $true + Confirm = $false + } + $script:NoDocsPath = New-AnvilModule @Params + $script:NoDocsBuild = Get-Content -Path (Join-Path $script:NoDocsPath 'build/module.build.ps1') -Raw + } + + It 'does not create files in docs/' { + $DocsDir = Join-Path $script:NoDocsPath 'docs' + $DocsFiles = Get-ChildItem -Path $DocsDir -File -Recurse -ErrorAction SilentlyContinue + $DocsFiles | Should -BeNullOrEmpty + } + + It 'still creates the build script' { + Join-Path $script:NoDocsPath 'build/module.build.ps1' | Should -Exist + } + + It 'build script does not contain the Docs task' { + $script:NoDocsBuild | Should -Not -Match 'task Docs \{' + } + + It 'build script composite task does not include Docs' { + $CompositeLine = $script:NoDocsBuild -split '\r?\n' | + Where-Object { $_ -match '^task \. ' } | + Select-Object -First 1 + $CompositeLine | Should -Not -Match 'Docs' + } + + It 'build script has no section markers' { + $script:NoDocsBuild | Should -Not -Match '<%#section' + $script:NoDocsBuild | Should -Not -Match '<%#endsection' + } + } + + Context 'CIProvider = None' { + + BeforeAll { + $Params = @{ + Name = 'NoCI' + DestinationPath = $script:CondRoot + Author = 'Test' + CIProvider = 'None' + PassThru = $true + Confirm = $false + } + $script:NoCIPath = New-AnvilModule @Params + } + + It 'does not create .github directory' { + Join-Path $script:NoCIPath '.github' | Should -Not -Exist + } + + It 'does not create any CI workflow files' { + $CIFiles = Get-ChildItem -Path $script:NoCIPath -Recurse -File | + Where-Object { $_.Name -match '(ci|pipeline|gitlab).*\.(yml|yaml)$' } + $CIFiles | Should -BeNullOrEmpty + } + + It 'still creates the module source' { + Join-Path $script:NoCIPath 'src/NoCI/NoCI.psd1' | Should -Exist + } + } + + Context 'IncludeDocs = $true' { + + BeforeAll { + $Params = @{ + Name = 'WithDocs' + DestinationPath = $script:CondRoot + Author = 'Test' + IncludeDocs = $true + PassThru = $true + Confirm = $false + } + $script:WithDocsPath = New-AnvilModule @Params + $script:WithDocsBuild = Get-Content -Path (Join-Path $script:WithDocsPath 'build/module.build.ps1') -Raw + } + + It 'creates files in docs/' { + $DocsDir = Join-Path $script:WithDocsPath 'docs' + $DocsFiles = Get-ChildItem -Path $DocsDir -File -Recurse -ErrorAction SilentlyContinue + $DocsFiles | Should -Not -BeNullOrEmpty + } + + It 'build script contains the Docs task' { + $script:WithDocsBuild | Should -Match 'task Docs \{' + } + + It 'build script composite task includes Docs' { + $CompositeLine = $script:WithDocsBuild -split '\r?\n' | + Where-Object { $_ -match '^task \. ' } | + Select-Object -First 1 + $CompositeLine | Should -Match 'Docs' + } + + It 'build script has no section markers' { + $script:WithDocsBuild | Should -Not -Match '<%#section' + $script:WithDocsBuild | Should -Not -Match '<%#endsection' + } + } +} diff --git a/tests/unit/Anvil.Module.Tests.ps1 b/tests/unit/Anvil.Module.Tests.ps1 index 556ea42..1f55b60 100644 --- a/tests/unit/Anvil.Module.Tests.ps1 +++ b/tests/unit/Anvil.Module.Tests.ps1 @@ -57,20 +57,9 @@ Describe 'Module: Anvil' -Tag 'Unit' { It 'exports <_>' -ForEach $DeclaredFunctions { (Get-Module -Name 'Anvil').ExportedFunctions.Keys | Should -Contain $_ } - It 'does not export private functions' { + It 'only exports functions from the Public directory' { $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys - $Exported | Should -Not -Contain 'Invoke-TemplateEngine' - $Exported | Should -Not -Contain 'Resolve-PathTokens' - $Exported | Should -Not -Contain 'Resolve-ContentTokens' - $Exported | Should -Not -Contain 'Assert-ValidConfiguration' - $Exported | Should -Not -Contain 'Copy-CITemplates' - $Exported | Should -Not -Contain 'Test-Excluded' - $Exported | Should -Not -Contain 'Get-TestContent' - $Exported | Should -Not -Contain 'Get-FunctionContent' - $Exported | Should -Not -Contain 'Get-ClassContent' - $Exported | Should -Not -Contain 'Invoke-InteractivePrompt' - $Exported | Should -Not -Contain 'Read-PromptValue' - $Exported | Should -Not -Contain 'Read-PromptChoice' + $Exported | Should -HaveCount $ExpectedFunctionCount } } diff --git a/tests/unit/Private/Assert-ManifestConfiguration.Tests.ps1 b/tests/unit/Private/Assert-ManifestConfiguration.Tests.ps1 new file mode 100644 index 0000000..43964d1 --- /dev/null +++ b/tests/unit/Private/Assert-ManifestConfiguration.Tests.ps1 @@ -0,0 +1,178 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop + + $script:TestManifest = @{ + Name = 'Test' + Description = 'Test' + Version = '1.0.0' + Parameters = @( + @{ Name = 'Name'; Type = 'string'; Required = $true; Prompt = 'Name' + Validate = '^[A-Za-z][A-Za-z0-9._\-]{0,127}$' + ValidateMessage = 'Must start with a letter.' } + @{ Name = 'Author'; Type = 'string'; Required = $true; Prompt = 'Author' } + @{ Name = 'Description'; Type = 'string'; Prompt = 'Description' } + @{ Name = 'License'; Type = 'choice'; Prompt = 'License'; Choices = @('MIT','Apache2','None'); Default = 'MIT' } + @{ Name = 'Coverage'; Type = 'int'; Prompt = 'Coverage'; Range = @(0, 100); Default = 80 } + @{ Name = 'ProjectUri'; Type = 'uri'; Prompt = 'URI'; Default = '' } + ) + } +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Assert-ManifestConfiguration' -Tag 'Unit' { + + Context 'valid configuration' { + + It 'accepts a valid configuration' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ + Name = 'ValidModule' + Author = 'Jane' + Description = 'A module' + License = 'MIT' + Coverage = 80 + ProjectUri = 'https://example.com' + } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | Should -Not -Throw + } + } + + It 'accepts empty optional values' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ + Name = 'ValidModule' + Author = 'Jane' + Description = '' + License = 'MIT' + Coverage = 80 + ProjectUri = '' + } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | Should -Not -Throw + } + } + } + + Context 'required validation' { + + It 'rejects missing required value' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ Name = ''; Author = 'Jane'; License = 'MIT'; Coverage = 80 } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | + Should -Throw "*'Name' is required*" + } + } + } + + Context 'regex validation' { + + It 'rejects value that fails regex with custom message' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ Name = '123bad'; Author = 'Jane'; License = 'MIT'; Coverage = 80 } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | + Should -Throw '*Must start with a letter*' + } + } + + It 'uses generic message when no ValidateMessage is set' { + InModuleScope 'Anvil' { + $Manifest = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @( + @{ Name = 'Field'; Type = 'string'; Prompt = 'F'; Validate = '^\d+$' } + ) + } + $Config = @{ Field = 'notdigits' } + { Assert-ManifestConfiguration -Manifest $Manifest -Configuration $Config } | + Should -Throw "*does not match required format*" + } + } + } + + Context 'choice validation' { + + It 'rejects value not in choices' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ Name = 'Mod'; Author = 'Jane'; License = 'GPL'; Coverage = 80 } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | + Should -Throw "*'License' must be one of*" + } + } + } + + Context 'range validation' { + + It 'rejects value above maximum' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ Name = 'Mod'; Author = 'Jane'; License = 'MIT'; Coverage = 200 } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | + Should -Throw "*'Coverage' must be between 0 and 100*" + } + } + + It 'rejects value below minimum' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ Name = 'Mod'; Author = 'Jane'; License = 'MIT'; Coverage = -5 } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | + Should -Throw "*'Coverage' must be between 0 and 100*" + } + } + } + + Context 'URI validation' { + + It 'rejects invalid URI' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ Name = 'Mod'; Author = 'Jane'; License = 'MIT'; Coverage = 80; ProjectUri = 'not-a-uri' } + { Assert-ManifestConfiguration -Manifest $M -Configuration $Config } | + Should -Throw "*'ProjectUri' must be a valid absolute URI*" + } + } + } + + Context 'error aggregation' { + + It 'reports multiple errors in a single throw' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Config = @{ Name = ''; Author = ''; License = 'GPL'; Coverage = 200 } + try { + Assert-ManifestConfiguration -Manifest $M -Configuration $Config + } catch { + $_.Exception.Message | Should -Match "'Name' is required" + $_.Exception.Message | Should -Match "'Author' is required" + $_.Exception.Message | Should -Match "'License' must be one of" + $_.Exception.Message | Should -Match "'Coverage' must be between" + return + } + throw 'Expected Assert-ManifestConfiguration to throw' + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Assert-ManifestConfiguration' + } +} diff --git a/tests/unit/Private/Assert-TemplateManifest.Tests.ps1 b/tests/unit/Private/Assert-TemplateManifest.Tests.ps1 new file mode 100644 index 0000000..a20ddef --- /dev/null +++ b/tests/unit/Private/Assert-TemplateManifest.Tests.ps1 @@ -0,0 +1,293 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Assert-TemplateManifest' -Tag 'Unit' { + + BeforeAll { + $script:MinimalValid = @{ + Name = 'TestTemplate' + Description = 'A test template' + Version = '1.0.0' + Parameters = @( + @{ Name = 'Name'; Type = 'string'; Prompt = 'Module name' } + ) + } + } + + Context 'valid manifests' { + + It 'accepts a minimal valid manifest' { + InModuleScope 'Anvil' -Parameters @{ M = $script:MinimalValid } { + param($M) + { Assert-TemplateManifest -Manifest $M } | Should -Not -Throw + } + } + + It 'accepts a full manifest with all optional keys' { + InModuleScope 'Anvil' { + $Full = @{ + Name = 'Module' + Description = 'Full template' + Version = '2.0.0' + Parameters = @( + @{ Name = 'Name'; Type = 'string'; Required = $true; Prompt = 'Name' + Validate = '^\w+$'; ValidateMessage = 'Alpha only'; Format = 'raw' } + @{ Name = 'License'; Type = 'choice'; Prompt = 'License'; Choices = @('MIT','None') } + @{ Name = 'Coverage'; Type = 'int'; Prompt = 'Coverage'; Range = @(0, 100) } + @{ Name = 'Docs'; Type = 'bool'; Prompt = 'Include docs?'; Format = 'lower-string' } + @{ Name = 'Tags'; Type = 'csv'; Prompt = 'Tags'; Format = 'psd1-array' } + @{ Name = 'Uri'; Type = 'uri'; Prompt = 'Project URI' } + @{ Name = 'Author'; Type = 'string'; Prompt = 'Author'; DefaultFrom = 'GitUserName' } + ) + AutoTokens = @( + @{ Name = 'ModuleGuid'; Source = 'NewGuid' } + @{ Name = 'Year'; Source = 'CurrentYear' } + ) + IncludeWhen = @{ 'docs/*' = @{ Docs = 'true' } } + ExcludeWhen = @{ 'LICENSE.tmpl' = @{ License = 'None' } } + Sections = @{ + DocsTask = @{ IncludeWhen = @{ Docs = 'true' } } + } + Layers = @( + @{ PathKey = 'CIProvider'; BasePath = 'CI'; Skip = 'None' } + ) + } + { Assert-TemplateManifest -Manifest $Full } | Should -Not -Throw + } + } + } + + Context 'missing required top-level keys' { + + It 'rejects manifest missing Name' { + InModuleScope 'Anvil' { + $M = @{ + Description = 'Test'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*'Name' is required*" + } + } + + It 'rejects manifest missing Parameters' { + InModuleScope 'Anvil' { + $M = @{ Name = 'T'; Description = 'T'; Version = '1.0.0' } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*'Parameters' is required*" + } + } + + It 'rejects manifest with empty Parameters' { + InModuleScope 'Anvil' { + $M = @{ Name = 'T'; Description = 'T'; Version = '1.0.0'; Parameters = @() } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*'Parameters' is required*" + } + } + } + + Context 'parameter validation' { + + It 'rejects parameter with unknown Type' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'number'; Prompt = 'X' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*Type 'number' is not valid*" + } + } + + It 'rejects choice parameter missing Choices' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'choice'; Prompt = 'X' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*'Choices' is required for choice type*" + } + } + + It 'rejects parameter with invalid Range' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'int'; Prompt = 'X'; Range = @(100, 0) }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*Range minimum*must not exceed*" + } + } + + It 'rejects parameter with unknown Format' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X'; Format = 'xml-array' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*Format 'xml-array' is not valid*" + } + } + + It 'rejects parameter with unknown DefaultFrom' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X'; DefaultFrom = 'MagicValue' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*DefaultFrom 'MagicValue' is not valid*" + } + } + + It 'rejects parameter with invalid Validate regex' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X'; Validate = '[invalid' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*not a valid regex*" + } + } + + It 'rejects duplicate parameter names' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @( + @{ Name = 'Dup'; Type = 'string'; Prompt = 'First' } + @{ Name = 'Dup'; Type = 'string'; Prompt = 'Second' } + ) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*duplicate name 'Dup'*" + } + } + + It 'rejects parameter missing Prompt' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*'Prompt' is required*" + } + } + } + + Context 'auto-token validation' { + + It 'rejects auto-token with unknown Source' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X' }) + AutoTokens = @(@{ Name = 'Magic'; Source = 'Imaginary' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*Source 'Imaginary' is not valid*" + } + } + + It 'rejects auto-token name that duplicates a parameter name' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'Year'; Type = 'string'; Prompt = 'Year' }) + AutoTokens = @(@{ Name = 'Year'; Source = 'CurrentYear' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*duplicate name 'Year'*" + } + } + } + + Context 'section validation' { + + It 'rejects section with neither IncludeWhen nor ExcludeWhen' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X' }) + Sections = @{ Bad = @{ SomethingElse = @{ X = 'y' } } } + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*must have either 'IncludeWhen' or 'ExcludeWhen'*" + } + } + + It 'rejects section with both IncludeWhen and ExcludeWhen' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X' }) + Sections = @{ + Bad = @{ + IncludeWhen = @{ X = 'a' } + ExcludeWhen = @{ X = 'b' } + } + } + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*must have only one of*" + } + } + } + + Context 'layer validation' { + + It 'rejects layer missing PathKey' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X' }) + Layers = @(@{ BasePath = 'CI' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*'PathKey' is required*" + } + } + + It 'rejects layer missing BasePath' { + InModuleScope 'Anvil' { + $M = @{ + Name = 'T'; Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Name = 'X'; Type = 'string'; Prompt = 'X' }) + Layers = @(@{ PathKey = 'CI' }) + } + { Assert-TemplateManifest -Manifest $M } | Should -Throw "*'BasePath' is required*" + } + } + } + + Context 'error aggregation' { + + It 'reports multiple errors in a single throw' { + InModuleScope 'Anvil' { + $M = @{ + Description = 'T'; Version = '1.0.0' + Parameters = @(@{ Type = 'string'; Prompt = 'X' }) + } + try { + Assert-TemplateManifest -Manifest $M + } catch { + $_.Exception.Message | Should -Match "'Name' is required" + $_.Exception.Message | Should -Match "Parameters\[0\].*'Name' is required" + return + } + throw 'Expected Assert-TemplateManifest to throw' + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Assert-TemplateManifest' + } +} diff --git a/tests/unit/Private/Assert-ValidConfiguration.Tests.ps1 b/tests/unit/Private/Assert-ValidConfiguration.Tests.ps1 deleted file mode 100644 index 3156cdd..0000000 --- a/tests/unit/Private/Assert-ValidConfiguration.Tests.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } - -BeforeAll { - $ProjectRoot = $PSScriptRoot - while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { - $ProjectRoot = Split-Path $ProjectRoot -Parent - } - $ModuleName = 'Anvil' - $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName - $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" - - Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ManifestPath -Force -ErrorAction Stop - -} - -AfterAll { - Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force -} - -Describe 'Assert-ValidConfiguration' -Tag 'Unit' { - - Context 'Valid configurations' { - It 'accepts a minimal valid config' { - $Config = @{ - ModuleName = 'MyModule' - Author = 'Test Author' - Description = 'A test module' - } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Not -Throw - } - - It 'accepts a full config with all optional keys' { - $Config = @{ - ModuleName = 'My.Cool-Module_1' - Author = 'Jane Doe' - Description = 'Something useful' - CIProvider = 'GitHub' - License = 'MIT' - CoverageThreshold = 80 - MinPowerShellVersion = '7.0' - } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Not -Throw - } - } - - Context 'Missing required keys' { - It 'rejects a config missing ModuleName' { - $Config = @{ Author = 'X'; Description = 'Y' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*ModuleName*' - } - - It 'rejects a config missing Author' { - $Config = @{ ModuleName = 'X'; Description = 'Y' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*Author*' - } - - It 'rejects a config with empty Description' { - $Config = @{ ModuleName = 'X'; Author = 'Y'; Description = '' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*Description*' - } - } - - Context 'ModuleName validation' { - It 'rejects names starting with a digit' { - $Config = @{ ModuleName = '1Bad'; Author = 'X'; Description = 'Y' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*invalid characters*' - } - - It 'rejects names with spaces' { - $Config = @{ ModuleName = 'My Module'; Author = 'X'; Description = 'Y' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*invalid characters*' - } - } - - Context 'Enum validation' { - It 'rejects an unknown CIProvider' { - $Config = @{ ModuleName = 'X'; Author = 'Y'; Description = 'Z'; CIProvider = 'Jenkins' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*CIProvider*' - } - - It 'rejects an unknown License' { - $Config = @{ ModuleName = 'X'; Author = 'Y'; Description = 'Z'; License = 'GPL' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*License*' - } - } - - Context 'Numeric validation' { - It 'rejects CoverageThreshold above 100' { - $Config = @{ ModuleName = 'X'; Author = 'Y'; Description = 'Z'; CoverageThreshold = 150 } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*CoverageThreshold*' - } - - It 'rejects an invalid MinPowerShellVersion' { - $Config = @{ ModuleName = 'X'; Author = 'Y'; Description = 'Z'; MinPowerShellVersion = 'nope' } - { InModuleScope 'Anvil' -ArgumentList $Config { param($Config) Assert-ValidConfiguration -Configuration $Config } } | - Should -Throw '*MinPowerShellVersion*' - } - } -} diff --git a/tests/unit/Private/Convert-PromptResult.Tests.ps1 b/tests/unit/Private/Convert-PromptResult.Tests.ps1 new file mode 100644 index 0000000..465f6af --- /dev/null +++ b/tests/unit/Private/Convert-PromptResult.Tests.ps1 @@ -0,0 +1,119 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Convert-PromptResult' -Tag 'Unit' { + + Context 'csv type' { + + It 'splits comma-separated string into array' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value 'A, B, C' -Type 'csv' + $Result | Should -HaveCount 3 + $Result[0] | Should -Be 'A' + $Result[2] | Should -Be 'C' + } + } + + It 'passes through arrays unchanged' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value @('X', 'Y') -Type 'csv' + $Result | Should -HaveCount 2 + $Result[0] | Should -Be 'X' + } + } + + It 'returns empty array for empty string' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value '' -Type 'csv' + $Result | Should -HaveCount 0 + } + } + + It 'returns empty array for null' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value $null -Type 'csv' + $Result | Should -HaveCount 0 + } + } + } + + Context 'int type' { + + It 'casts string to integer' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value '42' -Type 'int' + $Result | Should -Be 42 + $Result | Should -BeOfType [int] + } + } + + It 'passes through integers unchanged' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value 80 -Type 'int' + $Result | Should -Be 80 + } + } + } + + Context 'bool type' { + + It 'passes through boolean true' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value $true -Type 'bool' + $Result | Should -BeTrue + } + } + + It 'passes through boolean false' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value $false -Type 'bool' + $Result | Should -BeFalse + } + } + + It 'converts y to true' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value 'y' -Type 'bool' + $Result | Should -BeTrue + } + } + + It 'converts n to false' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value 'n' -Type 'bool' + $Result | Should -BeFalse + } + } + } + + Context 'string type (default)' { + + It 'returns string values unchanged' { + InModuleScope 'Anvil' { + $Result = Convert-PromptResult -Value 'hello' -Type 'string' + $Result | Should -Be 'hello' + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Convert-PromptResult' + } +} diff --git a/tests/unit/Private/Copy-CITemplates.Tests.ps1 b/tests/unit/Private/Copy-CITemplates.Tests.ps1 deleted file mode 100644 index 354622c..0000000 --- a/tests/unit/Private/Copy-CITemplates.Tests.ps1 +++ /dev/null @@ -1,46 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } - -BeforeAll { - $ProjectRoot = $PSScriptRoot - while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { - $ProjectRoot = Split-Path $ProjectRoot -Parent - } - $ModuleName = 'Anvil' - $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName - $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" - - Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ManifestPath -Force -ErrorAction Stop -} - -AfterAll { - Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force -} - -Describe 'Copy-CITemplates' -Tag 'Unit' { - - It 'returns a file count for a valid provider' { - $DestDir = Join-Path $TestDrive 'ci-test' - $Tokens = @{ ModuleName = 'TestMod' } - $Count = InModuleScope 'Anvil' -ArgumentList $DestDir, $Tokens { - param($Dst, $Tok) - Copy-CITemplates -Provider 'GitHub' -DestinationPath $Dst -Tokens $Tok - } - $Count | Should -BeGreaterThan 0 - } - - It 'creates CI workflow files' { - $DestDir = Join-Path $TestDrive 'ci-test2' - $Tokens = @{ ModuleName = 'TestMod' } - InModuleScope 'Anvil' -ArgumentList $DestDir, $Tokens { - param($Dst, $Tok) - Copy-CITemplates -Provider 'GitHub' -DestinationPath $Dst -Tokens $Tok - } - Join-Path $DestDir '.github/workflows' | Should -Exist - } - - It 'is not exported' { - $Exported = (Get-Module 'Anvil').ExportedFunctions.Keys - $Exported | Should -Not -Contain 'Copy-CITemplates' - } -} diff --git a/tests/unit/Private/Format-TokenValue.Tests.ps1 b/tests/unit/Private/Format-TokenValue.Tests.ps1 new file mode 100644 index 0000000..8f4b7f3 --- /dev/null +++ b/tests/unit/Private/Format-TokenValue.Tests.ps1 @@ -0,0 +1,117 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Format-TokenValue' -Tag 'Unit' { + + Context 'raw formatter' { + + It 'returns string value unchanged' { + InModuleScope 'Anvil' { + Format-TokenValue -Value 'hello' -Formatter 'raw' | Should -Be 'hello' + } + } + + It 'converts integer to string' { + InModuleScope 'Anvil' { + Format-TokenValue -Value 80 -Formatter 'raw' | Should -Be '80' + } + } + + It 'returns empty string for null' { + InModuleScope 'Anvil' { + Format-TokenValue -Value $null -Formatter 'raw' | Should -Be '' + } + } + } + + Context 'psd1-array formatter' { + + It 'formats a string array as psd1 array literal' { + InModuleScope 'Anvil' { + $Result = Format-TokenValue -Value @('Desktop', 'Core') -Formatter 'psd1-array' + $Result | Should -Be "@('Desktop', 'Core')" + } + } + + It 'formats a single-element array' { + InModuleScope 'Anvil' { + $Result = Format-TokenValue -Value @('Core') -Formatter 'psd1-array' + $Result | Should -Be "@('Core')" + } + } + + It 'formats an empty array as @()' { + InModuleScope 'Anvil' { + $Result = Format-TokenValue -Value @() -Formatter 'psd1-array' + $Result | Should -Be '@()' + } + } + } + + Context 'lower-string formatter' { + + It 'formats boolean true as lowercase string' { + InModuleScope 'Anvil' { + Format-TokenValue -Value $true -Formatter 'lower-string' | Should -Be 'true' + } + } + + It 'formats boolean false as lowercase string' { + InModuleScope 'Anvil' { + Format-TokenValue -Value $false -Formatter 'lower-string' | Should -Be 'false' + } + } + + It 'lowercases a string value' { + InModuleScope 'Anvil' { + Format-TokenValue -Value 'GitHub' -Formatter 'lower-string' | Should -Be 'github' + } + } + } + + Context 'quoted formatter' { + + It 'wraps value in single quotes' { + InModuleScope 'Anvil' { + Format-TokenValue -Value 'hello' -Formatter 'quoted' | Should -Be "'hello'" + } + } + + It 'wraps empty string in single quotes' { + InModuleScope 'Anvil' { + Format-TokenValue -Value '' -Formatter 'quoted' | Should -Be "''" + } + } + } + + Context 'unknown formatter' { + + It 'throws for an unrecognized formatter name' { + InModuleScope 'Anvil' { + { Format-TokenValue -Value 'x' -Formatter 'nonexistent' } | + Should -Throw "*Unknown formatter 'nonexistent'*" + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Format-TokenValue' + } +} diff --git a/tests/unit/Private/Invoke-InteractivePrompt.Tests.ps1 b/tests/unit/Private/Invoke-InteractivePrompt.Tests.ps1 deleted file mode 100644 index b258f8d..0000000 --- a/tests/unit/Private/Invoke-InteractivePrompt.Tests.ps1 +++ /dev/null @@ -1,218 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } - -BeforeAll { - $ProjectRoot = $PSScriptRoot - while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { - $ProjectRoot = Split-Path $ProjectRoot -Parent - } - $ModuleName = 'Anvil' - $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName - $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" - - Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ManifestPath -Force -ErrorAction Stop -} - -AfterAll { - Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force -} - -Describe 'Invoke-InteractivePrompt' -Tag 'Unit' { - - BeforeAll { - $script:TestDefaults = @{ - Description = 'A PowerShell module scaffolded by Anvil.' - CompanyName = '' - MinPowerShellVersion = '5.1' - CompatiblePSEditions = @('Desktop', 'Core') - CIProvider = 'GitHub' - License = 'MIT' - CoverageThreshold = 80 - IncludeDocs = $false - Tags = @() - ProjectUri = '' - LicenseUri = '' - GitInit = $false - } - } - - Context 'All values pre-bound' { - It 'returns bound values without prompting' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Read-Host { throw 'Read-Host should not be called' } - Mock Write-Host {} - Mock Resolve-AuthorName { $null } - - $Params = @{ - Name = 'TestMod' - DestinationPath = 'C:\temp' - Author = 'Tester' - Description = 'A test module' - CompanyName = 'TestCo' - MinPowerShellVersion = '7.2' - CompatiblePSEditions = @('Core') - CIProvider = 'GitHub' - License = 'MIT' - CoverageThreshold = 90 - IncludeDocs = $true - Tags = @('Test') - ProjectUri = 'https://example.com' - LicenseUri = 'https://example.com/license' - GitInit = $false - Force = $false - PassThru = $true - } - - $Result = Invoke-InteractivePrompt -BoundParams $Params -Defaults $Defaults -Interactive $true - $Result.Name | Should -Be 'TestMod' - $Result.Author | Should -Be 'Tester' - $Result.CIProvider | Should -Be 'GitHub' - $Result.PassThru | Should -BeTrue - $Result.GitInit | Should -BeFalse - Should -Invoke Read-Host -Times 0 -Exactly - } - } - } - - Context 'Interactive mode — no values pre-bound' { - It 'prompts for all values and returns a complete hashtable' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Write-Host {} - Mock Resolve-AuthorName { $null } - Mock Read-PromptValue { param($Prompt, $Default, [switch]$Required) - if ($Required -and -not $Default) { return 'PromptMod' } - return $Default - } - Mock Read-PromptChoice { param($Prompt, $Choices, $Default) return $Default } - - $Result = Invoke-InteractivePrompt -BoundParams @{} -Defaults $Defaults -Interactive $true - - $Result.Name | Should -Be 'PromptMod' - $Result.CIProvider | Should -Be 'GitHub' - $Result.License | Should -Be 'MIT' - $Result.CoverageThreshold | Should -Be 80 - $Result.Force | Should -BeFalse - $Result.PassThru | Should -BeFalse - } - } - } - - Context 'Interactive mode — partial values pre-bound' { - It 'prompts only for missing values' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Write-Host {} - Mock Resolve-AuthorName { $null } - Mock Read-PromptValue { param($Prompt, $Default, [switch]$Required) - if ($Required -and -not $Default) { return 'Prompted' } - return $Default - } - Mock Read-PromptChoice { param($Prompt, $Choices, $Default) return $Default } - - $Params = @{ - Name = 'PreBound' - Author = 'KnownAuthor' - } - - $Result = Invoke-InteractivePrompt -BoundParams $Params -Defaults $Defaults -Interactive $true - $Result.Name | Should -Be 'PreBound' - $Result.Author | Should -Be 'KnownAuthor' - $Result.CIProvider | Should -Be 'GitHub' - } - } - } - - Context 'Non-interactive mode' { - It 'fills optional values from Defaults silently' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Write-Host {} - Mock Read-Host { throw 'Read-Host should not be called' } - Mock Resolve-AuthorName { $null } - - $Params = @{ - Name = 'CIMod' - DestinationPath = 'C:\temp' - Author = 'CI' - } - - $Result = Invoke-InteractivePrompt -BoundParams $Params -Defaults $Defaults - $Result.Description | Should -Be 'A PowerShell module scaffolded by Anvil.' - $Result.MinPowerShellVersion | Should -Be '5.1' - $Result.CompatiblePSEditions | Should -Be @('Desktop', 'Core') - $Result.CIProvider | Should -Be 'GitHub' - $Result.License | Should -Be 'MIT' - $Result.CoverageThreshold | Should -Be 80 - $Result.IncludeDocs | Should -BeFalse - $Result.Tags | Should -Be @() - $Result.GitInit | Should -BeFalse - Should -Invoke Read-Host -Times 0 -Exactly - } - } - - It 'throws when Name is missing' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Resolve-AuthorName { $null } - - { Invoke-InteractivePrompt -BoundParams @{} -Defaults $Defaults } | - Should -Throw "*'Name'*required*" - } - } - - It 'throws when DestinationPath is missing' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Resolve-AuthorName { $null } - - $Params = @{ Name = 'Test' } - { Invoke-InteractivePrompt -BoundParams $Params -Defaults $Defaults } | - Should -Throw "*'DestinationPath'*required*" - } - } - - It 'throws when Author is missing and git has no user.name' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Resolve-AuthorName { $null } - - $Params = @{ - Name = 'Test' - DestinationPath = 'C:\temp' - } - { Invoke-InteractivePrompt -BoundParams $Params -Defaults $Defaults } | - Should -Throw "*'Author'*required*" - } - } - - It 'falls back to git user.name for Author when available' { - InModuleScope 'Anvil' -Parameters @{ Defaults = $script:TestDefaults } { - param($Defaults) - - Mock Write-Host {} - Mock Resolve-AuthorName { 'Git Author' } - - $Params = @{ - Name = 'Test' - DestinationPath = 'C:\temp' - } - $Result = Invoke-InteractivePrompt -BoundParams $Params -Defaults $Defaults - $Result.Author | Should -Be 'Git Author' - } - } - } - - It 'is not exported' { - $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys - $Exported | Should -Not -Contain 'Invoke-InteractivePrompt' - } -} diff --git a/tests/unit/Private/Invoke-ManifestPrompt.Tests.ps1 b/tests/unit/Private/Invoke-ManifestPrompt.Tests.ps1 new file mode 100644 index 0000000..cd649d6 --- /dev/null +++ b/tests/unit/Private/Invoke-ManifestPrompt.Tests.ps1 @@ -0,0 +1,198 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop + + $script:TestManifest = @{ + Name = 'Test' + Description = 'Test template' + Version = '1.0.0' + Parameters = @( + @{ Name = 'Name'; Type = 'string'; Required = $true; Prompt = 'Module name' } + @{ Name = 'Author'; Type = 'string'; Required = $true; Prompt = 'Author'; DefaultFrom = 'GitUserName' } + @{ Name = 'Description'; Type = 'string'; Prompt = 'Description'; Default = 'Default desc' } + @{ Name = 'License'; Type = 'choice'; Prompt = 'License'; Choices = @('MIT','Apache2','None'); Default = 'MIT' } + @{ Name = 'Coverage'; Type = 'int'; Prompt = 'Coverage'; Default = 80; Range = @(0, 100) } + @{ Name = 'IncludeDocs'; Type = 'bool'; Prompt = 'Include docs?'; Default = $false; Format = 'lower-string' } + @{ Name = 'Tags'; Type = 'csv'; Prompt = 'Tags'; Default = ''; Format = 'psd1-array' } + @{ Name = 'ProjectUri'; Type = 'uri'; Prompt = 'Project URI'; Default = '' } + ) + } +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Invoke-ManifestPrompt' -Tag 'Unit' { + + Context 'all values pre-bound' { + + It 'returns bound values without prompting' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + Mock Read-Host { throw 'should not be called' } + $Bound = @{ + Name = 'TestMod' + Author = 'Jane' + Description = 'Custom' + License = 'Apache2' + Coverage = 90 + IncludeDocs = $true + Tags = @('A', 'B') + ProjectUri = 'https://example.com' + } + $Result = Invoke-ManifestPrompt -Manifest $M -BoundParams $Bound + $Result.Name | Should -Be 'TestMod' + $Result.Author | Should -Be 'Jane' + $Result.License | Should -Be 'Apache2' + $Result.Coverage | Should -Be 90 + $Result.IncludeDocs | Should -BeTrue + Should -Not -Invoke Read-Host + } + } + } + + Context 'non-interactive with defaults' { + + It 'fills optional values from defaults silently' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + Mock Resolve-AuthorName { 'GitUser' } + $Bound = @{ Name = 'TestMod' } + $Result = Invoke-ManifestPrompt -Manifest $M -BoundParams $Bound -Interactive $false + $Result.Name | Should -Be 'TestMod' + $Result.Author | Should -Be 'GitUser' + $Result.Description | Should -Be 'Default desc' + $Result.License | Should -Be 'MIT' + $Result.Coverage | Should -Be 80 + $Result.IncludeDocs | Should -BeFalse + } + } + + It 'throws when required value is missing and no default' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + Mock Resolve-AuthorName { $null } + $Bound = @{} + { Invoke-ManifestPrompt -Manifest $M -BoundParams $Bound -Interactive $false } | + Should -Throw "*'Name' is required*" + } + } + } + + Context 'interactive mode' { + + It 'prompts for missing values by type' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Script:PromptCount = 0 + Mock Read-Host { + $Script:PromptCount++ + switch ($Script:PromptCount) { + 1 { 'MyModule' } # Name (string) + 2 { 'John' } # Author (string) + 3 { '' } # Description (default) + 4 { '' } # License (default) + 5 { '' } # Coverage (default) + 6 { '' } # IncludeDocs (default n) + 7 { '' } # Tags (default empty) + 8 { '' } # ProjectUri (default empty) + } + } + Mock Write-Host {} + + $Result = Invoke-ManifestPrompt -Manifest $M -BoundParams @{} -Interactive $true + $Result.Name | Should -Be 'MyModule' + $Result.Author | Should -Be 'John' + $Result.Description | Should -Be 'Default desc' + $Result.License | Should -Be 'MIT' + $Result.Coverage | Should -Be 80 + $Result.IncludeDocs | Should -BeFalse + } + } + + It 'skips bound parameters' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Script:PromptCount = 0 + Mock Read-Host { + $Script:PromptCount++ + switch ($Script:PromptCount) { + 1 { 'Jane' } # Author + 2 { '' } # Description + 3 { '' } # License + 4 { '' } # Coverage + 5 { '' } # IncludeDocs + 6 { '' } # Tags + 7 { '' } # ProjectUri + } + } + Mock Write-Host {} + + $Bound = @{ Name = 'PreBound' } + $Result = Invoke-ManifestPrompt -Manifest $M -BoundParams $Bound -Interactive $true + $Result.Name | Should -Be 'PreBound' + $Result.Author | Should -Be 'Jane' + } + } + } + + Context 'csv type handling' { + + It 'splits comma-separated input into array' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + $Bound = @{ + Name = 'M' + Author = 'A' + Description = 'D' + License = 'MIT' + Coverage = 80 + IncludeDocs = $false + Tags = 'PowerShell, Module, Tools' + ProjectUri = '' + } + $Result = Invoke-ManifestPrompt -Manifest $M -BoundParams $Bound + $Result.Tags | Should -HaveCount 3 + $Result.Tags[0] | Should -Be 'PowerShell' + $Result.Tags[2] | Should -Be 'Tools' + } + } + + It 'returns empty array for empty csv default' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + Mock Resolve-AuthorName { 'Git' } + $Result = Invoke-ManifestPrompt -Manifest $M -BoundParams @{ Name = 'M' } + $Result.Tags | Should -HaveCount 0 + } + } + } + + Context 'DefaultFrom resolvers' { + + It 'uses GitUserName resolver for Author default' { + InModuleScope 'Anvil' -Parameters @{ M = $script:TestManifest } { + param($M) + Mock Resolve-AuthorName { 'GitConfigUser' } + $Result = Invoke-ManifestPrompt -Manifest $M -BoundParams @{ Name = 'M' } + $Result.Author | Should -Be 'GitConfigUser' + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Invoke-ManifestPrompt' + } +} diff --git a/tests/unit/Private/Invoke-TemplateEngine.Tests.ps1 b/tests/unit/Private/Invoke-TemplateEngine.Tests.ps1 index 4356ed4..0b33b14 100644 --- a/tests/unit/Private/Invoke-TemplateEngine.Tests.ps1 +++ b/tests/unit/Private/Invoke-TemplateEngine.Tests.ps1 @@ -98,6 +98,175 @@ Describe 'Invoke-TemplateEngine' -Tag 'Unit' { } } + Context 'Manifest conditions' { + + BeforeEach { + $script:CondSrc = Join-Path $TestDrive "condsrc_$([guid]::NewGuid().ToString('N').Substring(0,8))" + $script:CondDst = Join-Path $TestDrive "conddst_$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $script:CondSrc -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $script:CondSrc 'keep.txt') -Value 'kept' + Set-Content -Path (Join-Path $script:CondSrc 'LICENSE.tmpl') -Value 'MIT License <%Author%>' + $DocsDir = Join-Path $script:CondSrc 'docs' + New-Item -Path $DocsDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $DocsDir 'README.md.tmpl') -Value '# Docs for <%ModuleName%>' + } + + It 'excludes files matching ExcludeWhen condition' { + InModuleScope 'Anvil' -Parameters @{ Src = $script:CondSrc; Dst = $script:CondDst } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ License = 'None'; Author = 'Test'; ModuleName = 'M' } + ExcludeWhen = @{ 'LICENSE.tmpl' = @{ License = 'None' } } + } + Invoke-TemplateEngine @Params + } + Join-Path $script:CondDst 'LICENSE' | Should -Not -Exist + Join-Path $script:CondDst 'keep.txt' | Should -Exist + } + + It 'includes files when ExcludeWhen condition does not match' { + InModuleScope 'Anvil' -Parameters @{ Src = $script:CondSrc; Dst = $script:CondDst } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ License = 'MIT'; Author = 'Test'; ModuleName = 'M' } + ExcludeWhen = @{ 'LICENSE.tmpl' = @{ License = 'None' } } + } + Invoke-TemplateEngine @Params + } + Join-Path $script:CondDst 'LICENSE' | Should -Exist + } + + It 'excludes files when IncludeWhen condition does not match' { + InModuleScope 'Anvil' -Parameters @{ Src = $script:CondSrc; Dst = $script:CondDst } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ License = 'MIT'; Author = 'Test'; ModuleName = 'M'; IncludeDocs = 'false' } + IncludeWhen = @{ 'docs/*' = @{ IncludeDocs = 'true' } } + } + Invoke-TemplateEngine @Params + } + Join-Path $script:CondDst 'docs/README.md' | Should -Not -Exist + } + + It 'includes files when IncludeWhen condition matches' { + InModuleScope 'Anvil' -Parameters @{ Src = $script:CondSrc; Dst = $script:CondDst } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ License = 'MIT'; Author = 'Test'; ModuleName = 'M'; IncludeDocs = 'true' } + IncludeWhen = @{ 'docs/*' = @{ IncludeDocs = 'true' } } + } + Invoke-TemplateEngine @Params + } + Join-Path $script:CondDst 'docs/README.md' | Should -Exist + } + + It 'processes all files when no conditions are specified' { + InModuleScope 'Anvil' -Parameters @{ Src = $script:CondSrc; Dst = $script:CondDst } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ License = 'MIT'; Author = 'Test'; ModuleName = 'M' } + } + Invoke-TemplateEngine @Params + } + Join-Path $script:CondDst 'keep.txt' | Should -Exist + Join-Path $script:CondDst 'LICENSE' | Should -Exist + Join-Path $script:CondDst 'docs/README.md' | Should -Exist + } + } + + Context 'Section processing' { + + It 'strips sections when condition does not match' { + $SrcDir = Join-Path $TestDrive "secsrc_$([guid]::NewGuid().ToString('N').Substring(0,8))" + $DstDir = Join-Path $TestDrive "secdst_$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $SrcDir -ItemType Directory -Force | Out-Null + $Content = @" +before +<%#section DocsTask%> +task Docs { } +<%#endsection%> +after +"@ + Set-Content -Path (Join-Path $SrcDir 'build.ps1.tmpl') -Value $Content + InModuleScope 'Anvil' -Parameters @{ Src = $SrcDir; Dst = $DstDir } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ IncludeDocs = 'false' } + Sections = @{ DocsTask = @{ IncludeWhen = @{ IncludeDocs = 'true' } } } + } + Invoke-TemplateEngine @Params + } + $Result = Get-Content (Join-Path $DstDir 'build.ps1') -Raw + $Result | Should -Match 'before' + $Result | Should -Match 'after' + $Result | Should -Not -Match 'task Docs' + } + + It 'keeps sections when condition matches' { + $SrcDir = Join-Path $TestDrive "secsrc2_$([guid]::NewGuid().ToString('N').Substring(0,8))" + $DstDir = Join-Path $TestDrive "secdst2_$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $SrcDir -ItemType Directory -Force | Out-Null + $Content = @" +before +<%#section DocsTask%> +task Docs { } +<%#endsection%> +after +"@ + Set-Content -Path (Join-Path $SrcDir 'build.ps1.tmpl') -Value $Content + InModuleScope 'Anvil' -Parameters @{ Src = $SrcDir; Dst = $DstDir } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ IncludeDocs = 'true' } + Sections = @{ DocsTask = @{ IncludeWhen = @{ IncludeDocs = 'true' } } } + } + Invoke-TemplateEngine @Params + } + $Result = Get-Content (Join-Path $DstDir 'build.ps1') -Raw + $Result | Should -Match 'task Docs' + $Result | Should -Not -Match '<%#section' + } + + It 'applies token replacement after section processing' { + $SrcDir = Join-Path $TestDrive "secsrc3_$([guid]::NewGuid().ToString('N').Substring(0,8))" + $DstDir = Join-Path $TestDrive "secdst3_$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $SrcDir -ItemType Directory -Force | Out-Null + $TokenPattern = '<%' + 'ModuleName' + '%>' + $Content = @" +<%#section Header%> +# $TokenPattern +<%#endsection%> +"@ + Set-Content -Path (Join-Path $SrcDir 'readme.md.tmpl') -Value $Content + InModuleScope 'Anvil' -Parameters @{ Src = $SrcDir; Dst = $DstDir } { + param($Src, $Dst) + $Params = @{ + SourcePath = $Src + DestinationPath = $Dst + Tokens = @{ ModuleName = 'MyMod'; Show = 'yes' } + Sections = @{ Header = @{ IncludeWhen = @{ Show = 'yes' } } } + } + Invoke-TemplateEngine @Params + } + $Result = Get-Content (Join-Path $DstDir 'readme.md') -Raw + $Result | Should -Match '# MyMod' + } + } + It 'is not exported' { $Exported = (Get-Module 'Anvil').ExportedFunctions.Keys $Exported | Should -Not -Contain 'Invoke-TemplateEngine' diff --git a/tests/unit/Private/Read-PromptUri.Tests.ps1 b/tests/unit/Private/Read-PromptUri.Tests.ps1 new file mode 100644 index 0000000..31c6b98 --- /dev/null +++ b/tests/unit/Private/Read-PromptUri.Tests.ps1 @@ -0,0 +1,80 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Read-PromptUri' -Tag 'Unit' { + + Context 'valid absolute URI' { + + It 'returns the URI when user enters a valid absolute URI' { + InModuleScope 'Anvil' { + Mock Read-Host { 'https://github.com/user/repo' } + $Result = Read-PromptUri -Prompt ' Project URI' + $Result | Should -Be 'https://github.com/user/repo' + } + } + } + + Context 'empty input with default' { + + It 'returns default when user enters nothing' { + InModuleScope 'Anvil' { + Mock Read-Host { '' } + $Result = Read-PromptUri -Prompt ' Project URI' -Default 'https://example.com' + $Result | Should -Be 'https://example.com' + } + } + } + + Context 'empty input without default' { + + It 'returns empty string when user enters nothing and no default' { + InModuleScope 'Anvil' { + Mock Read-Host { '' } + $Result = Read-PromptUri -Prompt ' Project URI' -Default '' + $Result | Should -BeNullOrEmpty + } + } + } + + Context 'invalid then valid URI' { + + It 'reprompts on invalid input then returns the valid URI' { + InModuleScope 'Anvil' { + $Script:CallCount = 0 + Mock Read-Host { + $Script:CallCount++ + if ($Script:CallCount -eq 1) { 'not-a-uri' } + else { 'https://github.com/user/repo' } + } + Mock Write-Host {} + + $Result = Read-PromptUri -Prompt ' Project URI' + $Result | Should -Be 'https://github.com/user/repo' + Should -Invoke Write-Host -Times 1 -ParameterFilter { + $Object -like '*Must be a valid absolute URI*' + } + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Read-PromptUri' + } +} diff --git a/tests/unit/Private/Read-TemplateManifest.Tests.ps1 b/tests/unit/Private/Read-TemplateManifest.Tests.ps1 new file mode 100644 index 0000000..6de4544 --- /dev/null +++ b/tests/unit/Private/Read-TemplateManifest.Tests.ps1 @@ -0,0 +1,114 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Read-TemplateManifest' -Tag 'Unit' { + + BeforeAll { + $script:TempRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "AnvilManifestTest_$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -Path $script:TempRoot -ItemType Directory -Force | Out-Null + } + + AfterAll { + if (Test-Path -Path $script:TempRoot) { + Remove-Item -Path $script:TempRoot -Recurse -Force + } + } + + Context 'valid manifest' { + + It 'loads and returns a valid manifest hashtable' { + InModuleScope 'Anvil' -Parameters @{ Root = $script:TempRoot } { + param($Root) + $TemplateDir = Join-Path -Path $Root -ChildPath 'ValidTemplate' + New-Item -Path $TemplateDir -ItemType Directory -Force | Out-Null + $Content = @" +@{ + Name = 'TestTemplate' + Description = 'A test template' + Version = '1.0.0' + Parameters = @( + @{ Name = 'Name'; Type = 'string'; Prompt = 'Module name' } + ) +} +"@ + Set-Content -Path (Join-Path $TemplateDir 'template.psd1') -Value $Content + + $Result = Read-TemplateManifest -TemplatePath $TemplateDir + $Result | Should -Not -BeNullOrEmpty + $Result.Name | Should -Be 'TestTemplate' + $Result.Parameters.Count | Should -Be 1 + } + } + } + + Context 'missing file' { + + It 'throws when template.psd1 does not exist' { + InModuleScope 'Anvil' -Parameters @{ Root = $script:TempRoot } { + param($Root) + $EmptyDir = Join-Path -Path $Root -ChildPath 'EmptyTemplate' + New-Item -Path $EmptyDir -ItemType Directory -Force | Out-Null + + { Read-TemplateManifest -TemplatePath $EmptyDir } | + Should -Throw '*Template manifest not found*' + } + } + } + + Context 'malformed PSD1' { + + It 'throws when the file is not valid PowerShell data' { + InModuleScope 'Anvil' -Parameters @{ Root = $script:TempRoot } { + param($Root) + $BadDir = Join-Path -Path $Root -ChildPath 'BadTemplate' + New-Item -Path $BadDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $BadDir 'template.psd1') -Value 'this is not valid psd1 {' + + { Read-TemplateManifest -TemplatePath $BadDir } | Should -Throw + } + } + } + + Context 'schema validation' { + + It 'throws when the manifest fails schema validation' { + InModuleScope 'Anvil' -Parameters @{ Root = $script:TempRoot } { + param($Root) + $InvalidDir = Join-Path -Path $Root -ChildPath 'InvalidTemplate' + New-Item -Path $InvalidDir -ItemType Directory -Force | Out-Null + $Content = @" +@{ + Name = 'Bad' + Description = 'Missing Parameters' + Version = '1.0.0' +} +"@ + Set-Content -Path (Join-Path $InvalidDir 'template.psd1') -Value $Content + + { Read-TemplateManifest -TemplatePath $InvalidDir } | + Should -Throw '*Parameters*required*' + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Read-TemplateManifest' + } +} diff --git a/tests/unit/Private/Resolve-AutoToken.Tests.ps1 b/tests/unit/Private/Resolve-AutoToken.Tests.ps1 new file mode 100644 index 0000000..f968f83 --- /dev/null +++ b/tests/unit/Private/Resolve-AutoToken.Tests.ps1 @@ -0,0 +1,76 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Resolve-AutoToken' -Tag 'Unit' { + + Context 'NewGuid' { + + It 'returns a valid GUID string' { + InModuleScope 'Anvil' { + $Result = Resolve-AutoToken -Source 'NewGuid' + { [guid]::Parse($Result) } | Should -Not -Throw + } + } + + It 'returns a different GUID on each call' { + InModuleScope 'Anvil' { + $First = Resolve-AutoToken -Source 'NewGuid' + $Second = Resolve-AutoToken -Source 'NewGuid' + $First | Should -Not -Be $Second + } + } + } + + Context 'CurrentYear' { + + It 'returns the current four-digit year' { + InModuleScope 'Anvil' { + $Result = Resolve-AutoToken -Source 'CurrentYear' + $Result | Should -Be (Get-Date).Year.ToString() + $Result | Should -Match '^\d{4}$' + } + } + } + + Context 'CurrentDate' { + + It 'returns the current date in yyyy-MM-dd format' { + InModuleScope 'Anvil' { + $Result = Resolve-AutoToken -Source 'CurrentDate' + $Result | Should -Be (Get-Date).ToString('yyyy-MM-dd') + $Result | Should -Match '^\d{4}-\d{2}-\d{2}$' + } + } + } + + Context 'unknown source' { + + It 'throws for an unrecognized source name' { + InModuleScope 'Anvil' { + { Resolve-AutoToken -Source 'Nonexistent' } | + Should -Throw "*Unknown auto-token source 'Nonexistent'*" + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Resolve-AutoToken' + } +} diff --git a/tests/unit/Private/Resolve-DefaultFrom.Tests.ps1 b/tests/unit/Private/Resolve-DefaultFrom.Tests.ps1 new file mode 100644 index 0000000..ecc5e0c --- /dev/null +++ b/tests/unit/Private/Resolve-DefaultFrom.Tests.ps1 @@ -0,0 +1,65 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Resolve-DefaultFrom' -Tag 'Unit' { + + Context 'GitUserName' { + + It 'returns git user name when available' { + InModuleScope 'Anvil' { + Mock Resolve-AuthorName { 'GitUser' } + $Result = Resolve-DefaultFrom -ResolverName 'GitUserName' + $Result | Should -Be 'GitUser' + } + } + + It 'returns null when git user name is not configured' { + InModuleScope 'Anvil' { + Mock Resolve-AuthorName { $null } + $Result = Resolve-DefaultFrom -ResolverName 'GitUserName' + $Result | Should -BeNullOrEmpty + } + } + } + + Context 'CurrentDirectory' { + + It 'returns the current working directory' { + InModuleScope 'Anvil' { + $Result = Resolve-DefaultFrom -ResolverName 'CurrentDirectory' + $Result | Should -Be $PWD.Path + } + } + } + + Context 'unknown resolver' { + + It 'returns null for an unrecognized resolver name' { + InModuleScope 'Anvil' { + $Result = Resolve-DefaultFrom -ResolverName 'Nonexistent' + $Result | Should -BeNullOrEmpty + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Resolve-DefaultFrom' + } +} diff --git a/tests/unit/Private/Resolve-PathTokens.Tests.ps1 b/tests/unit/Private/Resolve-PathTokens.Tests.ps1 index 63c550b..d4c76f2 100644 --- a/tests/unit/Private/Resolve-PathTokens.Tests.ps1 +++ b/tests/unit/Private/Resolve-PathTokens.Tests.ps1 @@ -19,18 +19,18 @@ AfterAll { Describe 'Resolve-PathTokens' -Tag 'Unit' { - It 'replaces __ModuleName__ in a path segment' { + It 'replaces path tokens in a path segment' { InModuleScope 'Anvil' { - $Result = Resolve-PathTokens -RelativePath 'src/__ModuleName__/Public' -Tokens @{ ModuleName = 'Foo' } + $Result = Resolve-PathTokens -RelativePath 'src/__Name__/Public' -Tokens @{ Name = 'Foo' } $Result | Should -Be 'src/Foo/Public' } } It 'replaces multiple different tokens' { InModuleScope 'Anvil' { - $Result = Resolve-PathTokens -RelativePath '__Author__/__ModuleName__' -Tokens @{ - Author = 'Jane' - ModuleName = 'Bar' + $Result = Resolve-PathTokens -RelativePath '__Author__/__Name__' -Tokens @{ + Author = 'Jane' + Name = 'Bar' } $Result | Should -Be 'Jane/Bar' } @@ -38,7 +38,7 @@ Describe 'Resolve-PathTokens' -Tag 'Unit' { It 'leaves paths without tokens unchanged' { InModuleScope 'Anvil' { - $Result = Resolve-PathTokens -RelativePath 'build/bootstrap.ps1' -Tokens @{ ModuleName = 'X' } + $Result = Resolve-PathTokens -RelativePath 'build/bootstrap.ps1' -Tokens @{ Name = 'X' } $Result | Should -Be 'build/bootstrap.ps1' } } diff --git a/tests/unit/Private/Resolve-TemplateSections.Tests.ps1 b/tests/unit/Private/Resolve-TemplateSections.Tests.ps1 new file mode 100644 index 0000000..de520f3 --- /dev/null +++ b/tests/unit/Private/Resolve-TemplateSections.Tests.ps1 @@ -0,0 +1,248 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Resolve-TemplateSections' -Tag 'Unit' { + + Context 'section kept when condition matches' { + + It 'keeps content and strips markers for IncludeWhen match' { + InModuleScope 'Anvil' { + $Content = @" +before +<%#section DocsTask%> +task Docs { + Write-Host 'docs' +} +<%#endsection%> +after +"@ + $Sections = @{ + DocsTask = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + } + $Tokens = @{ IncludeDocs = 'true' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Match 'before' + $Result | Should -Match 'task Docs' + $Result | Should -Match 'after' + $Result | Should -Not -Match '<%#section' + $Result | Should -Not -Match '<%#endsection' + } + } + + It 'keeps content for ExcludeWhen non-match' { + InModuleScope 'Anvil' { + $Content = @" +top +<%#section LicenseBadge%> +![License](badge.svg) +<%#endsection%> +bottom +"@ + $Sections = @{ + LicenseBadge = @{ ExcludeWhen = @{ License = 'None' } } + } + $Tokens = @{ License = 'MIT' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Match 'License.*badge' + $Result | Should -Not -Match '<%#section' + } + } + } + + Context 'section stripped when condition does not match' { + + It 'strips content for IncludeWhen non-match' { + InModuleScope 'Anvil' { + $Content = @" +before +<%#section DocsTask%> +task Docs { + Write-Host 'docs' +} +<%#endsection%> +after +"@ + $Sections = @{ + DocsTask = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + } + $Tokens = @{ IncludeDocs = 'false' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Match 'before' + $Result | Should -Not -Match 'task Docs' + $Result | Should -Match 'after' + } + } + + It 'strips content for ExcludeWhen match' { + InModuleScope 'Anvil' { + $Content = @" +top +<%#section LicenseBadge%> +![License](badge.svg) +<%#endsection%> +bottom +"@ + $Sections = @{ + LicenseBadge = @{ ExcludeWhen = @{ License = 'None' } } + } + $Tokens = @{ License = 'None' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Not -Match 'License.*badge' + $Result | Should -Match 'top' + $Result | Should -Match 'bottom' + } + } + } + + Context 'multiple sections in one file' { + + It 'processes each section independently' { + InModuleScope 'Anvil' { + $Content = @" +header +<%#section Alpha%> +alpha content +<%#endsection%> +middle +<%#section Beta%> +beta content +<%#endsection%> +footer +"@ + $Sections = @{ + Alpha = @{ IncludeWhen = @{ A = 'yes' } } + Beta = @{ IncludeWhen = @{ B = 'yes' } } + } + $Tokens = @{ A = 'yes'; B = 'no' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Match 'alpha content' + $Result | Should -Not -Match 'beta content' + $Result | Should -Match 'header' + $Result | Should -Match 'middle' + $Result | Should -Match 'footer' + } + } + } + + Context 'no sections in content' { + + It 'returns content unchanged when there are no markers' { + InModuleScope 'Anvil' { + $Content = "just plain content`nwith multiple lines" + $Sections = @{ + DocsTask = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + } + $Tokens = @{ IncludeDocs = 'true' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Be $Content + } + } + } + + Context 'preserves surrounding content' { + + It 'does not eat extra whitespace around sections' { + InModuleScope 'Anvil' { + $Content = @" +line1 +line2 +<%#section Keep%> +kept +<%#endsection%> +line3 +line4 +"@ + $Sections = @{ + Keep = @{ IncludeWhen = @{ X = 'y' } } + } + $Tokens = @{ X = 'y' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Match 'line1' + $Result | Should -Match 'line2' + $Result | Should -Match 'kept' + $Result | Should -Match 'line3' + $Result | Should -Match 'line4' + } + } + } + + Context 'undeclared section' { + + It 'throws when a section marker has no manifest entry' { + InModuleScope 'Anvil' { + $Content = @" +<%#section Unknown%> +content +<%#endsection%> +"@ + $Sections = @{} + $Tokens = @{} + + { Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens } | + Should -Throw "*Section 'Unknown' found in template but not declared*" + } + } + } + + Context 'indented markers' { + + It 'handles markers with leading whitespace' { + InModuleScope 'Anvil' { + $Content = @" +task . Clean, Build + <%#section DocsTask%> + task Docs { + Write-Host 'docs' + } + <%#endsection%> +task Release Version +"@ + $Sections = @{ + DocsTask = @{ IncludeWhen = @{ IncludeDocs = 'true' } } + } + $Tokens = @{ IncludeDocs = 'false' } + + $Result = Resolve-TemplateSections -Content $Content -Sections $Sections -Tokens $Tokens + + $Result | Should -Not -Match 'task Docs' + $Result | Should -Match 'task \. Clean' + $Result | Should -Match 'task Release' + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Resolve-TemplateSections' + } +} diff --git a/tests/unit/Private/Test-FileCondition.Tests.ps1 b/tests/unit/Private/Test-FileCondition.Tests.ps1 new file mode 100644 index 0000000..3d98276 --- /dev/null +++ b/tests/unit/Private/Test-FileCondition.Tests.ps1 @@ -0,0 +1,135 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Test-FileCondition' -Tag 'Unit' { + + Context 'IncludeWhen' { + + It 'includes a file when the condition matches' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'docs/README.md' + IncludeWhen = @{ 'docs/*' = @{ IncludeDocs = 'true' } } + Tokens = @{ IncludeDocs = 'true' } + } + Test-FileCondition @Params | Should -BeTrue + } + } + + It 'excludes a file when the condition does not match' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'docs/README.md' + IncludeWhen = @{ 'docs/*' = @{ IncludeDocs = 'true' } } + Tokens = @{ IncludeDocs = 'false' } + } + Test-FileCondition @Params | Should -BeFalse + } + } + } + + Context 'ExcludeWhen' { + + It 'excludes a file when the condition matches' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'LICENSE.tmpl' + ExcludeWhen = @{ 'LICENSE.tmpl' = @{ License = 'None' } } + Tokens = @{ License = 'None' } + } + Test-FileCondition @Params | Should -BeFalse + } + } + + It 'includes a file when the condition does not match' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'LICENSE.tmpl' + ExcludeWhen = @{ 'LICENSE.tmpl' = @{ License = 'None' } } + Tokens = @{ License = 'MIT' } + } + Test-FileCondition @Params | Should -BeTrue + } + } + } + + Context 'no matching pattern' { + + It 'includes a file not mentioned in any condition table' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'src/Module.psm1' + IncludeWhen = @{ 'docs/*' = @{ IncludeDocs = 'true' } } + ExcludeWhen = @{ 'LICENSE.tmpl' = @{ License = 'None' } } + Tokens = @{ IncludeDocs = 'false'; License = 'None' } + } + Test-FileCondition @Params | Should -BeTrue + } + } + } + + Context 'wildcard path matching' { + + It 'matches nested paths with wildcard pattern' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'docs/commands/Get-Thing.md' + IncludeWhen = @{ 'docs/*' = @{ IncludeDocs = 'true' } } + Tokens = @{ IncludeDocs = 'true' } + } + Test-FileCondition @Params | Should -BeTrue + } + } + } + + Context 'ExcludeWhen takes precedence' { + + It 'excludes when both tables match the same path' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'docs/README.md' + IncludeWhen = @{ 'docs/*' = @{ IncludeDocs = 'true' } } + ExcludeWhen = @{ 'docs/*' = @{ License = 'None' } } + Tokens = @{ IncludeDocs = 'true'; License = 'None' } + } + Test-FileCondition @Params | Should -BeFalse + } + } + } + + Context 'empty tables' { + + It 'includes everything when both tables are empty' { + InModuleScope 'Anvil' { + $Params = @{ + RelativePath = 'anything.ps1' + IncludeWhen = @{} + ExcludeWhen = @{} + Tokens = @{ License = 'MIT' } + } + Test-FileCondition @Params | Should -BeTrue + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Test-FileCondition' + } +} diff --git a/tests/unit/Private/Test-ManifestCondition.Tests.ps1 b/tests/unit/Private/Test-ManifestCondition.Tests.ps1 new file mode 100644 index 0000000..c35c5e3 --- /dev/null +++ b/tests/unit/Private/Test-ManifestCondition.Tests.ps1 @@ -0,0 +1,118 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Test-ManifestCondition' -Tag 'Unit' { + + Context 'single key exact match' { + + It 'returns true when token matches the condition value' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{ License = 'MIT' } -Tokens @{ License = 'MIT' } + $Result | Should -BeTrue + } + } + + It 'returns false when token does not match' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{ License = 'MIT' } -Tokens @{ License = 'Apache2' } + $Result | Should -BeFalse + } + } + } + + Context 'set membership (array of allowed values)' { + + It 'returns true when token matches one of the allowed values' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{ License = 'MIT', 'Apache2' } -Tokens @{ License = 'Apache2' } + $Result | Should -BeTrue + } + } + + It 'returns false when token matches none of the allowed values' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{ License = 'MIT', 'Apache2' } -Tokens @{ License = 'None' } + $Result | Should -BeFalse + } + } + } + + Context 'multi-key AND logic' { + + It 'returns true when all keys match' { + InModuleScope 'Anvil' { + $Condition = @{ IncludeDocs = 'true'; CIProvider = 'GitHub' } + $Tokens = @{ IncludeDocs = 'true'; CIProvider = 'GitHub'; License = 'MIT' } + $Result = Test-ManifestCondition -Condition $Condition -Tokens $Tokens + $Result | Should -BeTrue + } + } + + It 'returns false when one key does not match' { + InModuleScope 'Anvil' { + $Condition = @{ IncludeDocs = 'true'; CIProvider = 'GitHub' } + $Tokens = @{ IncludeDocs = 'true'; CIProvider = 'GitLab' } + $Result = Test-ManifestCondition -Condition $Condition -Tokens $Tokens + $Result | Should -BeFalse + } + } + } + + Context 'missing token key' { + + It 'returns false when the token key does not exist' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{ License = 'MIT' } -Tokens @{ Author = 'Jane' } + $Result | Should -BeFalse + } + } + } + + Context 'empty condition' { + + It 'returns true when condition is empty' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{} -Tokens @{ License = 'MIT' } + $Result | Should -BeTrue + } + } + } + + Context 'empty string matching' { + + It 'returns true when condition expects empty string and token is empty' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{ ProjectUri = '' } -Tokens @{ ProjectUri = '' } + $Result | Should -BeTrue + } + } + + It 'returns false when condition expects empty string and token has a value' { + InModuleScope 'Anvil' { + $Result = Test-ManifestCondition -Condition @{ ProjectUri = '' } -Tokens @{ ProjectUri = 'https://example.com' } + $Result | Should -BeFalse + } + } + } + + It 'is not exported' { + $Exported = (Get-Module -Name 'Anvil').ExportedFunctions.Keys + $Exported | Should -Not -Contain 'Test-ManifestCondition' + } +} diff --git a/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 b/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 index c0857c4..e34ebb8 100644 --- a/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 +++ b/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 @@ -24,36 +24,76 @@ Describe 'Get-AnvilTemplate' -Tag 'Unit' { $Results | Should -Not -BeNullOrEmpty } - It 'includes base templates' { + It 'includes the Module template' { $Results = Get-AnvilTemplate - $Base = $Results | Where-Object { $_.Type -eq 'BaseTemplate' } - $Base | Should -Not -BeNullOrEmpty + $Module = $Results | Where-Object { $_.Name -eq 'Module' } + $Module | Should -Not -BeNullOrEmpty } - It 'includes CI providers' { + It 'returns objects with expected properties' { $Results = Get-AnvilTemplate - $CI = $Results | Where-Object { $_.Type -eq 'CIProvider' } - $CI | Should -Not -BeNullOrEmpty + $First = $Results | Select-Object -First 1 + $First.PSObject.Properties.Name | Should -Contain 'Name' + $First.PSObject.Properties.Name | Should -Contain 'Type' + $First.PSObject.Properties.Name | Should -Contain 'Description' + $First.PSObject.Properties.Name | Should -Contain 'Version' + $First.PSObject.Properties.Name | Should -Contain 'Parameters' + $First.PSObject.Properties.Name | Should -Contain 'FileCount' + $First.PSObject.Properties.Name | Should -Contain 'Layers' + $First.PSObject.Properties.Name | Should -Contain 'Path' } - It 'includes the Module base template' { + It 'reads manifest metadata for the Module template' { $Results = Get-AnvilTemplate - $Module = $Results | Where-Object { $_.Name -eq 'Module' -and $_.Type -eq 'BaseTemplate' } - $Module | Should -Not -BeNullOrEmpty + $Module = $Results | Where-Object { $_.Name -eq 'Module' } + $Module.Description | Should -Not -BeNullOrEmpty + $Module.Version | Should -Not -BeNullOrEmpty + $Module.Parameters | Should -Not -BeNullOrEmpty + $Module.Parameters | Should -Contain 'Name' + $Module.Parameters | Should -Contain 'Author' } - It 'includes GitHub as a CI provider' { + It 'excludes template.psd1 from file count' { $Results = Get-AnvilTemplate - $GitHub = $Results | Where-Object { $_.Name -eq 'GitHub' -and $_.Type -eq 'CIProvider' } - $GitHub | Should -Not -BeNullOrEmpty + $Module = $Results | Where-Object { $_.Name -eq 'Module' } + $ManifestFile = Join-Path $Module.Path 'template.psd1' + $ManifestFile | Should -Exist + $AllFiles = (Get-ChildItem -Path $Module.Path -File -Recurse).Count + $Module.FileCount | Should -BeLessThan $AllFiles } - It 'returns objects with expected properties' { + Context 'Layers' { + + It 'discovers CI providers as layers on the Module template' { + $Results = Get-AnvilTemplate + $Module = $Results | Where-Object { $_.Name -eq 'Module' } + $Module.Layers | Should -Not -BeNullOrEmpty + $Module.Layers.Name | Should -Contain 'GitHub' + $Module.Layers.Name | Should -Contain 'AzurePipelines' + $Module.Layers.Name | Should -Contain 'GitLab' + } + + It 'layer objects have expected properties' { + $Results = Get-AnvilTemplate + $Module = $Results | Where-Object { $_.Name -eq 'Module' } + $GitHub = $Module.Layers | Where-Object { $_.Name -eq 'GitHub' } + $GitHub.PathKey | Should -Be 'CIProvider' + $GitHub.FileCount | Should -BeGreaterThan 0 + $GitHub.Path | Should -Not -BeNullOrEmpty + } + + It 'does not include the Skip value as a layer' { + $Results = Get-AnvilTemplate + $Module = $Results | Where-Object { $_.Name -eq 'Module' } + $Module.Layers.Name | Should -Not -Contain 'None' + } + } + + It 'only discovers templates with a manifest' { $Results = Get-AnvilTemplate - $First = $Results | Select-Object -First 1 - $First.PSObject.Properties.Name | Should -Contain 'Name' - $First.PSObject.Properties.Name | Should -Contain 'Type' - $First.PSObject.Properties.Name | Should -Contain 'FileCount' - $First.PSObject.Properties.Name | Should -Contain 'Path' + $Results | ForEach-Object { + $ManifestFile = Join-Path $_.Path 'template.psd1' + $ManifestFile | Should -Exist + } } } diff --git a/tests/unit/Public/New-AnvilModule.Tests.ps1 b/tests/unit/Public/New-AnvilModule.Tests.ps1 index 2112969..f718674 100644 --- a/tests/unit/Public/New-AnvilModule.Tests.ps1 +++ b/tests/unit/Public/New-AnvilModule.Tests.ps1 @@ -155,4 +155,54 @@ Describe 'New-AnvilModule' -Tag 'Unit' { { New-AnvilModule @Params } | Should -Throw '*already exists*' } } + + Context 'Custom template via -Template' { + + BeforeAll { + $script:CustomTemplatePath = Join-Path $TestDrive 'CustomTemplate' + New-Item -Path $script:CustomTemplatePath -ItemType Directory -Force | Out-Null + $ManifestContent = @" +@{ + Name = 'Custom' + Description = 'A custom test template' + Version = '1.0.0' + Parameters = @( + @{ Name = 'Name'; Type = 'string'; Required = `$true; Prompt = 'Project name' } + @{ Name = 'Greeting'; Type = 'string'; Prompt = 'Greeting'; Default = 'Hello' } + ) +} +"@ + Set-Content -Path (Join-Path $script:CustomTemplatePath 'template.psd1') -Value $ManifestContent + + $TokenName = '<%Name%>' + $TokenGreeting = '<%Greeting%>' + Set-Content -Path (Join-Path $script:CustomTemplatePath 'output.txt.tmpl') -Value "$TokenGreeting from $TokenName" + } + + It 'scaffolds from a custom template path' { + $Params = @{ + Name = 'CustomProject' + DestinationPath = $TestDrive + Template = $script:CustomTemplatePath + Force = $true + } + New-AnvilModule @Params + $OutputFile = Join-Path $TestDrive 'CustomProject/output.txt' + $OutputFile | Should -Exist + $Content = Get-Content $OutputFile -Raw + $Content | Should -Match 'Hello from CustomProject' + } + + It 'returns path with -PassThru for custom template' { + $Params = @{ + Name = 'CustomPassThru' + DestinationPath = $TestDrive + Template = $script:CustomTemplatePath + PassThru = $true + Force = $true + } + $Result = New-AnvilModule @Params + $Result | Should -Be (Join-Path $TestDrive 'CustomPassThru') + } + } } diff --git a/tests/unit/TemplateManifestParity.Tests.ps1 b/tests/unit/TemplateManifestParity.Tests.ps1 new file mode 100644 index 0000000..505847a --- /dev/null +++ b/tests/unit/TemplateManifestParity.Tests.ps1 @@ -0,0 +1,92 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $ProjectRoot = $PSScriptRoot + while ($ProjectRoot -and -not (Test-Path (Join-Path $ProjectRoot 'build/build.settings.psd1'))) { + $ProjectRoot = Split-Path $ProjectRoot -Parent + } + $ModuleName = 'Anvil' + $ModuleDir = Join-Path -Path $ProjectRoot -ChildPath 'src' | Join-Path -ChildPath $ModuleName + $ManifestPath = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1" + + Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ManifestPath -Force -ErrorAction Stop + + $script:TemplateRoot = Join-Path -Path $ModuleDir -ChildPath 'Templates' +} + +AfterAll { + Get-Module -Name 'Anvil' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +Describe 'Template Manifest Parity' -Tag 'Unit' { + + BeforeAll { + $script:ModuleTemplatePath = Join-Path -Path $script:TemplateRoot -ChildPath 'Module' + $script:CITemplatePath = Join-Path -Path $script:TemplateRoot -ChildPath 'CI' + + $script:Manifest = InModuleScope 'Anvil' -Parameters @{ P = $script:ModuleTemplatePath } { + param($P) + Read-TemplateManifest -TemplatePath $P + } + + $script:DeclaredParams = $script:Manifest.Parameters | ForEach-Object { $_.Name } + $script:DeclaredAuto = @() + if ($script:Manifest.ContainsKey('AutoTokens')) { + $script:DeclaredAuto = $script:Manifest.AutoTokens | ForEach-Object { $_.Name } + } + $script:DeclaredTokens = @($script:DeclaredParams) + @($script:DeclaredAuto) + + # Collect all content tokens from Module and CI templates + $script:ContentTokens = [System.Collections.Generic.HashSet[string]]::new() + $AllTmplFiles = Get-ChildItem -Path $script:ModuleTemplatePath -Recurse -File -Filter '*.tmpl' + $AllTmplFiles += Get-ChildItem -Path $script:CITemplatePath -Recurse -File -Filter '*.tmpl' + foreach ($File in $AllTmplFiles) { + $Content = Get-Content -Path $File.FullName -Raw + $Matches = [regex]::Matches($Content, '<%(\w+)%>') + foreach ($M in $Matches) { + [void]$script:ContentTokens.Add($M.Groups[1].Value) + } + } + + # Collect all path tokens from Module templates + $script:PathTokens = [System.Collections.Generic.HashSet[string]]::new() + $AllDirs = Get-ChildItem -Path $script:ModuleTemplatePath -Recurse -Directory + foreach ($Dir in $AllDirs) { + $DirMatches = [regex]::Matches($Dir.Name, '__(\w+)__') + foreach ($M in $DirMatches) { + [void]$script:PathTokens.Add($M.Groups[1].Value) + } + } + $AllFiles = Get-ChildItem -Path $script:ModuleTemplatePath -Recurse -File + foreach ($File in $AllFiles) { + $FileMatches = [regex]::Matches($File.Name, '__(\w+)__') + foreach ($M in $FileMatches) { + [void]$script:PathTokens.Add($M.Groups[1].Value) + } + } + + $script:UsedTokens = [System.Collections.Generic.HashSet[string]]::new() + foreach ($T in $script:ContentTokens) { [void]$script:UsedTokens.Add($T) } + foreach ($T in $script:PathTokens) { [void]$script:UsedTokens.Add($T) } + } + + It 'manifest loads successfully' { + $script:Manifest | Should -Not -BeNullOrEmpty + } + + It 'every content token in template files is declared in the manifest' { + $Undeclared = $script:ContentTokens | Where-Object { $_ -notin $script:DeclaredTokens } + $Undeclared | Should -BeNullOrEmpty -Because "tokens used in templates must be declared in template.psd1: $($Undeclared -join ', ')" + } + + It 'every path token in template files is declared in the manifest' { + $Undeclared = $script:PathTokens | Where-Object { $_ -notin $script:DeclaredTokens } + $Undeclared | Should -BeNullOrEmpty -Because "path tokens used in templates must be declared in template.psd1: $($Undeclared -join ', ')" + } + + It 'every auto-token is used in at least one template file' { + $Unused = $script:DeclaredAuto | Where-Object { $_ -notin $script:UsedTokens } + $Unused | Should -BeNullOrEmpty -Because "declared auto-tokens should appear in template files: $($Unused -join ', ')" + } +} From 401ca4712ef9402fb8afe841dfc8c09fa2c0ad42 Mon Sep 17 00:00:00 2001 From: Stephen Mills Date: Sat, 18 Apr 2026 20:47:38 +1000 Subject: [PATCH 2/5] chore: default GitInit and IncludeDocs to enabled --- src/Anvil/Public/New-AnvilModule.ps1 | 4 ++-- src/Anvil/Templates/Module/template.psd1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Anvil/Public/New-AnvilModule.ps1 b/src/Anvil/Public/New-AnvilModule.ps1 index 4d9962a..87ad644 100644 --- a/src/Anvil/Public/New-AnvilModule.ps1 +++ b/src/Anvil/Public/New-AnvilModule.ps1 @@ -251,10 +251,10 @@ function New-AnvilModule { $ResolvedGitInit = if ($PSBoundParameters.ContainsKey('GitInit')) { [bool]$GitInit } elseif ($Interactive) { - $GitInput = Read-PromptValue -Prompt ' Initialize git repository? (y/n)' -Default 'n' + $GitInput = Read-PromptValue -Prompt ' Initialize git repository? (y/n)' -Default 'y' $GitInput -match '^[Yy]' } else { - $false + $true } Assert-ManifestConfiguration -Manifest $Manifest -Configuration $Resolved diff --git a/src/Anvil/Templates/Module/template.psd1 b/src/Anvil/Templates/Module/template.psd1 index 47c1c79..bd06c95 100644 --- a/src/Anvil/Templates/Module/template.psd1 +++ b/src/Anvil/Templates/Module/template.psd1 @@ -71,7 +71,7 @@ Name = 'IncludeDocs' Type = 'bool' Prompt = 'Include platyPS docs generation?' - Default = $false + Default = $true Format = 'lower-string' } @{ From b381ddcf7fa61c7899d4613116a6bb087b198f7c Mon Sep 17 00:00:00 2001 From: Stephen Mills Date: Sat, 18 Apr 2026 20:59:57 +1000 Subject: [PATCH 3/5] fix: use -Force in Get-AnvilTemplate to find dotfiles on Linux --- src/Anvil/Public/Get-AnvilTemplate.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Anvil/Public/Get-AnvilTemplate.ps1 b/src/Anvil/Public/Get-AnvilTemplate.ps1 index 3209bf3..0099649 100644 --- a/src/Anvil/Public/Get-AnvilTemplate.ps1 +++ b/src/Anvil/Public/Get-AnvilTemplate.ps1 @@ -46,7 +46,7 @@ function Get-AnvilTemplate { $Manifest = Import-PowerShellDataFile -Path (Join-Path $TemplatePath 'template.psd1') -ErrorAction SilentlyContinue if (-not $Manifest) { return } - $FileCount = (Get-ChildItem -Path $TemplatePath -File -Recurse -ErrorAction SilentlyContinue | + $FileCount = (Get-ChildItem -Path $TemplatePath -File -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'template.psd1' }).Count $Layers = @() @@ -58,7 +58,7 @@ function Get-AnvilTemplate { foreach ($Dir in $LayerDirs) { $Skip = if ($Layer.ContainsKey('Skip')) { $Layer.Skip } else { $null } if ($Dir.Name -eq $Skip) { continue } - $LayerFileCount = (Get-ChildItem -Path $Dir.FullName -File -Recurse -ErrorAction SilentlyContinue).Count + $LayerFileCount = (Get-ChildItem -Path $Dir.FullName -File -Recurse -Force -ErrorAction SilentlyContinue).Count $Layers += [PSCustomObject]@{ Name = $Dir.Name PathKey = $Layer.PathKey From 087b3f5c02551a5de7826d83824fed2ddd9ca6cf Mon Sep 17 00:00:00 2001 From: Stephen Mills Date: Sat, 18 Apr 2026 21:02:32 +1000 Subject: [PATCH 4/5] fix: pass IncludeDocs explicitly in no-docs integration test --- tests/integration/GoldenTemplate.Tests.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/GoldenTemplate.Tests.ps1 b/tests/integration/GoldenTemplate.Tests.ps1 index 7d26703..dac66ed 100644 --- a/tests/integration/GoldenTemplate.Tests.ps1 +++ b/tests/integration/GoldenTemplate.Tests.ps1 @@ -328,6 +328,7 @@ Describe 'New-AnvilModule manifest conditions' -Tag 'Integration' { Name = 'NoDocs' DestinationPath = $script:CondRoot Author = 'Test' + IncludeDocs = $false PassThru = $true Confirm = $false } From db75a26119f546ac98dfb501e471a963913a99c1 Mon Sep 17 00:00:00 2001 From: Stephen Mills Date: Sat, 18 Apr 2026 21:05:44 +1000 Subject: [PATCH 5/5] fix: use -Force in Get-AnvilTemplate test to match function behavior --- tests/unit/Public/Get-AnvilTemplate.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 b/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 index e34ebb8..a2a4f4c 100644 --- a/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 +++ b/tests/unit/Public/Get-AnvilTemplate.Tests.ps1 @@ -58,7 +58,7 @@ Describe 'Get-AnvilTemplate' -Tag 'Unit' { $Module = $Results | Where-Object { $_.Name -eq 'Module' } $ManifestFile = Join-Path $Module.Path 'template.psd1' $ManifestFile | Should -Exist - $AllFiles = (Get-ChildItem -Path $Module.Path -File -Recurse).Count + $AllFiles = (Get-ChildItem -Path $Module.Path -File -Recurse -Force).Count $Module.FileCount | Should -BeLessThan $AllFiles }