diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8cc0a6a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(ls -1 /c/scripts/Plaster-1/Plaster/Private/*.ps1)", + "Bash(pwsh:*)", + "WebFetch(domain:pester.dev)", + "Bash(xargs head:*)", + "Bash(hugo server:*)", + "Bash(hugo --printPathWarnings)" + ] + } +} diff --git a/Plaster/Plaster.psd1 b/Plaster/Plaster.psd1 index 7a8792e..5d22abe 100644 --- a/Plaster/Plaster.psd1 +++ b/Plaster/Plaster.psd1 @@ -41,7 +41,7 @@ CompanyName = 'PowerShell.org' # Copyright statement for this module - Copyright = '(c) PowerShell.org 2016-2025. All rights reserved.' + Copyright = '(c) PowerShell.org 2016-2026. All rights reserved.' # Description of the functionality provided by this module Description = 'Plaster is a template-based file and project generator written in PowerShell. Create consistent PowerShell projects with customizable templates supporting both XML and JSON formats.' @@ -73,10 +73,15 @@ # Functions to export from this module - explicitly list each function that should be # exported. This improves performance of PowerShell when discovering the commands in # module. - FunctionsToExport = '*' + FunctionsToExport = @( + 'Get-PlasterTemplate', + 'Invoke-Plaster', + 'New-PlasterManifest', + 'Test-PlasterManifest' + ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. - CmdletsToExport = '*' + CmdletsToExport = @() # Variables to export from this module VariablesToExport = @() diff --git a/Plaster/Plaster.psm1 b/Plaster/Plaster.psm1 index eed1807..9b23605 100644 --- a/Plaster/Plaster.psm1 +++ b/Plaster/Plaster.psm1 @@ -37,7 +37,7 @@ data LocalizedData { ManifestNotValid_F1=The Plaster manifest '{0}' is not valid. ManifestNotValidVerbose_F1=The Plaster manifest '{0}' is not valid. Specify -Verbose to see the specific schema errors. ManifestNotWellFormedXml_F2=The Plaster manifest '{0}' is not a well-formed XML file. {1} - ManifestWrongFilename_F1=The Plaster manifest filename '{0}' is not valid. The value of the Path argument must refer to a file named 'plasterManifest.xml' or 'plasterManifest_.xml'. Change the Plaster manifest filename and then try again. + ManifestWrongFilename_F1=The Plaster manifest filename '{0}' is not valid. The value of the Path argument must refer to a file named 'plasterManifest.xml', 'plasterManifest.json', or a culture-specific variant (e.g., 'plasterManifest_en-US.xml'). Change the Plaster manifest filename and then try again. MissingParameterPrompt_F1= NewModManifest_CreatingDir_F1=Creating destination directory for module manifest: {0} OpConflict=Conflict @@ -139,6 +139,22 @@ if (-not $script:XmlSchemaValidationSupported) { # Module logging configuration $script:LogLevel = if ($env:PLASTER_LOG_LEVEL) { $env:PLASTER_LOG_LEVEL } else { 'Information' } +# Dot-source functions when running from source (not compiled build) +# The build system (PowerShellBuild) compiles all functions into this PSM1. +# When running from source, we need to dot-source them from Private/ and Public/. +$privatePath = Join-Path $PSScriptRoot 'Private' +$publicPath = Join-Path $PSScriptRoot 'Public' +if (Test-Path $privatePath) { + foreach ($file in (Get-ChildItem -Path $privatePath -Filter '*.ps1' -Recurse)) { + . $file.FullName + } +} +if (Test-Path $publicPath) { + foreach ($file in (Get-ChildItem -Path $publicPath -Filter '*.ps1' -Recurse)) { + . $file.FullName + } +} + # Global variables and constants for Plaster 2.0 # Enhanced $TargetNamespace definition with proper scoping diff --git a/Plaster/Private/Get-PlasterManifestPathForCulture.ps1 b/Plaster/Private/Get-PlasterManifestPathForCulture.ps1 index 693e214..b650c89 100644 --- a/Plaster/Private/Get-PlasterManifestPathForCulture.ps1 +++ b/Plaster/Private/Get-PlasterManifestPathForCulture.ps1 @@ -63,7 +63,7 @@ function Get-PlasterManifestPathForCulture { return $plasterManifestPath } - # If no manifest is found, return $null. - # TODO: Should we throw an error instead? + # If no manifest is found, return $null. Callers (Invoke-Plaster, etc.) + # handle the missing manifest case and may fall back to JSON format. return $null } diff --git a/Plaster/Private/New-TemplateObjectFromManifest.ps1 b/Plaster/Private/New-TemplateObjectFromManifest.ps1 index 5bed6dd..8cb83d8 100644 --- a/Plaster/Private/New-TemplateObjectFromManifest.ps1 +++ b/Plaster/Private/New-TemplateObjectFromManifest.ps1 @@ -35,7 +35,17 @@ function New-TemplateObjectFromManifest { ) try{ - $manifestXml = Test-PlasterManifest -Path $ManifestPath + $manifestResult = Test-PlasterManifest -Path $ManifestPath + # Test-PlasterManifest may return an array if extra output leaks through; + # extract the XmlDocument from the result. + $manifestXml = if ($manifestResult -is [System.Xml.XmlDocument]) { + $manifestResult + } else { + $manifestResult | Where-Object { $_ -is [System.Xml.XmlDocument] } | Select-Object -First 1 + } + if ($null -eq $manifestXml) { + throw "Failed to load manifest from '$ManifestPath'" + } $metadata = $manifestXml["plasterManifest"]["metadata"] $manifestObj = [PSCustomObject]@{ diff --git a/Plaster/Private/Test-JsonManifest.ps1 b/Plaster/Private/Test-JsonManifest.ps1 index d6e1dd9..a920eee 100644 --- a/Plaster/Private/Test-JsonManifest.ps1 +++ b/Plaster/Private/Test-JsonManifest.ps1 @@ -65,14 +65,11 @@ function Test-JsonManifest { throw "Invalid template name: $($metadata.name). Must start with letter and contain only letters, numbers, underscore, or hyphen" } - # Parameters validation # Parameters validation if ($jsonObject.PSObject.Properties['parameters'] -and $jsonObject.parameters -and $jsonObject.parameters.Count -gt 0) { Test-JsonManifestParameters -Parameters $jsonObject.parameters } - # Content validation - # Content validation # Content validation if ($jsonObject.content -and $jsonObject.content.Count -gt 0) { Test-JsonManifestContent -Content $jsonObject.content diff --git a/Plaster/Private/Write-PlasterLog.ps1 b/Plaster/Private/Write-PlasterLog.ps1 index d4ea57b..fe12049 100644 --- a/Plaster/Private/Write-PlasterLog.ps1 +++ b/Plaster/Private/Write-PlasterLog.ps1 @@ -78,14 +78,4 @@ function Write-PlasterLog { Write-Debug $logMessage } } - - # Also write to host for immediate feedback during interactive sessions - if ($Level -in @('Error', 'Warning') -and $Host.Name -ne 'ServerRemoteHost') { - $color = switch ($Level) { - 'Error' { 'Red' } - 'Warning' { 'Yellow' } - default { 'White' } - } - Write-Host $logMessage -ForegroundColor $color - } } diff --git a/Plaster/Public/Get-PlasterTemplate.ps1 b/Plaster/Public/Get-PlasterTemplate.ps1 index 00d4466..3691e9c 100644 --- a/Plaster/Public/Get-PlasterTemplate.ps1 +++ b/Plaster/Public/Get-PlasterTemplate.ps1 @@ -80,9 +80,16 @@ function Get-PlasterTemplate { Get-ManifestsUnderPath @getManifestsUnderPathSplat } } else { - # Return all templates included with Plaster + # Return all templates included with Plaster. + # When running from source this function is dot-sourced from Public/, so + # $PSScriptRoot points at Public/ and Templates/ lives one level up. The + # compiled build flattens everything to the module root, where the first path is correct. + $templatesRoot = Join-Path $PSScriptRoot 'Templates' + if (-not (Test-Path -LiteralPath $templatesRoot)) { + $templatesRoot = Join-Path (Split-Path $PSScriptRoot -Parent) 'Templates' + } $getManifestsUnderPathSplat = @{ - RootPath = "$PSScriptRoot\Templates" + RootPath = $templatesRoot Recurse = $true Name = $Name Tag = $Tag diff --git a/Plaster/Public/Test-PlasterManifest.ps1 b/Plaster/Public/Test-PlasterManifest.ps1 index 4b64452..92e8f72 100644 --- a/Plaster/Public/Test-PlasterManifest.ps1 +++ b/Plaster/Public/Test-PlasterManifest.ps1 @@ -16,6 +16,13 @@ function Test-PlasterManifest { begin { $schemaPath = [System.IO.Path]::Combine($PSScriptRoot, "Schema", "PlasterManifest-v1.xsd") + # When running from source, this function is dot-sourced from Public/, so + # $PSScriptRoot points at Public/ and Schema/ lives one level up. The compiled + # build flattens everything to the module root, where the first path is correct. + if (-not (Test-Path -LiteralPath $schemaPath)) { + $schemaPath = [System.IO.Path]::Combine((Split-Path $PSScriptRoot -Parent), "Schema", "PlasterManifest-v1.xsd") + } + # Schema validation is not available on .NET Core - at the moment. if ('System.Xml.Schema.XmlSchemaSet' -as [type]) { $xmlSchemaSet = New-Object System.Xml.Schema.XmlSchemaSet diff --git a/Plaster/en-US/Plaster.Resources.psd1 b/Plaster/en-US/Plaster.Resources.psd1 index b443c27..b35a28b 100644 --- a/Plaster/en-US/Plaster.Resources.psd1 +++ b/Plaster/en-US/Plaster.Resources.psd1 @@ -36,7 +36,7 @@ ManifestErrorReading_F1=Error reading Plaster manifest: {0} ManifestNotValid_F1=The Plaster manifest '{0}' is not valid. ManifestNotValidVerbose_F1=The Plaster manifest '{0}' is not valid. Specify -Verbose to see the specific schema errors. ManifestNotWellFormedXml_F2=The Plaster manifest '{0}' is not a well-formed XML file. {1} -ManifestWrongFilename_F1=The Plaster manifest filename '{0}' is not valid. The value of the Path argument must refer to a file named 'plasterManifest.xml' or 'plasterManifest_.xml'. Change the Plaster manifest filename and then try again. +ManifestWrongFilename_F1=The Plaster manifest filename '{0}' is not valid. The value of the Path argument must refer to a file named 'plasterManifest.xml', 'plasterManifest.json', or a culture-specific variant (e.g., 'plasterManifest_en-US.xml'). Change the Plaster manifest filename and then try again. MissingParameterPrompt_F1= NewModManifest_CreatingDir_F1=Creating destination directory for module manifest: {0} OpConflict=Conflict diff --git a/README.md b/README.md index 0a401c2..b9bfa03 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![PowerShell Gallery Version](https://img.shields.io/powershellgallery/v/Plaster.svg)](https://www.powershellgallery.com/packages/Plaster) [![PowerShell Gallery Downloads](https://img.shields.io/powershellgallery/dt/Plaster.svg)](https://www.powershellgallery.com/packages/Plaster) -[![Build Status](https://github.com/PowerShell/Plaster/workflows/CI/badge.svg)](https://github.com/PowerShell/Plaster/actions) +[![Build Status](https://github.com/PowerShellOrg/Plaster/actions/workflows/PesterReports.yml/badge.svg)](https://github.com/PowerShellOrg/Plaster/actions) Plaster is a template-based file and project generator written in PowerShell. Its purpose is to streamline the creation of PowerShell module projects, Pester tests, DSC configurations, and more. File generation is performed using crafted templates which allow the user to fill in details and choose from options to get their desired output. @@ -43,7 +43,7 @@ Install-Module -Name Plaster -Scope CurrentUser ### From Source ```powershell -git clone https://github.com/PowerShell/Plaster.git +git clone https://github.com/PowerShellOrg/Plaster.git Import-Module .\Plaster\Plaster\Plaster.psd1 ``` @@ -87,7 +87,7 @@ code plasterManifest.xml ### JSON Manifest Example ```json { - "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "$schema": "https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json", "schemaVersion": "2.0", "metadata": { "name": "MyTemplate", @@ -259,7 +259,7 @@ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guid ### Development Setup ```powershell -git clone https://github.com/PowerShell/Plaster.git +git clone https://github.com/PowerShellOrg/Plaster.git cd Plaster Import-Module .\Plaster\Plaster.psd1 Invoke-Pester # Run tests @@ -278,4 +278,4 @@ This project is licensed under the MIT License - see [LICENSE](LICENSE) for deta --- -**Plaster 2.0** - Modern template scaffolding for PowerShell with JSON support, better tooling, and enhanced developer experience. 🚀 +**Plaster 2.0** - Modern template scaffolding for PowerShell with JSON support, cross-platform compatibility, and enhanced developer experience. diff --git a/ReleaseNotes.md b/ReleaseNotes.md index fefc51c..5ef006b 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,7 +1,32 @@ -## What is New in Plaster 1.0.0 -December 16, 2016 +## What is New in Plaster 2.0.0 +April 2026 -- First official release shipped to the PowerShell Gallery! +### Breaking Changes +- Minimum PowerShell version updated to 5.1 (was 3.0) +- Test framework updated to Pester 5.x +- Default file encoding changed to UTF8-NoBOM + +### New Features +- **JSON Manifest Support**: Create templates using JSON (`plasterManifest.json`) with full JSON Schema validation and VS Code IntelliSense +- **Cross-Platform**: Full support for Windows, Linux, and macOS on PowerShell 7.x +- **Simplified Variables**: JSON manifests use `${ParameterName}` instead of `${PLASTER_PARAM_ParameterName}` +- **Native Arrays**: Multichoice defaults use JSON arrays `[0, 1, 2]` instead of comma-separated strings +- **Format Auto-Detection**: Plaster automatically detects and processes both XML and JSON manifests +- **Enhanced Logging**: Configurable logging via `$env:PLASTER_LOG_LEVEL` + +### Improvements +- Better error messages with actionable guidance +- Improved constrained runspace compatibility with PowerShell 7.x +- Platform-specific parameter store paths (XDG on Linux, standard paths on macOS/Windows) +- Optimized module loading and template processing +- Comprehensive Pester 5.x test suite + +### Bug Fixes +- Fixed .NET Core XML schema validation issues +- Resolved path handling on non-Windows platforms +- Fixed constrained runspace compatibility with PowerShell 7.x +- Corrected parameter default value storage on non-Windows platforms +- Fixed variable substitution edge cases ### Feedback -Please send your feedback to http://github.com/PowerShell/Plaster/issues +Please send your feedback to https://github.com/PowerShellOrg/Plaster/issues diff --git a/demos/.gitignore b/demos/.gitignore new file mode 100644 index 0000000..37bd086 --- /dev/null +++ b/demos/.gitignore @@ -0,0 +1,2 @@ +# Generated by Run-Demos.ps1 / Demo4-Discovery-Authoring.ps1 +output/ diff --git a/demos/Demo4-Discovery-Authoring.ps1 b/demos/Demo4-Discovery-Authoring.ps1 new file mode 100644 index 0000000..51a1620 --- /dev/null +++ b/demos/Demo4-Discovery-Authoring.ps1 @@ -0,0 +1,86 @@ +<# +.SYNOPSIS + Demo 4 - Template discovery and live manifest authoring. +.DESCRIPTION + Shows the three "meta" cmdlets that surround Invoke-Plaster: + * Get-PlasterTemplate - discover templates (bundled + your own folders) + * New-PlasterManifest - author a brand-new JSON manifest from a folder of files + * Test-PlasterManifest - validate a manifest before you ship it + + Ends by scaffolding from the template it just authored, proving the round-trip. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$templates = Join-Path $PSScriptRoot 'templates' +$outputDir = Join-Path $PSScriptRoot 'output' + +Import-Module (Join-Path $root 'Plaster\Plaster.psd1') -Force + +function Write-Header($Text) { + Write-Host '' + Write-Host ('=' * 70) -ForegroundColor DarkBlue + Write-Host " $Text" -ForegroundColor Yellow + Write-Host ('=' * 70) -ForegroundColor DarkBlue +} + +# ---------------------------------------------------------------------------- +Write-Header 'DEMO 4a - Discover templates with Get-PlasterTemplate' + +Write-Host "`n Templates that ship with Plaster:" -ForegroundColor Green +Get-PlasterTemplate | Format-Table Name, Version, Title -AutoSize + +Write-Host " Your own templates (any folder, -Recurse):" -ForegroundColor Green +Get-PlasterTemplate -Path $templates -Recurse | Format-Table Name, Version, Title, Tags -AutoSize + +# ---------------------------------------------------------------------------- +Write-Header 'DEMO 4b - Author a new manifest with New-PlasterManifest' + +# Start from a folder that already has some files we want to template. +$scratch = Join-Path $outputDir 'authored-template' +if (Test-Path $scratch) { Remove-Item $scratch -Recurse -Force } +New-Item $scratch -ItemType Directory | Out-Null +'Write-Host "Hello from <%= $PLASTER_PARAM_Thing %>"' | Set-Content (Join-Path $scratch 'thing.ps1') +'# Notes about <%= $PLASTER_PARAM_Thing %>' | Set-Content (Join-Path $scratch 'NOTES.md') + +Write-Host "`n Generating plasterManifest.json (-AddContent scans the folder)..." -ForegroundColor Green +New-PlasterManifest -Path (Join-Path $scratch 'plasterManifest.json') ` + -TemplateName 'MyTinyTemplate' -TemplateType Item ` + -Title 'My Tiny Template' -Description 'Authored live on stage' ` + -Author 'Grace Hopper' -Tags Demo, Authoring -AddContent + +Write-Host " --- authored plasterManifest.json ---" -ForegroundColor DarkGray +Get-Content (Join-Path $scratch 'plasterManifest.json') | ForEach-Object { " $_" } + +# ---------------------------------------------------------------------------- +Write-Header 'DEMO 4c - Validate it with Test-PlasterManifest' + +$manifest = Test-PlasterManifest -Path (Join-Path $scratch 'plasterManifest.json') 3>$null +if ($manifest) { + Write-Host "`n Valid. name='$($manifest.plasterManifest.metadata.name)' type='$($manifest.plasterManifest.templateType)'" -ForegroundColor Green +} + +# ---------------------------------------------------------------------------- +Write-Header 'DEMO 4d - Use the template we just authored' + +# Two quick edits to make the round-trip meaningful: +# 1. Add a 'Thing' parameter so there is a $PLASTER_PARAM_Thing to substitute. +# 2. New-PlasterManifest -AddContent emits 'file' actions (verbatim copy). Switch +# them to 'templateFile' so the <%= ... %> placeholders actually expand. +$json = Get-Content (Join-Path $scratch 'plasterManifest.json') -Raw | ConvertFrom-Json +$json.parameters = @( + [pscustomobject]@{ name = 'Thing'; type = 'text'; prompt = 'Name the thing'; default = 'Sproket' } +) +foreach ($action in $json.content) { $action.type = 'templateFile' } +$json | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $scratch 'plasterManifest.json') + +$dst = Join-Path $outputDir '04-authored-output' +if (Test-Path $dst) { Remove-Item $dst -Recurse -Force } +Invoke-Plaster -TemplatePath $scratch -DestinationPath $dst -NoLogo -Thing 'Sproket' + +Write-Host "`n --- thing.ps1 (generated from our authored template) ---" -ForegroundColor DarkGray +Get-Content (Join-Path $dst 'thing.ps1') | ForEach-Object { " $_" } + +Write-Host "`nDemo 4 complete.`n" -ForegroundColor Cyan diff --git a/demos/README.md b/demos/README.md new file mode 100644 index 0000000..4fbecdd --- /dev/null +++ b/demos/README.md @@ -0,0 +1,81 @@ +# Plaster Presentation Demos + +Three ready-to-run demos showing **Plaster** scaffolding with both manifest formats, +using dummy data (Contoso / Fabrikam / Acme / Ada Lovelace). + +| # | Format | Template | Shows off | +|---|--------|----------|-----------| +| 1 | **XML** | `templates/01-xml-greeter` | Classic format, `${PLASTER_PARAM_X}` syntax, `choice` param, `templateFile`, `message` | +| 2 | **JSON** | `templates/02-json-greeter` | Same template, modern format: `${X}` syntax, `&`-labels without XML escaping | +| 3 | **JSON** | `templates/03-json-module` | `multichoice` (native `[0,1]` array default), `pattern` validation, `user-fullname` (git), **conditional** content, `newModuleManifest`, `modify` | +| 4 | both | `Demo4-Discovery-Authoring.ps1` | The surrounding cmdlets: `Get-PlasterTemplate` (discover), `New-PlasterManifest` (author), `Test-PlasterManifest` (validate), then scaffold from the authored template | + +## Run it (non-interactive — safe for a live stage) + +```powershell +# From the repo root +.\demos\Run-Demos.ps1 # runs all four +.\demos\Run-Demos.ps1 -Demo 2 # just one (1, 2, 3, 4) +.\demos\Demo4-Discovery-Authoring.ps1 # the discovery/authoring demo on its own +``` + +Every parameter is passed on the command line, so nothing prompts and the run is +deterministic. Generated projects land in `demos/output/`. + +## Run it interactively (to show the prompts live) + +Plaster turns each template parameter into a real cmdlet parameter *and* prompts for +any you omit. To demo the interactive Q&A, run a template by hand and leave parameters off: + +```powershell +Import-Module .\Plaster\Plaster.psd1 -Force +Invoke-Plaster -TemplatePath .\demos\templates\03-json-module ` + -DestinationPath .\demos\output\live +``` + +You'll get prompts for the module name (with regex validation), author (pre-filled from +`git config user.name`), a single-choice license menu, and a multi-select feature list. +Run it from a real terminal — the VS Code integrated terminal works; piped/non-TTY hosts do not. + +## Talking points + +- **One engine, two formats.** Internally Plaster converts JSON manifests to the same XML + structure the engine has always used, so JSON is purely an authoring convenience with + zero behavioral difference. (See `Plaster/Private/ConvertFrom-JsonManifest.ps1`.) +- **Variable syntax differs by location:** + - *Manifest attributes* (`destination`, message `text`): JSON lets you write the short + `${ModuleName}`; Plaster rewrites it to `${PLASTER_PARAM_ModuleName}` automatically. + - *`condition` expressions* and *template file bodies* (`<%= ... %>`): use the **full** + `$PLASTER_PARAM_ModuleName` form — those paths are not rewritten. +- **Conditions exclude files.** In Demo 3, `Build` is deselected, so `build.ps1` is never + created — show the output tree to make the point. +- **`modify` edits an already-generated file.** Demo 3 generates `README.md` with a + `__LICENSE__` placeholder, then a `modify` action swaps in the chosen license. +- **Safety.** Templates are declarative and expressions run in a *constrained runspace* — + a template can't run arbitrary destructive code. (See `New-ConstrainedRunspace.ps1`.) + +## Side-by-side: same template, two formats + +`01-xml-greeter/plasterManifest.xml` vs `02-json-greeter/plasterManifest.json` produce an +equivalent script. Good slide material for the XML-vs-JSON contrast: + +- XML: `` → JSON: `{ "label": "&Hello" }` (no escaping) +- XML: `default="0"` string → JSON: `"default": 0` (and `[0, 1]` for multichoice) +- XML: `${PLASTER_PARAM_ScriptName}` → JSON: `${ScriptName}` + +## Note on module fixes + +Running the templates from source surfaced three small bugs that these demos depend on; all +are fixed in this branch: + +1. `Test-PlasterManifest` resolved the XML schema (`PlasterManifest-v1.xsd`) relative to + `Public/` when run from source — added a one-level-up fallback. +2. `ConvertFrom-JsonContentAction` leaked `AppendChild` return values into the pipeline for + `modify` actions, returning an array instead of a single element — suppressed with `$null =`. +3. `Get-PlasterTemplate` (no args) looked for the bundled `Templates/` folder relative to + `Public/` when run from source — added the same one-level-up fallback. + +All three share one root cause: when the module runs from source its functions are +dot-sourced from `Public/`/`Private/`, so `$PSScriptRoot` points one level below the module +root. The compiled build in `Output/` flattens everything to the root, so it was already fine — +but it has been rebuilt (`.\build.ps1`) anyway so source and compiled match. diff --git a/demos/Run-Demos.ps1 b/demos/Run-Demos.ps1 new file mode 100644 index 0000000..1e56d0d --- /dev/null +++ b/demos/Run-Demos.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS + Runs the Plaster presentation demos non-interactively. +.DESCRIPTION + Imports Plaster from source and scaffolds three projects into demos/output: + 1. XML manifest - classic greeter script + 2. JSON manifest - same greeter, modern format + 3. JSON manifest - full-featured module (multichoice, conditions, modify, newModuleManifest) + + Non-interactive: every template parameter is passed on the command line, so + nothing prompts. Great for a reliable live run. To demo the INTERACTIVE + experience instead, run one template by hand, e.g.: + + Invoke-Plaster -TemplatePath .\demos\templates\02-json-greeter ` + -DestinationPath .\demos\output\greeter-interactive +.PARAMETER Demo + Which demo(s) to run: 1, 2, 3, or All (default). +#> +[CmdletBinding()] +param( + [ValidateSet('1', '2', '3', '4', 'All')] + [string]$Demo = 'All' +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$outputDir = Join-Path $PSScriptRoot 'output' +$templates = Join-Path $PSScriptRoot 'templates' + +$moduleToLoad = Join-Path $root 'Plaster\Plaster.psd1' +Write-Host "Loading Plaster from: $moduleToLoad" -ForegroundColor DarkGray +Import-Module $moduleToLoad -Force + +# Fresh output folder each run +if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse -Force } +New-Item $outputDir -ItemType Directory | Out-Null + +function Show-Tree($Path) { + Get-ChildItem $Path -Recurse -File | + ForEach-Object { ' ' + $_.FullName.Substring($Path.Length + 1) } | + Sort-Object +} + +function Write-Header($Text) { + Write-Host '' + Write-Host ('=' * 70) -ForegroundColor DarkBlue + Write-Host " $Text" -ForegroundColor Yellow + Write-Host ('=' * 70) -ForegroundColor DarkBlue +} + +if ($Demo -in '1', 'All') { + Write-Header 'DEMO 1 - XML manifest (classic greeter script)' + $dst = Join-Path $outputDir '01-xml-greeter' + Invoke-Plaster -TemplatePath (Join-Path $templates '01-xml-greeter') ` + -DestinationPath $dst -NoLogo ` + -ScriptName 'Greet-Contoso' -Greeting 'Howdy' + Write-Host "`n Files created:" -ForegroundColor Green + Show-Tree $dst + Write-Host "`n --- Greet-Contoso.ps1 ---" -ForegroundColor DarkGray + Get-Content (Join-Path $dst 'Greet-Contoso.ps1') | ForEach-Object { " $_" } +} + +if ($Demo -in '2', 'All') { + Write-Header 'DEMO 2 - JSON manifest (same greeter, modern format)' + $dst = Join-Path $outputDir '02-json-greeter' + Invoke-Plaster -TemplatePath (Join-Path $templates '02-json-greeter') ` + -DestinationPath $dst -NoLogo ` + -ScriptName 'Greet-Fabrikam' -Greeting 'Salutations' + Write-Host "`n Files created:" -ForegroundColor Green + Show-Tree $dst + Write-Host "`n --- Greet-Fabrikam.ps1 ---" -ForegroundColor DarkGray + Get-Content (Join-Path $dst 'Greet-Fabrikam.ps1') | ForEach-Object { " $_" } +} + +if ($Demo -in '3', 'All') { + Write-Header 'DEMO 3 - JSON manifest (full module: multichoice + conditions + modify)' + $dst = Join-Path $outputDir '03-json-module' + # Features is multichoice -> pass an array. Drop 'Build' to show a condition + # excluding a file (no build.ps1 is generated). + Invoke-Plaster -TemplatePath (Join-Path $templates '03-json-module') ` + -DestinationPath $dst -NoLogo ` + -ModuleName 'AcmeWidget' -Author 'Ada Lovelace' ` + -License 'Apache' -Features 'Pester', 'Git' + Write-Host "`n Files created (note: build.ps1 was skipped by condition):" -ForegroundColor Green + Show-Tree $dst + Write-Host "`n --- AcmeWidget/README.md (note __LICENSE__ was replaced) ---" -ForegroundColor DarkGray + Get-Content (Join-Path $dst 'AcmeWidget\README.md') | ForEach-Object { " $_" } +} + +if ($Demo -in '4', 'All') { + # Demo 4 (discovery + authoring) lives in its own script because it drives the + # surrounding cmdlets rather than scaffolding from a fixed template. + & (Join-Path $PSScriptRoot 'Demo4-Discovery-Authoring.ps1') +} + +Write-Host "`nAll done. Output is in: $outputDir`n" -ForegroundColor Cyan diff --git a/demos/templates/01-xml-greeter/greeter.ps1 b/demos/templates/01-xml-greeter/greeter.ps1 new file mode 100644 index 0000000..38dd8fb --- /dev/null +++ b/demos/templates/01-xml-greeter/greeter.ps1 @@ -0,0 +1,10 @@ +function <%= $PLASTER_PARAM_ScriptName %> { + [CmdletBinding()] + param( + [Parameter(Position = 0)] + [string]$Name = 'World' + ) + "<%= $PLASTER_PARAM_Greeting %>, $Name!" +} + +# Generated by Plaster (XML manifest) on <%= (Get-Date).ToString('yyyy-MM-dd') %> diff --git a/demos/templates/01-xml-greeter/plasterManifest.xml b/demos/templates/01-xml-greeter/plasterManifest.xml new file mode 100644 index 0000000..90fc204 --- /dev/null +++ b/demos/templates/01-xml-greeter/plasterManifest.xml @@ -0,0 +1,26 @@ + + + + XmlGreeter + 9f3b1c20-0001-4a10-9aaa-111111111111 + 1.0.0 + Greeter Script (XML format) + Classic XML manifest. Scaffolds a small greeter script. + Contoso Demo Team + Demo, Script, XML + + + + + + + + + + + + Created '${PLASTER_PARAM_ScriptName}.ps1' that says '${PLASTER_PARAM_Greeting}'. + + diff --git a/demos/templates/02-json-greeter/greeter.ps1 b/demos/templates/02-json-greeter/greeter.ps1 new file mode 100644 index 0000000..2e987da --- /dev/null +++ b/demos/templates/02-json-greeter/greeter.ps1 @@ -0,0 +1,10 @@ +function <%= $PLASTER_PARAM_ScriptName %> { + [CmdletBinding()] + param( + [Parameter(Position = 0)] + [string]$Name = 'World' + ) + "<%= $PLASTER_PARAM_Greeting %>, $Name!" +} + +# Generated by Plaster (JSON manifest) on <%= (Get-Date).ToString('yyyy-MM-dd') %> diff --git a/demos/templates/02-json-greeter/plasterManifest.json b/demos/templates/02-json-greeter/plasterManifest.json new file mode 100644 index 0000000..8cb9d61 --- /dev/null +++ b/demos/templates/02-json-greeter/plasterManifest.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "name": "JsonGreeter", + "id": "9f3b1c20-0002-4a10-9aaa-222222222222", + "version": "1.0.0", + "title": "Greeter Script (JSON format)", + "description": "Same template as the XML demo, in the modern JSON format.", + "author": "Contoso Demo Team", + "tags": ["Demo", "Script", "JSON"], + "templateType": "Item" + }, + "parameters": [ + { "name": "ScriptName", "type": "text", "prompt": "Name of the script", "default": "Greet-World" }, + { + "name": "Greeting", + "type": "choice", + "prompt": "Pick a greeting", + "default": 0, + "choices": [ + { "label": "&Hello", "value": "Hello", "help": "Friendly" }, + { "label": "&Howdy", "value": "Howdy", "help": "Casual" }, + { "label": "&Salutations", "value": "Salutations", "help": "Formal" } + ] + } + ], + "content": [ + { "type": "templateFile", "source": "greeter.ps1", "destination": "${ScriptName}.ps1" }, + { "type": "message", "text": "\nCreated '${ScriptName}.ps1' that says '${Greeting}'." } + ] +} diff --git a/demos/templates/03-json-module/build.ps1 b/demos/templates/03-json-module/build.ps1 new file mode 100644 index 0000000..ff1dab2 --- /dev/null +++ b/demos/templates/03-json-module/build.ps1 @@ -0,0 +1,7 @@ +#requires -Modules Pester +# Minimal build script for <%= $PLASTER_PARAM_ModuleName %> +[CmdletBinding()] +param([string]$Task = 'Test') + +Write-Host "Running '$Task' for <%= $PLASTER_PARAM_ModuleName %>..." -ForegroundColor Cyan +Invoke-Pester -Path "$PSScriptRoot/tests" diff --git a/demos/templates/03-json-module/gitignore.txt b/demos/templates/03-json-module/gitignore.txt new file mode 100644 index 0000000..73b7d10 --- /dev/null +++ b/demos/templates/03-json-module/gitignore.txt @@ -0,0 +1,5 @@ +Output/ +*.log +.vs/ +.vscode/ +TestResults/ diff --git a/demos/templates/03-json-module/module.psm1 b/demos/templates/03-json-module/module.psm1 new file mode 100644 index 0000000..58319af --- /dev/null +++ b/demos/templates/03-json-module/module.psm1 @@ -0,0 +1,14 @@ +# <%= $PLASTER_PARAM_ModuleName %>.psm1 +# Author: <%= $PLASTER_PARAM_Author %> + +function Get-<%= $PLASTER_PARAM_ModuleName %>Info { + [CmdletBinding()] + param() + [pscustomobject]@{ + Module = '<%= $PLASTER_PARAM_ModuleName %>' + Author = '<%= $PLASTER_PARAM_Author %>' + License = '<%= $PLASTER_PARAM_License %>' + } +} + +Export-ModuleMember -Function Get-<%= $PLASTER_PARAM_ModuleName %>Info diff --git a/demos/templates/03-json-module/plasterManifest.json b/demos/templates/03-json-module/plasterManifest.json new file mode 100644 index 0000000..d374624 --- /dev/null +++ b/demos/templates/03-json-module/plasterManifest.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "name": "AcmeModule", + "id": "9f3b1c20-0003-4a10-9aaa-333333333333", + "version": "1.0.0", + "title": "Acme PowerShell Module (JSON, full-featured)", + "description": "Showcases multichoice, pattern validation, conditional content, newModuleManifest and modify.", + "author": "Contoso Demo Team", + "tags": ["Demo", "Module", "JSON"], + "templateType": "Project" + }, + "parameters": [ + { + "name": "ModuleName", + "type": "text", + "prompt": "Module name (letters, numbers, underscore)", + "default": "AcmeWidget", + "pattern": "^[A-Za-z][A-Za-z0-9_]*$" + }, + { + "name": "Author", + "type": "user-fullname", + "prompt": "Author name" + }, + { + "name": "License", + "type": "choice", + "prompt": "Choose a license", + "default": 0, + "choices": [ + { "label": "&MIT", "value": "MIT" }, + { "label": "&Apache 2.0", "value": "Apache" }, + { "label": "&None", "value": "None" } + ] + }, + { + "name": "Features", + "type": "multichoice", + "prompt": "Select optional features", + "default": [0, 1], + "choices": [ + { "label": "&Pester tests", "value": "Pester", "help": "Add a Pester test scaffold" }, + { "label": "&Build script", "value": "Build", "help": "Add a build.ps1" }, + { "label": "&Git ignore", "value": "Git", "help": "Add a .gitignore" } + ] + } + ], + "content": [ + { + "type": "newModuleManifest", + "destination": "${ModuleName}/${ModuleName}.psd1", + "moduleVersion": "0.1.0", + "rootModule": "${ModuleName}.psm1", + "author": "${Author}", + "description": "The ${ModuleName} module." + }, + { + "type": "templateFile", + "source": "module.psm1", + "destination": "${ModuleName}/${ModuleName}.psm1" + }, + { + "type": "templateFile", + "source": "readme.md", + "destination": "${ModuleName}/README.md" + }, + { + "type": "templateFile", + "source": "tests.ps1", + "destination": "${ModuleName}/tests/${ModuleName}.Tests.ps1", + "condition": "$PLASTER_PARAM_Features -contains 'Pester'" + }, + { + "type": "templateFile", + "source": "build.ps1", + "destination": "${ModuleName}/build.ps1", + "condition": "$PLASTER_PARAM_Features -contains 'Build'" + }, + { + "type": "file", + "source": "gitignore.txt", + "destination": "${ModuleName}/.gitignore", + "condition": "$PLASTER_PARAM_Features -contains 'Git'" + }, + { + "type": "modify", + "path": "${ModuleName}/README.md", + "modifications": [ + { "type": "replace", "search": "__LICENSE__", "replace": "${License}" } + ] + }, + { + "type": "message", + "text": "\nModule '${ModuleName}' scaffolded with license '${License}'." + } + ] +} diff --git a/demos/templates/03-json-module/readme.md b/demos/templates/03-json-module/readme.md new file mode 100644 index 0000000..0b0b35b --- /dev/null +++ b/demos/templates/03-json-module/readme.md @@ -0,0 +1,7 @@ +# <%= $PLASTER_PARAM_ModuleName %> + +The **<%= $PLASTER_PARAM_ModuleName %>** module by <%= $PLASTER_PARAM_Author %>. + +## License + +This project is licensed under the __LICENSE__ license. diff --git a/demos/templates/03-json-module/tests.ps1 b/demos/templates/03-json-module/tests.ps1 new file mode 100644 index 0000000..35b0ccc --- /dev/null +++ b/demos/templates/03-json-module/tests.ps1 @@ -0,0 +1,8 @@ +Describe '<%= $PLASTER_PARAM_ModuleName %>' { + BeforeAll { + Import-Module "$PSScriptRoot/../<%= $PLASTER_PARAM_ModuleName %>.psd1" -Force + } + It 'reports its own name' { + (Get-<%= $PLASTER_PARAM_ModuleName %>Info).Module | Should -Be '<%= $PLASTER_PARAM_ModuleName %>' + } +} diff --git a/examples/NewModule/plasterManifest.json b/examples/NewModule/plasterManifest.json index 849e082..ca2239a 100644 --- a/examples/NewModule/plasterManifest.json +++ b/examples/NewModule/plasterManifest.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "$schema": "https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json", "schemaVersion": "2.0", "metadata": { "id": "dcd95744-8abc-4ecb-a439-bf2cd37821bb", diff --git a/tests/New-PlasterManifest.Tests.ps1 b/tests/New-PlasterManifest.Tests.ps1 index 6fac221..391300c 100644 --- a/tests/New-PlasterManifest.Tests.ps1 +++ b/tests/New-PlasterManifest.Tests.ps1 @@ -27,7 +27,6 @@ BeforeDiscovery { $actualManifest | Should -BeExactly $expectedManifest } } -# TODO: Add JSON tests Describe 'New-PlasterManifest Command Tests' { BeforeEach { $TemplateDir = "TestDrive:\TemplateRootTemp" @@ -214,3 +213,109 @@ Describe 'New-PlasterManifest Command Tests' { } #> } + +Describe 'New-PlasterManifest JSON Format Tests' { + BeforeEach { + $TemplateDir = "TestDrive:\JsonTemplateDir" + New-Item -ItemType Directory $TemplateDir | Out-Null + $PlasterManifestPath = "$TemplateDir\plasterManifest.json" + } + AfterEach { + Remove-Item $TemplateDir -Recurse -Confirm:$false -ErrorAction SilentlyContinue + } + + Context 'Generates a valid JSON manifest' { + It 'Creates JSON with basic parameters' { + $newPlasterManifestSplat = @{ + Path = $PlasterManifestPath + Id = '1a1b0933-78b2-4a3e-bf48-492591e69521' + TemplateName = 'JsonTemplate' + TemplateType = 'Project' + Format = 'JSON' + Author = 'TestAuthor' + Description = 'A JSON test template' + } + New-PlasterManifest @newPlasterManifestSplat + + Test-Path $PlasterManifestPath | Should -Be $true + $content = Get-Content $PlasterManifestPath -Raw | ConvertFrom-Json + $content.schemaVersion | Should -Be '2.0' + $content.metadata.name | Should -Be 'JsonTemplate' + $content.metadata.id | Should -Be '1a1b0933-78b2-4a3e-bf48-492591e69521' + $content.metadata.version | Should -Be '1.0.0' + $content.metadata.templateType | Should -Be 'Project' + $content.metadata.author | Should -Be 'TestAuthor' + $content.metadata.description | Should -Be 'A JSON test template' + } + + It 'Handles tags in JSON format' { + $newPlasterManifestSplat = @{ + Path = $PlasterManifestPath + Id = '1a1b0933-78b2-4a3e-bf48-492591e69521' + TemplateName = 'TagTest' + TemplateType = 'Item' + Format = 'JSON' + Author = 'Test' + Tags = 'Module', 'PowerShell', 'Template' + } + New-PlasterManifest @newPlasterManifestSplat + + $content = Get-Content $PlasterManifestPath -Raw | ConvertFrom-Json + $content.metadata.tags | Should -HaveCount 3 + $content.metadata.tags | Should -Contain 'Module' + $content.metadata.tags | Should -Contain 'PowerShell' + } + + It 'JSON manifest does not require XML entity escaping' { + $newPlasterManifestSplat = @{ + Path = $PlasterManifestPath + Id = '1a1b0933-78b2-4a3e-bf48-492591e69521' + TemplateName = 'EscapeTest' + TemplateType = 'Project' + Format = 'JSON' + Author = 'Test' + Description = 'This is & awesome.' + } + New-PlasterManifest @newPlasterManifestSplat + + $content = Get-Content $PlasterManifestPath -Raw | ConvertFrom-Json + $content.metadata.description | Should -Be 'This is & awesome.' + } + + It 'AddContent parameter works with JSON format' { + Copy-Item $PSScriptRoot\Recurse $TemplateDir -Recurse + + $newPlasterManifestSplat = @{ + Path = $PlasterManifestPath + Id = '1a1b0933-78b2-4a3e-bf48-492591e69521' + TemplateName = 'ContentTest' + TemplateType = 'Project' + Format = 'JSON' + Author = 'Test' + AddContent = $true + } + New-PlasterManifest @newPlasterManifestSplat + + $content = Get-Content $PlasterManifestPath -Raw | ConvertFrom-Json + $content.content.Count | Should -BeGreaterThan 0 + $content.content[0].type | Should -Be 'file' + $content.content[0].source | Should -Not -BeNullOrEmpty + } + + It 'Defaults to JSON format when Format not specified' { + $defaultPath = "$TemplateDir\plasterManifest.json" + $newPlasterManifestSplat = @{ + Path = $defaultPath + Id = '1a1b0933-78b2-4a3e-bf48-492591e69521' + TemplateName = 'DefaultFormat' + TemplateType = 'Item' + Author = 'Test' + } + New-PlasterManifest @newPlasterManifestSplat + + Test-Path $defaultPath | Should -Be $true + $content = Get-Content $defaultPath -Raw | ConvertFrom-Json + $content.schemaVersion | Should -Be '2.0' + } + } +}