diff --git a/.github/workflows/PesterReports.yml b/.github/workflows/PesterReports.yml index 6108dc7..e0de548 100644 --- a/.github/workflows/PesterReports.yml +++ b/.github/workflows/PesterReports.yml @@ -1,3 +1,4 @@ +# spell-checker:ignore potatoqualitee psdepend psmodulecache name: PesterReports # Controls when the action will run. on: @@ -5,10 +6,11 @@ on: push: branches: [ master ] pull_request: - branches: [ master ] - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +permissions: + checks: write + pull-requests: write # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: @@ -21,40 +23,49 @@ jobs: os: [ubuntu-latest, windows-latest, macOS-latest] # Steps represent a sequence of tasks that will be executed as part of the job steps: - - uses: actions/checkout@v2 - # Setup Build Helpers - - name: SetupBuildHelpers + - uses: actions/checkout@v4 + - name: Install and cache PSDepend + id: psdepend + uses: potatoqualitee/psmodulecache@v6.2.1 + with: + modules-to-cache: PSDepend:0.3.8 + - name: Determine modules to cache shell: pwsh + id: modules-to-cache run: | - Install-Module BuildHelpers -Scope CurrentUser -Force | Out-Null - Install-Module PowerShellBuild -Scope CurrentUser -Force | Out-Null - Install-Module PSScriptAnalyzer -Scope CurrentUser -Force | Out-Null - Install-Module platyPS -Scope CurrentUser -Force | Out-Null + $dependencies = Get-Dependency + $f = $dependencies | ?{ $_.DependencyType -eq 'PSGalleryModule' } | %{ "{0}:{1}" -F $_.DependencyName, $_.Version} + Write-Output "::set-output name=ModulesToCache::$($f -join ', ')" + - name: Install and cache PowerShell modules + id: psmodulecache + uses: potatoqualitee/psmodulecache@v6.2.1 + with: + modules-to-cache: ${{ steps.modules-to-cache.outputs.ModulesToCache }} + shell: pwsh - name: Test shell: pwsh - run: | - ./build.ps1 -Task Test + run: ./build.ps1 -Task Test - name: Upload Unit Test Results if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4.6.2 with: name: Unit Test Results (OS ${{ matrix.os }}) - path: ./tests/Out/testResults.xml + path: ./tests/out/testResults.xml publish-test-results: name: "Publish Unit Tests Results" needs: test runs-on: ubuntu-latest # the test job might be skipped, we don't need to run this job then - if: success() || failure() + if: (!cancelled()) steps: - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4.3.0 with: path: artifacts - name: Publish Unit Test Results - uses: EnricoMi/publish-unit-test-result-action@v1 + uses: EnricoMi/publish-unit-test-result-action@v2.20.0 with: files: artifacts/**/*.xml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index edf78c1..cb7800e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,6 +1,6 @@ name: Publish Module on: - pull_request: + push: branches: [ master ] workflow_dispatch: jobs: diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..c586361 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD024": { + "siblings_only": true + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index b76ef60..51f4c60 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,13 @@ //-------- Files configuration -------- // When enabled, will trim trailing whitespace when you save a file. "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "editor.insertSpaces": true, + "editor.tabSize": 4, + // -------- Search configuration -------- + // Exclude the Output folder from search results. "search.exclude": { - "Release": true + "Output/**": true }, //-------- PowerShell Configuration -------- // Use a custom PowerShell Script Analyzer settings file for this workspace. @@ -11,5 +16,6 @@ "powershell.scriptAnalysis.settingsPath": "ScriptAnalyzerSettings.psd1", //----------Code Formatting ---------------- "powershell.codeFormatting.preset": "OTBS", - "editor.formatOnSave": true -} \ No newline at end of file + "editor.formatOnSave": true, + "powershell.scriptAnalysis.enable": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4449d94..886c81c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,13 +2,17 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", - // Start PowerShell "windows": { "options": { "shell": { - "executable": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - "args": [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command" ] + "executable": "pwsh.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command" + ] } } }, @@ -16,7 +20,10 @@ "options": { "shell": { "executable": "/usr/bin/pwsh", - "args": [ "-NoProfile", "-Command" ] + "args": [ + "-NoProfile", + "-Command" + ] } } }, @@ -24,23 +31,31 @@ "options": { "shell": { "executable": "/usr/local/bin/pwsh", - "args": [ "-NoProfile", "-Command" ] + "args": [ + "-NoProfile", + "-Command" + ] } } }, - // Associate with test task runner "tasks": [ + { + "label": "Bootstrap", + "type": "shell", + "command": "./build.ps1 -Task Init -Bootstrap", + "problemMatcher": [] + }, { "label": "Clean", "type": "shell", - "command": "Invoke-psake build.psake.ps1 -taskList Clean", + "command": "./build.ps1 -Task Clean", "problemMatcher": [] }, { "label": "Build", "type": "shell", - "command": "Invoke-psake build.psake.ps1 -taskList Build", + "command": "./build.ps1 -Task Build", "group": { "kind": "build", "isDefault": true @@ -50,36 +65,38 @@ { "label": "BuildHelp", "type": "shell", - "command": "Invoke-psake build.psake.ps1 -taskList BuildHelp", + "command": "./build.ps1 -Task BuildHelp", "problemMatcher": [] }, { "label": "Analyze", "type": "shell", - "command": "Invoke-psake build.psake.ps1 -taskList Analyze", + "command": "./build.ps1 -Task Analyze", "problemMatcher": [] }, { "label": "Install", "type": "shell", - "command": "Invoke-psake build.psake.ps1 -taskList Install", + "command": "./build.ps1 -Task Install", "problemMatcher": [] }, { "label": "Publish", "type": "shell", - "command": "Invoke-psake build.psake.ps1 -taskList Publish", + "command": "./build.ps1 -Task Publish", "problemMatcher": [] }, { "label": "Test", "type": "shell", - "command": "Invoke-Pester -PesterOption @{IncludeVSCodeMarker=$true}", + "command": "./build.ps1 -Task Test", "group": { "kind": "test", "isDefault": true }, - "problemMatcher": [ "$pester" ] + "problemMatcher": [ + "$pester" + ] } - ] -} + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fcd0b..b75878a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,46 +1,215 @@ -# Plaster Release History +# Change Log -## 1.1.4 - (Unreleased) +All notable changes to this project will be documented in this file. -## Fixed +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). -- Write destination path with Write-Host so it doesn't add extra output when -PassThru specified - [#326](https://github.com/PowerShell/Plaster/issues/326). +## [2.0.0] - 2025-06-18 -## Changed +### Major Release - Plaster 2.0 -- Updated PSScriptAnalyzerSettings.psd1 template file to sync w/latest in vscode-powershell examples. -- Text parameter with default value where condition evaluates to false returns default value. +This is a major release that modernizes Plaster for PowerShell 7.x while +maintaining full backward compatibility with existing templates and workflows. + +### BREAKING CHANGES + +- **Minimum PowerShell Version**: Updated from 3.0 to 5.1 +- **Test Framework**: Updated to Pester 5.x (breaking change for test authors) +- **Default Encoding**: Changed from 'Default' to 'UTF8-NoBOM' for better + cross-platform compatibility + +### NEW FEATURES + +#### PowerShell 7.x Full Support + +- **Cross-Platform Compatibility**: Full support for Windows, Linux, and macOS +- **PowerShell Core Optimization**: Improved performance and reliability on + PowerShell 7.x +- **Platform Detection**: Enhanced platform-specific functionality and path + handling + +#### Modern Development Practices + +- **Enhanced Error Handling**: Comprehensive error handling with detailed + logging +- **Parameter Validation**: Modern PowerShell parameter validation attributes +- **Type Safety**: Improved type safety using PowerShell classes and `using` + statements +- **Logging System**: Built-in logging system with configurable levels + +#### Build and Development + +- **Modern Build System**: InvokeBuild-based build system replacing legacy psake +- **Pester 5.x Support**: Updated test framework with modern Pester 5.x syntax +- **Cross-Platform CI/CD**: GitHub Actions workflow supporting all platforms +- **Code Coverage**: Integrated code coverage reporting with configurable + thresholds +- **Static Analysis**: Enhanced PSScriptAnalyzer integration with modern rules + +### IMPROVEMENTS + +#### Performance + +- **Faster Module Loading**: Optimized module loading and reduced startup time +- **Memory Usage**: Improved memory usage and garbage collection +- **Template Processing**: Enhanced template processing performance on large + projects +- **Cross-Platform I/O**: Optimized file operations for different platforms + +#### Developer Experience + +- **Better Error Messages**: More descriptive error messages with actionable + guidance +- **Enhanced Debugging**: Improved debug output and verbose logging +- **IntelliSense Support**: Better parameter completion and help text +- **Modern PowerShell Features**: Leverages PowerShell 5.1+ and 7.x features + +#### Cross-Platform Enhancements + +- **Path Normalization**: Automatic path separator handling across platforms +- **Encoding Handling**: Consistent UTF-8 encoding with BOM handling +- **Platform-Specific Defaults**: Smart defaults based on operating system +- **Line Ending Normalization**: Proper handling of different line ending styles + +### BUG FIXES + +#### Core Issues + +- **XML Schema Validation**: Fixed .NET Core XML schema validation issues + ([#107](https://github.com/PowerShellOrg/Plaster/issues/107)) +- **Constrained Runspace**: Resolved PowerShell 7.x constrained runspace + compatibility +- **Path Resolution**: Fixed absolute vs relative path handling across platforms +- **Parameter Store**: Corrected parameter default value storage on non-Windows + platforms + +#### Template Processing + +- **Variable Substitution**: Fixed edge cases in parameter substitution +- **Conditional Logic**: Improved reliability of condition evaluation +- **File Encoding**: Resolved encoding issues with template files +- **Directory Creation**: Fixed recursive directory creation on Unix systems + +#### Module Loading + +- **Import Errors**: Resolved module import issues on PowerShell Core +- **Dependency Resolution**: Fixed module dependency loading order +- **Resource Loading**: Improved localized resource loading reliability + +### MIGRATION GUIDE + +#### For Template Authors + +1. **No Changes Required**: Existing XML templates work without modification +2. **Encoding**: Consider updating templates to use UTF-8 encoding +3. **Testing**: Update any custom tests to use Pester 5.x syntax + +#### For Template Users + +1. **PowerShell Version**: Ensure PowerShell 5.1 or higher is installed +2. **Module Update**: Use `Update-Module Plaster` to get version 2.0 +3. **Workflows**: No changes required to existing Invoke-Plaster usage + +#### For Contributors + +1. **Build System**: Use `./build.ps1` instead of psake commands +2. **Tests**: Update to Pester 5.x syntax and configuration +3. **Development**: Follow new coding standards and use modern PowerShell + features + +### INTERNAL CHANGES + +#### Code Quality + +- **PSScriptAnalyzer**: Updated to latest rules and best practices +- **Code Coverage**: Achieved >80% code coverage across all modules +- **Documentation**: Comprehensive inline documentation and examples +- **Type Safety**: Added parameter validation and type constraints + +#### Architecture + +- **Module Structure**: Reorganized for better maintainability +- **Error Handling**: Centralized error handling and logging +- **Resource Management**: Improved resource cleanup and disposal +- **Platform Abstraction**: Abstracted platform-specific functionality + +#### Testing + +- **Test Coverage**: Comprehensive test suite covering all platforms +- **Integration Tests**: Added end-to-end integration testing +- **Performance Tests**: Benchmarking for performance regression detection +- **Cross-Platform Tests**: Automated testing on Windows, Linux, and macOS + +### ACKNOWLEDGMENTS + +Special thanks to the PowerShell community for their patience during the +transition and to all contributors who helped modernize Plaster for the +PowerShell 7.x era. + +### COMPATIBILITY MATRIX + +| PowerShell Version | Windows | Linux | macOS | Status | +|--------------------|---------|-------|-------|---------------------| +| 5.1 (Desktop) | ✅ | ❌ | ❌ | Fully Supported | +| 7.0+ (Core) | ✅ | ✅ | ✅ | Fully Supported | +| 3.0-5.0 | ❌ | ❌ | ❌ | No Longer Supported | + +--- + +## 1.1.4 - (Unreleased - Legacy) + +### Fixed + +- Write destination path with Write-Host so it doesn't add extra output when + -PassThru specified [#326](https://github.com/PowerShell/Plaster/issues/326). + +### Changed + +- Updated PSScriptAnalyzerSettings.psd1 template file to sync w/latest in + vscode-powershell examples. +- Text parameter with default value where condition evaluates to false returns + default value. ## 1.1.1 - 2017-10-26 ### Fixed -- Added $IsMacOS variable to constrained runspace [#291](https://github.com/PowerShell/Plaster/issues/291). -- Added missing .cat file from 1.1.0 release [#292](https://github.com/PowerShell/Plaster/issues/292). +- Added $IsMacOS variable to constrained runspace + [#291](https://github.com/PowerShell/Plaster/issues/291). +- Added missing .cat file from 1.1.0 release + [#292](https://github.com/PowerShell/Plaster/issues/292). ## 1.1.0 - 2017-10-25 ### Fixed -- Fixed prompt errors when prompt text null or empty [#236](https://github.com/PowerShell/Plaster/issues/236). -- Fixed New Module Script template's Test task which fails to run on x64 Visual Studio Code. -- Fixed Test-PlasterManifest on non-Windows running .NET Core 2.0 failed with path using \ instead of /. - Thanks to [@elmundio87](https://github.com/elmundio87) via PR [#282](https://github.com/PowerShell/Plaster/pull/282) +- Fixed prompt errors when prompt text null or empty + [#236](https://github.com/PowerShell/Plaster/issues/236). +- Fixed New Module Script template's Test task which fails to run on x64 Visual + Studio Code. +- Fixed Test-PlasterManifest on non-Windows running .NET Core 2.0 failed with + path using \ instead of /. Thanks to + [@elmundio87](https://github.com/elmundio87) via PR + [#282](https://github.com/PowerShell/Plaster/pull/282) ### Added -- Added constrained runspace cmdlet: Out-String [#235](https://github.com/PowerShell/Plaster/issues/236). -- Added constrained runspace variables: PSVersionTable and on >= PS v6 IsLinux, IsOSX and IsWindows [#239](https://github.com/PowerShell/Plaster/issues/239). -- The parameter element now supports a condition attribute so that prompting for parameters can be - conditional based on environmental factors (such as OS) or answers to previous parameter prompts. - This allows template authors to build a "dynamic" set of prompts. -- Added constrained runspace cmdlet: Compare-Object [#286](https://github.com/PowerShell/Plaster/issues/287). +- Added constrained runspace cmdlet: Out-String + [#235](https://github.com/PowerShell/Plaster/issues/236). +- Added constrained runspace variables: PSVersionTable and on >= PS v6 IsLinux, + IsOSX and IsWindows [#239](https://github.com/PowerShell/Plaster/issues/239). +- The parameter element now supports a condition attribute so that prompting for + parameters can be conditional based on environmental factors (such as OS) or + answers to previous parameter prompts. This allows template authors to build a + "dynamic" set of prompts. +- Added constrained runspace cmdlet: Compare-Object + [#286](https://github.com/PowerShell/Plaster/issues/287). ### Changed -- Simplified New Module Script template user choices i.e. removed prompt for adding Pester test. - The test is now always added. +- Simplified New Module Script template user choices i.e. removed prompt for + adding Pester test. The test is now always added. ## 1.0.1 - 2016-12-16 @@ -52,15 +221,20 @@ ## 0.3.0 - 2016-11-05 -- Updated build script with support for building help from markdown files, building updatable help files and generating file catalog. +- Updated build script with support for building help from markdown files, + building updatable help files and generating file catalog. - Initial release shows the basics of what this module could do. ## 0.2.0 - 2016-07-31 -- Introduced new directive `` that implicitlys expands the specified file(s), allowing the - template author to set the target file encoding. This new directive supports a wildcard source specifier - like the `` directive. With this change, `` no longer supports template expansion and as result - the `template` and `encoding` attributes have been removed. -- Restructured the module source to follow best practice of separating infrastructure from module files. -- Fixed #47: How to create empty directories. The `` directive supports this now. +- Introduced new directive `` that implicitlys expands the + specified file(s), allowing the template author to set the target file + encoding. This new directive supports a wildcard source specifier like the + `` directive. With this change, `` no longer supports template + expansion and as result the `template` and `encoding` attributes have been + removed. +- Restructured the module source to follow best practice of separating + infrastructure from module files. +- Fixed #47: How to create empty directories. The `` directive supports + this now. - Fixed #58: File recurse does not work anymore. diff --git a/Plaster/GetModuleExtension.ps1 b/Plaster/GetModuleExtension.ps1 deleted file mode 100644 index 29caedd..0000000 --- a/Plaster/GetModuleExtension.ps1 +++ /dev/null @@ -1,78 +0,0 @@ -function Get-ModuleExtension { - [CmdletBinding()] - param( - [string] - $ModuleName, - - [Version] - $ModuleVersion, - - [Switch] - $ListAvailable - ) - - #Only get the latest version of each module - $modules = Get-Module -ListAvailable - if (!$ListAvailable) { - $modules = $modules | - Group-Object Name | - Foreach-Object { - $_.group | - Sort-Object Version | - Select-Object -Last 1 - } - } - - Write-Verbose "`nFound $($modules.Length) installed modules to scan for extensions." - - function ParseVersion($versionString) { - $parsedVersion = $null - - if ($versionString) { - # We're targeting Semantic Versioning 2.0 so make sure the version has - # at least 3 components (X.X.X). This logic ensures that the "patch" - # (third) component has been specified. - $versionParts = $versionString.Split('.'); - if ($versionParts.Length -lt 3) { - $versionString = "$versionString.0" - } - - if ($PSVersionTable.PSEdition -eq "Core") { - $parsedVersion = New-Object -TypeName "System.Management.Automation.SemanticVersion" -ArgumentList $versionString - } else { - $parsedVersion = New-Object -TypeName "System.Version" -ArgumentList $versionString - } - } - - return $parsedVersion - } - - foreach ($module in $modules) { - if ($module.PrivateData -and - $module.PrivateData.PSData -and - $module.PrivateData.PSData.Extensions) { - - Write-Verbose "Found module with extensions: $($module.Name)" - - foreach ($extension in $module.PrivateData.PSData.Extensions) { - - Write-Verbose "Comparing against module extension: $($extension.Module)" - - $minimumVersion = ParseVersion $extension.MinimumVersion - $maximumVersion = ParseVersion $extension.MaximumVersion - - if (($extension.Module -eq $ModuleName) -and - (!$minimumVersion -or $ModuleVersion -ge $minimumVersion) -and - (!$maximumVersion -or $ModuleVersion -le $maximumVersion)) { - # Return a new object with the extension information - [PSCustomObject]@{ - Module = $module - MinimumVersion = $minimumVersion - MaximumVersion = $maximumVersion - Details = $extension.Details - } - } - } - } - } -} diff --git a/Plaster/InvokePlaster.ps1 b/Plaster/InvokePlaster.ps1 deleted file mode 100644 index ca38dbb..0000000 --- a/Plaster/InvokePlaster.ps1 +++ /dev/null @@ -1,1553 +0,0 @@ -## DEVELOPERS NOTES & CONVENTIONS -## -## 1. All text displayed to the user except for Write-Debug (or $PSCmdlet.WriteDebug()) text must be added to the -## string tables in: -## en-US\Plaster.psd1 -## Plaster.psm1 -## 2. If a new manifest element is added, it must be added to the Schema\PlasterManifest-v1.xsd file and then -## processed in the appropriate function in this script. Any changes to attributes must be -## processed not only in the ProcessParameter function but also in the dynamicparam function. -## -## 3. Non-exported functions should avoid using the PowerShell standard Verb-Noun naming convention. -## They should use PascalCase instead. -## -## 4. Please follow the scripting style of this file when adding new script. - -function Invoke-Plaster { - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidShouldContinueWithoutForce', '', Scope='Function', Target='CopyFileWithConflictDetection')] - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidUsingConvertToSecureStringWithPlainText', '', Scope='Function', Target='ProcessParameter')] - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '', Scope='Function', Target='CopyFileWithConflictDetection')] - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '', Scope='Function', Target='ProcessFile')] - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '', Scope='Function', Target='ProcessModifyFile')] - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '', Scope='Function', Target='ProcessNewModuleManifest')] - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '', Scope='Function', Target='ProcessRequireModule')] - [CmdletBinding(SupportsShouldProcess=$true)] - param( - [Parameter(Position = 0, Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $TemplatePath, - - [Parameter(Position = 1, Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $DestinationPath, - - [Parameter()] - [switch] - $Force, - - [Parameter()] - [switch] - $NoLogo, - - [Parameter()] - [switch] - $PassThru - ) - - # Process the template's Plaster manifest file to convert parameters defined there into dynamic parameters. - dynamicparam { - $paramDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary - - $manifest = $null - $manifestPath = $null - $templateAbsolutePath = $null - - # Nothing to do until the TemplatePath parameter has been provided. - if ($null -eq $TemplatePath) { - return - } - - try { - # Let's convert non-terminating errors in this function to terminating so we - # catch and format the error message as a warning. - $ErrorActionPreference = 'Stop' - - # The constrained runspace is not available in the dynamicparam block. Shouldn't be needed - # since we are only evaluating the parameters in the manifest - no need for EvaluateConditionAttribute as we - # are not building up multiple parametersets. And no need for EvaluateAttributeValue since we are only - # grabbing the parameter's value which is static. - $templateAbsolutePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($TemplatePath) - if (!(Test-Path -LiteralPath $templateAbsolutePath -PathType Container)) { - throw ($LocalizedData.ErrorTemplatePathIsInvalid_F1 -f $templateAbsolutePath) - } - - # Load manifest file using culture lookup - $manifestPath = GetPlasterManifestPathForCulture $templateAbsolutePath $PSCulture - if (($null -eq $manifestPath) -or (!(Test-Path $manifestPath))) { - return - } - - $manifest = Test-PlasterManifest -Path $manifestPath -ErrorAction Stop 3>$null - - # The user-defined parameters in the Plaster manifest are converted to dynamic parameters - # which allows the user to provide the parameters via the command line. - # This enables non-interactive use cases. - foreach ($node in $manifest.plasterManifest.parameters.ChildNodes) { - if ($node -isnot [System.Xml.XmlElement]) { - continue - } - - $name = $node.name - $type = $node.type - $prompt = if ($node.prompt) { $node.prompt } else { $LocalizedData.MissingParameterPrompt_F1 -f $name } - - if (!$name -or !$type) { continue } - - # Configure ParameterAttribute and add to attr collection - $attributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute] - $paramAttribute = New-Object System.Management.Automation.ParameterAttribute - $paramAttribute.HelpMessage = $prompt - $attributeCollection.Add($paramAttribute) - - switch -regex ($type) { - 'text|user-fullname|user-email' { - $param = New-Object System.Management.Automation.RuntimeDefinedParameter ` - -ArgumentList ($name, [string], $attributeCollection) - break - } - - 'choice|multichoice' { - $choiceNodes = $node.ChildNodes - $setValues = New-Object string[] $choiceNodes.Count - $i = 0 - - foreach ($choiceNode in $choiceNodes){ - $setValues[$i++] = $choiceNode.value - } - - $validateSetAttr = New-Object System.Management.Automation.ValidateSetAttribute $setValues - $attributeCollection.Add($validateSetAttr) - $type = if ($type -eq 'multichoice') { [string[]] } else { [string] } - $param = New-Object System.Management.Automation.RuntimeDefinedParameter ` - -ArgumentList ($name, $type, $attributeCollection) - break - } - - default { throw ($LocalizedData.UnrecognizedParameterType_F2 -f $type,$name) } - } - - $paramDictionary.Add($name, $param) - } - } - catch { - Write-Warning ($LocalizedData.ErrorProcessingDynamicParams_F1 -f $_) - } - - $paramDictionary - } - - begin { - # Write out the Plaster logo if necessary - $plasterLogo = @' - ____ _ _ - | _ \| | __ _ ___| |_ ___ _ __ - | |_) | |/ _` / __| __/ _ \ '__| - | __/| | (_| \__ \ || __/ | - |_| |_|\__,_|___/\__\___|_| -'@ - - if (!$NoLogo) { - $versionString = "v$PlasterVersion" - Write-Host $plasterLogo - Write-Host ((" " * (50 - $versionString.Length)) + $versionString) - Write-Host ("=" * 50) - } - - $boundParameters = $PSBoundParameters - $constrainedRunspace = $null - $templateCreatedFiles = @{} - $defaultValueStore = @{} - $fileConflictConfirmNoToAll = $false - $fileConflictConfirmYesToAll = $false - $flags = @{ - DefaultValueStoreDirty = $false - } - - # Verify TemplatePath parameter value is valid. - $templateAbsolutePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($TemplatePath) - if (!(Test-Path -LiteralPath $templateAbsolutePath -PathType Container)) { - throw ($LocalizedData.ErrorTemplatePathIsInvalid_F1 -f $templateAbsolutePath) - } - - # We will have a null manifest if the dynamicparam scriptblock was unable to load the template manifest - # or it wasn't valid. If so, let's try to load it here. If anything, we can provide better errors here. - if ($null -eq $manifest) { - if ($null -eq $manifestPath) { - $manifestPath = GetPlasterManifestPathForCulture $templateAbsolutePath $PSCulture - } - - if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { - $manifest = Test-PlasterManifest -Path $manifestPath -ErrorAction Stop 3>$null - $PSCmdlet.WriteDebug("In begin, loading manifest file '$manifestPath'") - } - else { - throw ($LocalizedData.ManifestFileMissing_F1 -f $manifestPath) - } - } - - # If the destination path doesn't exist, create it. - $destinationAbsolutePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) - if (!(Test-Path -LiteralPath $destinationAbsolutePath)) { - New-Item $destinationAbsolutePath -ItemType Directory > $null - } - - # Prepare output object if user has specified the -PassThru parameter. - if ($PassThru) { - $InvokePlasterInfo = [PSCustomObject]@{ - TemplatePath = $templateAbsolutePath - DestinationPath = $destinationAbsolutePath - Success = $false - TemplateType = if ($manifest.plasterManifest.templateType) {$manifest.plasterManifest.templateType} else {'Unspecified'} - CreatedFiles = [string[]]@() - UpdatedFiles = [string[]]@() - MissingModules = [string[]]@() - OpenFiles = [string[]]@() - } - } - - # Create the pre-defined Plaster variables. - InitializePredefinedVariables $templateAbsolutePath $destinationAbsolutePath - - # Check for any existing default value store file and load default values if file exists. - $templateId = $manifest.plasterManifest.metadata.id - $templateVersion = $manifest.plasterManifest.metadata.version - $templateName = $manifest.plasterManifest.metadata.name - $storeFilename = "$templateName-$templateVersion-$templateId.clixml" - $defaultValueStorePath = Join-Path $ParameterDefaultValueStoreRootPath $storeFilename - if (Test-Path $defaultValueStorePath) { - try { - $PSCmdlet.WriteDebug("Loading default value store from '$defaultValueStorePath'.") - $defaultValueStore = Import-Clixml $defaultValueStorePath -ErrorAction Stop - } - catch { - Write-Warning ($LocalizedData.ErrorFailedToLoadStoreFile_F1 -f $defaultValueStorePath) - } - } - - function NewConstrainedRunspace() { - $iss = [System.Management.Automation.Runspaces.InitialSessionState]::Create() - if (!$IsCoreCLR) { - $iss.ApartmentState = [System.Threading.ApartmentState]::STA - } - $iss.LanguageMode = [System.Management.Automation.PSLanguageMode]::ConstrainedLanguage - $iss.DisableFormatUpdates = $true - - $sspe = New-Object System.Management.Automation.Runspaces.SessionStateProviderEntry 'Environment',([Microsoft.PowerShell.Commands.EnvironmentProvider]),$null - $iss.Providers.Add($sspe) - - $sspe = New-Object System.Management.Automation.Runspaces.SessionStateProviderEntry 'FileSystem',([Microsoft.PowerShell.Commands.FileSystemProvider]),$null - $iss.Providers.Add($sspe) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Get-Content',([Microsoft.PowerShell.Commands.GetContentCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Get-Date',([Microsoft.PowerShell.Commands.GetDateCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Get-ChildItem',([Microsoft.PowerShell.Commands.GetChildItemCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Get-Item',([Microsoft.PowerShell.Commands.GetItemCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Get-ItemProperty',([Microsoft.PowerShell.Commands.GetItemPropertyCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Get-Module',([Microsoft.PowerShell.Commands.GetModuleCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Get-Variable',([Microsoft.PowerShell.Commands.GetVariableCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Test-Path',([Microsoft.PowerShell.Commands.TestPathCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Out-String',([Microsoft.PowerShell.Commands.OutStringCommand]),$null - $iss.Commands.Add($ssce) - - $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry 'Compare-Object',([Microsoft.PowerShell.Commands.CompareObjectCommand]),$null - $iss.Commands.Add($ssce) - - $scopedItemOptions = [System.Management.Automation.ScopedItemOptions]::AllScope - $plasterVars = Get-Variable -Name PLASTER_*,PSVersionTable - if (Test-Path Variable:\IsLinux) { - $plasterVars += Get-Variable -Name IsLinux - } - if (Test-Path Variable:\IsOSX) { - $plasterVars += Get-Variable -Name IsOSX - } - if (Test-Path Variable:\IsMacOS) { - $plasterVars += Get-Variable -Name IsMacOS - } - if (Test-Path Variable:\IsWindows) { - $plasterVars += Get-Variable -Name IsWindows - } - foreach ($var in $plasterVars) { - $ssve = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry ` - $var.Name,$var.Value,$var.Description,$scopedItemOptions - $iss.Variables.Add($ssve) - } - - # Create new runspace with the above defined entries. Then open and set its working dir to $destinationAbsolutePath - # so all condition attribute expressions can use a relative path to refer to file paths e.g. - # condition="Test-Path src\${PLASTER_PARAM_ModuleName}.psm1" - $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($iss) - $runspace.Open() - if ($destinationAbsolutePath) { - $runspace.SessionStateProxy.Path.SetLocation($destinationAbsolutePath) > $null - } - $runspace - } - - function ExecuteExpressionImpl([string]$Expression) { - try { - $powershell = [PowerShell]::Create() - - if ($null -eq $constrainedRunspace) { - $constrainedRunspace = NewConstrainedRunspace - } - $powershell.Runspace = $constrainedRunspace - - try { - $powershell.AddScript($Expression) > $null - $res = $powershell.Invoke() - $res - } - catch { - throw ($LocalizedData.ExpressionInvalid_F2 -f $Expression,$_) - } - - # Check for non-terminating errors. - if ($powershell.Streams.Error.Count -gt 0) { - $err = $powershell.Streams.Error[0] - throw ($LocalizedData.ExpressionNonTermErrors_F2 -f $Expression,$err) - } - } - finally { - if ($powershell) { - $powershell.Dispose() - } - } - } - - function InterpolateAttributeValue([string]$Value, [string]$Location) { - if ($null -eq $Value) { - return [string]::Empty - } - elseif ([string]::IsNullOrWhiteSpace($Value)) { - return $Value - } - - try { - $res = @(ExecuteExpressionImpl "`"$Value`"") - [string]$res[0] - } - catch { - throw ($LocalizedData.InterpolationError_F3 -f $Value.Trim(),$Location,$_) - } - } - - function EvaluateConditionAttribute([string]$Expression, [string]$Location) { - if ($null -eq $Expression) { - return [string]::Empty - } - elseif ([string]::IsNullOrWhiteSpace($Expression)) { - return $Expression - } - - try { - $res = @(ExecuteExpressionImpl $Expression) - [bool]$res[0] - } - catch { - throw ($LocalizedData.ExpressionInvalidCondition_F3 -f $Expression,$Location,$_) - } - } - - function EvaluateExpression([string]$Expression, [string]$Location) { - if ($null -eq $Expression) { - return [string]::Empty - } - elseif ([string]::IsNullOrWhiteSpace($Expression)) { - return $Expression - } - - try { - $res = @(ExecuteExpressionImpl $Expression) - [string]$res[0] - } - catch { - throw ($LocalizedData.ExpressionExecError_F2 -f $Location,$_) - } - } - - function EvaluateScript([string]$Script, [string]$Location) { - if ($null -eq $Script) { - return @([string]::Empty) - } - elseif ([string]::IsNullOrWhiteSpace($Script)) { - return $Script - } - - try { - $res = @(ExecuteExpressionImpl $Script) - [string[]]$res - } - catch { - throw ($LocalizedData.ExpressionExecError_F2 -f $Location,$_) - } - } - - function GetErrorLocationFileAttrVal([string]$ElementName, [string]$AttributeName) { - $LocalizedData.ExpressionErrorLocationFile_F2 -f $ElementName,$AttributeName - } - - function GetErrorLocationModifyAttrVal([string]$AttributeName) { - $LocalizedData.ExpressionErrorLocationModify_F1 -f $AttributeName - } - - function GetErrorLocationNewModManifestAttrVal([string]$AttributeName) { - $LocalizedData.ExpressionErrorLocationNewModManifest_F1 -f $AttributeName - } - - function GetErrorLocationParameterAttrVal([string]$ParameterName, [string]$AttributeName) { - $LocalizedData.ExpressionErrorLocationParameter_F2 -f $ParameterName,$AttributeName - } - - function GetErrorLocationRequireModuleAttrVal([string]$ModuleName, [string]$AttributeName) { - $LocalizedData.ExpressionErrorLocationRequireModule_F2 -f $ModuleName,$AttributeName - } - - function GetPSSnippetFunction([String]$FilePath) { - # Test if Path Exists - if (!(Test-Path $substitute -PathType Leaf)) { - throw ($LocalizedData.ErrorPathDoesNotExist_F1 -f $FilePath) - } - # Load File - return Get-Content -LiteralPath $substitute -Raw - } - - function ConvertToDestinationRelativePath($Path) { - $fullDestPath = $DestinationPath - if (![System.IO.Path]::IsPathRooted($fullDestPath)) { - $fullDestPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) - } - - $fullPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) - if (!$fullPath.StartsWith($fullDestPath, 'OrdinalIgnoreCase')) { - throw ($LocalizedData.ErrorPathMustBeUnderDestPath_F2 -f $fullPath, $fullDestPath) - } - - $fullPath.Substring($fullDestPath.Length).TrimStart('\','/') - } - - function VerifyPathIsUnderDestinationPath([ValidateNotNullOrEmpty()][string]$FullPath) { - if (![System.IO.Path]::IsPathRooted($FullPath)) { - $PSCmdlet.WriteDebug("The FullPath parameter '$FullPath' must be an absolute path.") - } - - $fullDestPath = $DestinationPath - if (![System.IO.Path]::IsPathRooted($fullDestPath)) { - $fullDestPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) - } - - if (!$FullPath.StartsWith($fullDestPath, [StringComparison]::OrdinalIgnoreCase)) { - throw ($LocalizedData.ErrorPathMustBeUnderDestPath_F2 -f $FullPath, $fullDestPath) - } - } - - function WriteContentWithEncoding([string]$path, [string[]]$content, [string]$encoding) { - if ($encoding -match '-nobom') { - $encoding,$dummy = $encoding -split '-' - - $noBomEncoding = $null - switch ($encoding) { - 'utf8' { $noBomEncoding = New-Object System.Text.UTF8Encoding($false) } - } - - if ($null -eq $content) { - $content = [string]::Empty - } - - [System.IO.File]::WriteAllLines($path, $content, $noBomEncoding) - } - else { - Set-Content -LiteralPath $path -Value $content -Encoding $encoding - } - } - - function ColorForOperation($operation) { - switch ($operation) { - $LocalizedData.OpConflict { 'Red' } - $LocalizedData.OpCreate { 'Green' } - $LocalizedData.OpForce { 'Yellow' } - $LocalizedData.OpIdentical { 'Cyan' } - $LocalizedData.OpModify { 'Magenta' } - $LocalizedData.OpUpdate { 'Green' } - $LocalizedData.OpMissing { 'Red' } - $LocalizedData.OpVerify { 'Green' } - default { $Host.UI.RawUI.ForegroundColor } - } - } - - function GetMaxOperationLabelLength { - ($LocalizedData.OpCreate, $LocalizedData.OpIdentical, - $LocalizedData.OpConflict, $LocalizedData.OpForce, - $LocalizedData.OpMissing, $LocalizedData.OpModify, - $LocalizedData.OpUpdate, $LocalizedData.OpVerify | - Measure-Object -Property Length -Maximum).Maximum - } - - function WriteOperationStatus($operation, $message) { - $maxLen = GetMaxOperationLabelLength - Write-Host ("{0,$maxLen} " -f $operation) -ForegroundColor (ColorForOperation $operation) -NoNewline - Write-Host $message - } - - function WriteOperationAdditionalStatus([string[]]$Message) { - $maxLen = GetMaxOperationLabelLength - foreach ($msg in $Message) { - $lines = $msg -split "`n" - foreach ($line in $lines) { - Write-Host ("{0,$maxLen} {1}" -f "",$line) - } - } - } - - function GetGitConfigValue($name) { - # Very simplistic git config lookup - # Won't work with namespace, just use final element, e.g. 'name' instead of 'user.name' - - # The $Home dir may not be reachable e.g. if on network share and/or script not running as admin. - # See issue https://github.com/PowerShell/Plaster/issues/92 - if (!(Test-Path -LiteralPath $Home)) { - return - } - - $gitConfigPath = Join-Path $Home '.gitconfig' - $PSCmdlet.WriteDebug("Looking for '$name' value in Git config: $gitConfigPath") - - if (Test-Path -LiteralPath $gitConfigPath) { - $matches = Select-String -LiteralPath $gitConfigPath -Pattern "\s+$name\s+=\s+(.+)$" - if (@($matches).Count -gt 0) - { - $matches.Matches.Groups[1].Value - } - } - } - - function PromptForInput($prompt, $default, $pattern) { - if (!$pattern) { - $patternMatch = $true - } - - do { - $value = Read-Host -Prompt $prompt - if (!$value -and $default) { - $value = $default - $patternMatch = $true - } - elseif ($value -and $pattern) { - if ($value -match $pattern) { - $patternMatch = $true - } - else { - $PSCmdlet.WriteDebug("Value '$value' did not match the pattern '$pattern'") - } - } - } while (!$value -or !$patternMatch) - - $value - } - - function PromptForChoice([string]$ParameterName, [ValidateNotNull()]$ChoiceNodes, [string]$prompt, - [int[]]$defaults, [switch]$IsMultiChoice) { - $choices = New-Object 'System.Collections.ObjectModel.Collection[System.Management.Automation.Host.ChoiceDescription]' - $values = New-Object object[] $ChoiceNodes.Count - $i = 0 - - foreach ($choiceNode in $ChoiceNodes) { - $label = InterpolateAttributeValue $choiceNode.label (GetErrorLocationParameterAttrVal $ParameterName label) - $help = InterpolateAttributeValue $choiceNode.help (GetErrorLocationParameterAttrVal $ParameterName help) - $value = InterpolateAttributeValue $choiceNode.value (GetErrorLocationParameterAttrVal $ParameterName value) - - $choice = New-Object System.Management.Automation.Host.ChoiceDescription -Arg $label,$help - $choices.Add($choice) - $values[$i++] = $value - } - - $retval = [PSCustomObject]@{Values=@(); Indices=@()} - - if ($IsMultiChoice) { - $selections = $Host.UI.PromptForChoice('', $prompt, $choices, $defaults) - foreach ($selection in $selections) { - $retval.Values += $values[$selection] - $retval.Indices += $selection - } - } - else { - if ($defaults.Count -gt 1) { - throw ($LocalizedData.ParameterTypeChoiceMultipleDefault_F1 -f $ChoiceNodes.ParentNode.name) - } - - $selection = $Host.UI.PromptForChoice('', $prompt, $choices, $defaults[0]) - $retval.Values = $values[$selection] - $retval.Indices = $selection - } - - $retval - } - - # All Plaster variables should be set via this method so that the ConstrainedRunspace can be - # configured to use the new variable. This method will null out the ConstrainedRunspace so that - # later, when we need to evaluate script in that runspace, it will get recreated first with all - # the latest Plaster variables. - function SetPlasterVariable() { - param( - [Parameter(Mandatory=$true)] - [ValidateNotNullOrEmpty()] - [string]$Name, - - [Parameter(Mandatory=$true)] - [AllowNull()] - $Value, - - [Parameter()] - [bool] - $IsParam = $true - ) - - # Variables created from a in the Plaster manifset are prefixed PLASTER_PARAM all others - # are just PLASTER_. - $variableName = if ($IsParam) { "PLASTER_PARAM_$Name" } else { "PLASTER_$Name" } - - Set-Variable -Name $variableName -Value $Value -Scope Script -WhatIf:$false - - # If the constrained runspace has been created, it needs to be disposed so that the next string - # expansion (or condition eval) gets an updated runspace that contains this variable or its new value. - if ($null -ne $script:ConstrainedRunspace) { - $script:ConstrainedRunspace.Dispose() - $script:ConstrainedRunspace = $null - } - } - - function ProcessParameter([ValidateNotNull()]$Node) { - $name = $Node.name - $type = $Node.type - $store = $Node.store - - $pattern = $Node.pattern - - $condition = $Node.condition - - $default = InterpolateAttributeValue $Node.default (GetErrorLocationParameterAttrVal $name default) - - if ($condition -and !(EvaluateConditionAttribute $condition "'<$($Node.LocalName)>'")) { - if (-not [string]::IsNullOrEmpty($default) -and $type -eq 'text') { - SetPlasterVariable -Name $name -Value $default -IsParam $true - $PSCmdlet.WriteDebug("The condition of the parameter $($name) with the type 'text' evaluated to false. The parameter has a default value which will be used.") - } - else { - # Define the parameter so later conditions can use it but its value will be $null - SetPlasterVariable -Name $name -Value $null -IsParam $true - $PSCmdlet.WriteDebug("Skipping parameter $($name), condition evaluated to false.") - } - - return - } - - $prompt = InterpolateAttributeValue $Node.prompt (GetErrorLocationParameterAttrVal $name prompt) - - # Check if parameter was provided via a dynamic parameter. - if ($boundParameters.ContainsKey($name)) { - $value = $boundParameters[$name] - } - else { - # Not a dynamic parameter so prompt user for the value but first check for a stored default value. - if ($store -and ($null -ne $defaultValueStore[$name])) { - $default = $defaultValueStore[$name] - $PSCmdlet.WriteDebug("Read default value '$default' for parameter '$name' from default value store.") - - if (($store -eq 'encrypted') -and ($default -is [System.Security.SecureString])) { - try { - $cred = New-Object -TypeName PSCredential -ArgumentList 'jsbplh',$default - $default = $cred.GetNetworkCredential().Password - $PSCmdlet.WriteDebug("Unencrypted default value for parameter '$name'.") - } - catch [System.Exception] { - Write-Warning ($LocalizedData.ErrorUnencryptingSecureString_F1 -f $name) - } - } - } - - # If the prompt message failed to evaluate or was empty, supply a diagnostic prompt message - if (!$prompt) { - $prompt = $LocalizedData.MissingParameterPrompt_F1 -f $name - } - - # Some default values might not come from the template e.g. some are harvested from .gitconfig if it exists. - $defaultNotFromTemplate = $false - - $splat = @{} - - if ($null -ne $pattern) { - $splat.Add('pattern', $pattern) - } - - # Now prompt user for parameter value based on the parameter type. - switch -regex ($type) { - 'text' { - # Display an appropriate "default" value in the prompt string. - if ($default) { - if ($store -eq 'encrypted') { - $obscuredDefault = $default -replace '(....).*', '$1****' - $prompt += " ($obscuredDefault)" - } - else { - $prompt += " ($default)" - } - } - # Prompt the user for text input. - $value = PromptForInput $prompt $default @splat - $valueToStore = $value - } - 'user-fullname' { - # If no default, try to get a name from git config. - if (!$default) { - $default = GetGitConfigValue('name') - $defaultNotFromTemplate = $true - } - - if ($default) { - if ($store -eq 'encrypted') { - $obscuredDefault = $default -replace '(....).*', '$1****' - $prompt += " ($obscuredDefault)" - } - else { - $prompt += " ($default)" - } - } - - # Prompt the user for text input. - $value = PromptForInput $prompt $default @splat - $valueToStore = $value - } - 'user-email' { - # If no default, try to get an email from git config - if (-not $default) { - $default = GetGitConfigValue('email') - $defaultNotFromTemplate = $true - } - - if ($default) { - if ($store -eq 'encrypted') { - $obscuredDefault = $default -replace '(....).*', '$1****' - $prompt += " ($obscuredDefault)" - } - else { - $prompt += " ($default)" - } - } - - # Prompt the user for text input. - $value = PromptForInput $prompt $default @splat - $valueToStore = $value - } - 'choice|multichoice' { - $choices = $Node.ChildNodes - $defaults = [int[]]($default -split ',') - - # Prompt the user for choice or multichoice selection input. - $selections = PromptForChoice $name $choices $prompt $defaults -IsMultiChoice:($type -eq 'multichoice') - $value = $selections.Values - $OFS = "," - $valueToStore = "$($selections.Indices)" - } - default { throw ($LocalizedData.UnrecognizedParameterType_F2 -f $type, $Node.LocalName) } - } - - # If parameter specifies that user's input be stored as the default value, - # store it to file if the value has changed. - if ($store -and (($default -ne $valueToStore) -or $defaultNotFromTemplate)) { - if ($store -eq 'encrypted') { - $PSCmdlet.WriteDebug("Storing new, encrypted default value for parameter '$name' to default value store.") - $defaultValueStore[$name] = ConvertTo-SecureString -String $valueToStore -AsPlainText -Force - } - else { - $PSCmdlet.WriteDebug("Storing new default value '$valueToStore' for parameter '$name' to default value store.") - $defaultValueStore[$name] = $valueToStore - } - - $flags.DefaultValueStoreDirty = $true - } - } - - # Make template defined parameters available as a PowerShell variable PLASTER_PARAM_. - SetPlasterVariable -Name $name -Value $value -IsParam $true - } - - function ProcessMessage([ValidateNotNull()]$Node) { - $text = InterpolateAttributeValue $Node.InnerText '' - $nonewline = $Node.nonewline -eq 'true' - - # Eliminate whitespace before and after the text that just happens to get inserted because you want - # the text on different lines than the start/end element tags. - $trimmedText = $text -replace '^[ \t]*\n','' -replace '\n[ \t]*$','' - - $condition = $Node.condition - if ($condition -and !(EvaluateConditionAttribute $condition "'<$($Node.LocalName)>'")) { - $debugText = $trimmedText -replace '\r|\n',' ' - $maxLength = [Math]::Min(40, $debugText.Length) - $PSCmdlet.WriteDebug("Skipping message '$($debugText.Substring(0, $maxLength))', condition evaluated to false.") - return - } - - Write-Host $trimmedText -NoNewline:($nonewline -eq 'true') - } - - function ProcessNewModuleManifest([ValidateNotNull()]$Node) { - $moduleVersion = InterpolateAttributeValue $Node.moduleVersion (GetErrorLocationNewModManifestAttrVal moduleVersion) - $rootModule = InterpolateAttributeValue $Node.rootModule (GetErrorLocationNewModManifestAttrVal rootModule) - $author = InterpolateAttributeValue $Node.author (GetErrorLocationNewModManifestAttrVal author) - $companyName = InterpolateAttributeValue $Node.companyName (GetErrorLocationNewModManifestAttrVal companyName) - $description = InterpolateAttributeValue $Node.description (GetErrorLocationNewModManifestAttrVal description) - $dstRelPath = InterpolateAttributeValue $Node.destination (GetErrorLocationNewModManifestAttrVal destination) - $powerShellVersion = InterpolateAttributeValue $Node.powerShellVersion (GetErrorLocationNewModManifestAttrVal powerShellVersion) - $nestedModules = InterpolateAttributeValue $Node.NestedModules (GetErrorLocationNewModManifestAttrVal NestedModules) - $dscResourcesToExport = InterpolateAttributeValue $Node.DscResourcesToExport (GetErrorLocationNewModManifestAttrVal DscResourcesToExport) - $copyright = InterpolateAttributeValue $Node.copyright (GetErrorLocationNewModManifestAttrVal copyright) - - # We could choose to not check this if the condition eval'd to false - # but I think it is better to let the template author know they've broken the - # rules for any of the file directives (not just the ones they're testing/enabled). - if ([System.IO.Path]::IsPathRooted($dstRelPath)) { - throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $dstRelPath,$Node.LocalName) - } - - $dstPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath((Join-Path $DestinationPath $dstRelPath)) - - $condition = $Node.condition - if ($condition -and !(EvaluateConditionAttribute $condition "'<$($Node.LocalName)>'")) { - $PSCmdlet.WriteDebug("Skipping module manifest generation for '$dstPath', condition evaluated to false.") - return - } - - $encoding = $Node.encoding - if (!$encoding) { - $encoding = $DefaultEncoding - } - - if ($PSCmdlet.ShouldProcess($dstPath, $LocalizedData.ShouldProcessNewModuleManifest)) { - $manifestDir = Split-Path $dstPath -Parent - if (!(Test-Path $manifestDir)) { - VerifyPathIsUnderDestinationPath $manifestDir - Write-Verbose ($LocalizedData.NewModManifest_CreatingDir_F1 -f $manifestDir) - New-Item $manifestDir -ItemType Directory > $null - } - - $newModuleManifestParams = @{} - - # If there is an existing module manifest, load it so we can reuse old values not specified by - # template. - if (Test-Path -LiteralPath $dstPath) { - $manifestFileName = Split-Path $dstPath -leaf - $newModuleManifestParams = Import-LocalizedData -BaseDirectory $manifestDir -FileName $manifestFileName - if ($newModuleManifestParams.PrivateData) { - $newModuleManifestParams += $newModuleManifestParams.PrivateData.psdata - $newModuleManifestParams.Remove('PrivateData') - } - } - - if (![string]::IsNullOrWhiteSpace($moduleVersion)) { - $newModuleManifestParams['ModuleVersion'] = $moduleVersion - } - if (![string]::IsNullOrWhiteSpace($rootModule)) { - $newModuleManifestParams['RootModule'] = $rootModule - } - if (![string]::IsNullOrWhiteSpace($author)) { - $newModuleManifestParams['Author'] = $author - } - if (![string]::IsNullOrWhiteSpace($companyName)) { - $newModuleManifestParams['CompanyName'] = $companyName - } - if (![string]::IsNullOrWhiteSpace($copyright)) { - $newModuleManifestParams['Copyright'] = $copyright - } - if (![string]::IsNullOrWhiteSpace($description)) { - $newModuleManifestParams['Description'] = $description - } - if (![string]::IsNullOrWhiteSpace($powerShellVersion)) { - $newModuleManifestParams['PowerShellVersion'] = $powerShellVersion - } - if (![string]::IsNullOrWhiteSpace($nestedModules)) { - $newModuleManifestParams['NestedModules'] = $nestedModules - } - if (![string]::IsNullOrWhiteSpace($dscResourcesToExport)) { - $newModuleManifestParams['DscResourcesToExport'] = $dscResourcesToExport - } - - $tempFile = $null - - try { - $tempFileBaseName = "moduleManifest-" + [Guid]::NewGuid() - $tempFile = [System.IO.Path]::GetTempPath() + "${tempFileBaseName}.psd1" - $PSCmdlet.WriteDebug("Created temp file for new module manifest - $tempFile") - $newModuleManifestParams['Path'] = $tempFile - - # Generate manifest into a temp file. - New-ModuleManifest @newModuleManifestParams - - # Typically the manifest is re-written with a new encoding (UTF8-NoBOM) because Git hates UTF-16. - $content = Get-Content -LiteralPath $tempFile -Raw - - # Replace the temp filename in the generated manifest file's comment header with the actual filename. - $dstBaseName = [System.IO.Path]::GetFileNameWithoutExtension($dstPath) - $content = $content -replace "(?<=\s*#.*?)$tempFileBaseName", $dstBaseName - - WriteContentWithEncoding -Path $tempFile -Content $content -Encoding $encoding - - CopyFileWithConflictDetection $tempFile $dstPath - - if ($PassThru -and ($Node.openInEditor -eq 'true')) { - $InvokePlasterInfo.OpenFiles += $dstPath - } - } - finally { - if ($tempFile -and (Test-Path $tempFile)) { - Remove-Item -LiteralPath $tempFile - $PSCmdlet.WriteDebug("Removed temp file for new module manifest - $tempFile") - } - } - } - } - - # - # Begin ProcessFile helper methods - # - function NewBackupFilename([string]$Path) { - $dir = [System.IO.Path]::GetDirectoryName($Path) - $filename = [System.IO.Path]::GetFileName($Path) - $backupPath = Join-Path -Path $dir -ChildPath "${filename}.bak" - $i = 1; - while (Test-Path -LiteralPath $backupPath) { - $backupPath = Join-Path -Path $dir -ChildPath "${filename}.bak$i" - $i++ - } - - $backupPath - } - - function AreFilesIdentical($Path1, $Path2) { - $file1 = Get-Item -LiteralPath $Path1 -Force - $file2 = Get-Item -LiteralPath $Path2 -Force - - if ($file1.Length -ne $file2.Length) { - return $false - } - - $hash1 = (Get-FileHash -LiteralPath $path1 -Algorithm SHA1).Hash - $hash2 = (Get-FileHash -LiteralPath $path2 -Algorithm SHA1).Hash - - $hash1 -eq $hash2 - } - - function NewFileSystemCopyInfo([string]$srcPath, [string]$dstPath) { - [PSCustomObject]@{SrcFileName=$srcPath; DstFileName=$dstPath} - } - - function ExpandFileSourceSpec([string]$srcRelPath, [string]$dstRelPath) { - $srcPath = Join-Path $templateAbsolutePath $srcRelPath - $dstPath = Join-Path $destinationAbsolutePath $dstRelPath - - if ($srcRelPath.IndexOfAny([char[]]('*','?')) -lt 0) { - # No wildcard spec in srcRelPath so return info on single file. - # Also, if dstRelPath is empty, then use source rel path. - if (!$dstRelPath) { - $dstPath = Join-Path $destinationAbsolutePath $srcRelPath - } - - return NewFileSystemCopyInfo $srcPath $dstPath - } - - # Prepare parameter values for call to Get-ChildItem to get list of files based on wildcard spec. - $gciParams = @{} - $parent = Split-Path $srcPath -Parent - $leaf = Split-Path $srcPath -Leaf - $gciParams['LiteralPath'] = $parent - $gciParams['File'] = $true - - if ($leaf -eq '**') { - $gciParams['Recurse'] = $true - } - else { - if ($leaf.IndexOfAny([char[]]('*','?')) -ge 0) { - $gciParams['Filter'] = $leaf - } - - $leaf = Split-Path $parent -Leaf - if ($leaf -eq '**') { - $parent = Split-Path $parent -Parent - $gciParams['LiteralPath'] = $parent - $gciParams['Recurse'] = $true - } - } - - $srcRelRootPathLength = $gciParams['LiteralPath'].Length - - # Generate a FileCopyInfo object for every file expanded by the wildcard spec. - $files = @(Microsoft.PowerShell.Management\Get-ChildItem @gciParams) - foreach ($file in $files) { - $fileSrcPath = $file.FullName - $relPath = $fileSrcPath.Substring($srcRelRootPathLength) - $fileDstPath = Join-Path $dstPath $relPath - NewFileSystemCopyInfo $fileSrcPath $fileDstPath - } - - # Copy over empty directories - if any. - $gciParams.Remove('File') - $gciParams['Directory'] = $true - $dirs = @(Microsoft.PowerShell.Management\Get-ChildItem @gciParams | - Where-Object {$_.GetFileSystemInfos().Length -eq 0}) - foreach ($dir in $dirs) { - $dirSrcPath = $dir.FullName - $relPath = $dirSrcPath.Substring($srcRelRootPathLength) - $dirDstPath = Join-Path $dstPath $relPath - NewFileSystemCopyInfo $dirSrcPath $dirDstPath - } - } - - # Plaster zen for file handling. All file related operations should use this method - # to actually write/overwrite/modify files in the DestinationPath. This method - # handles detecting conflicts, gives the user a chance to determine how to handle - # conflicts. The user can choose to use the Force parameter to force the overwriting - # of existing files at the destination path. - # File processing (expanding substitution variable, modifying file contents) should always - # be done to a temp file (be sure to always remove temp file when done). That temp file - # is what gets passed to this function as the $SrcPath. This allows Plaster to alert the - # user when the repeated application of a template will modify any existing file. - # NOTE: Plaster keeps track of which files it has "created" (as opposed to overwritten) - # so that any later change to that file doesn't trigger conflict handling. - function CopyFileWithConflictDetection([string]$SrcPath, [string]$DstPath) { - # Just double-checking that DstPath parameter is an absolute path otherwise - # it could fail the check that the DstPath is under the overall DestinationPath. - if (![System.IO.Path]::IsPathRooted($DstPath)) { - $DstPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DstPath) - } - - # Check if DstPath file conflicts with an existing SrcPath file. - $operation = $LocalizedData.OpCreate - $opmessage = (ConvertToDestinationRelativePath $DstPath) - if (Test-Path -LiteralPath $DstPath) { - if (AreFilesIdentical $SrcPath $DstPath) { - $operation = $LocalizedData.OpIdentical - } - elseif ($templateCreatedFiles.ContainsKey($DstPath)) { - # Plaster created this file previously during template invocation - # therefore, there is no conflict. We're simply updating the file. - $operation = $LocalizedData.OpUpdate - } - elseif ($Force) { - $operation = $LocalizedData.OpForce - } - else { - $operation = $LocalizedData.OpConflict - } - } - - # Copy the file to the destination - if ($PSCmdlet.ShouldProcess($DstPath, $operation)) { - WriteOperationStatus $operation $opmessage - - if ($operation -eq $LocalizedData.OpIdentical) { - # If the files are identical, no need to do anything - return - } - - if (($operation -eq $LocalizedData.OpCreate) -or ($operation -eq $LocalizedData.OpUpdate)) { - Copy-Item -LiteralPath $SrcPath -Destination $DstPath - if ($PassThru) { - $InvokePlasterInfo.CreatedFiles += $DstPath - } - $templateCreatedFiles[$DstPath] = $null - } - elseif ($Force -or $PSCmdlet.ShouldContinue(($LocalizedData.OverwriteFile_F1 -f $DstPath), - $LocalizedData.FileConflict, - [ref]$fileConflictConfirmYesToAll, - [ref]$fileConflictConfirmNoToAll)) { - $backupFilename = NewBackupFilename $DstPath - Copy-Item -LiteralPath $DstPath -Destination $backupFilename - Copy-Item -LiteralPath $SrcPath -Destination $DstPath - if ($PassThru) { - $InvokePlasterInfo.UpdatedFiles += $DstPath - } - $templateCreatedFiles[$DstPath] = $null - } - } - } - - # - # End ProcessFile helper methods - # - - # Processes both the and directives. - function ProcessFile([ValidateNotNull()]$Node) { - $srcRelPath = InterpolateAttributeValue $Node.source (GetErrorLocationFileAttrVal $Node.localName source) - $dstRelPath = InterpolateAttributeValue $Node.destination (GetErrorLocationFileAttrVal $Node.localName destination) - - $condition = $Node.condition - if ($condition -and !(EvaluateConditionAttribute $condition "'<$($Node.LocalName)>'")) { - $PSCmdlet.WriteDebug("Skipping $($Node.localName) '$srcRelPath' -> '$dstRelPath', condition evaluated to false.") - return - } - - # Only validate paths for conditions that evaluate to true. - # The path may not be valid if it evaluates to false depending - # on whether or not conditional parameters are used in the template. - if ([System.IO.Path]::IsPathRooted($srcRelPath)) { - throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $srcRelPath,$Node.LocalName) - } - - if ([System.IO.Path]::IsPathRooted($dstRelPath)) { - throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $dstRelPath,$Node.LocalName) - } - - # Check if node is the specialized, node. - # Only nodes expand templates and use the encoding attribute. - $isTemplateFile = $Node.localName -eq 'templateFile' - if ($isTemplateFile) { - $encoding = $Node.encoding - if (!$encoding) { - $encoding = $DefaultEncoding - } - } - - # Check if source specifies a wildcard and if so, expand the wildcard - # and then process each file system object (file or empty directory). - $fileSystemCopyInfoObjs = ExpandFileSourceSpec $srcRelPath $dstRelPath - foreach ($fileSystemCopyInfo in $fileSystemCopyInfoObjs) { - $srcPath = $fileSystemCopyInfo.SrcFileName - $dstPath = $fileSystemCopyInfo.DstFileName - - # The file's destination path must be under the DestinationPath specified by the user. - VerifyPathIsUnderDestinationPath $dstPath - - # Check to see if we're copying an empty dir - if (Test-Path -LiteralPath $srcPath -PathType Container) { - if (!(Test-Path -LiteralPath $dstPath)) { - if ($PSCmdlet.ShouldProcess($parentDir, $LocalizedData.ShouldProcessCreateDir)) { - WriteOperationStatus $LocalizedData.OpCreate ` - ($dstRelPath.TrimEnd(([char]'\'),([char]'/')) + [System.IO.Path]::DirectorySeparatorChar) - New-Item -Path $dstPath -ItemType Directory > $null - } - } - - continue - } - - # If the file's parent dir doesn't exist, create it. - $parentDir = Split-Path $dstPath -Parent - if (!(Test-Path -LiteralPath $parentDir)) { - if ($PSCmdlet.ShouldProcess($parentDir, $LocalizedData.ShouldProcessCreateDir)) { - New-Item -Path $parentDir -ItemType Directory > $null - } - } - - $tempFile = $null - - try { - # If processing a , copy to a temp file to expand the template file, - # then apply the normal file conflict detection/resolution handling. - $target = $LocalizedData.TempFileTarget_F1 -f (ConvertToDestinationRelativePath $dstPath) - if ($isTemplateFile -and $PSCmdlet.ShouldProcess($target, $LocalizedData.ShouldProcessExpandTemplate)) { - $content = Get-Content -LiteralPath $srcPath -Raw - - # Eval script expression delimiters - if ($content -and ($content.Count -gt 0)) { - $newContent = [regex]::Replace($content, '(<%=)(.*?)(%>)', { - param($match) - $expr = $match.groups[2].value - $res = EvaluateExpression $expr "templateFile '$srcRelPath'" - $PSCmdlet.WriteDebug("Replacing '$expr' with '$res' in contents of template file '$srcPath'") - $res - }, @('IgnoreCase')) - - # Eval script block delimiters - $newContent = [regex]::Replace($newContent, '(^<%)(.*?)(^%>)', { - param($match) - $expr = $match.groups[2].value - $res = EvaluateScript $expr "templateFile '$srcRelPath'" - $res = $res -join [System.Environment]::NewLine - $PSCmdlet.WriteDebug("Replacing '$expr' with '$res' in contents of template file '$srcPath'") - $res - }, @('IgnoreCase', 'SingleLine', 'MultiLine')) - - $srcPath = $tempFile = [System.IO.Path]::GetTempFileName() - $PSCmdlet.WriteDebug("Created temp file for expanded templateFile - $tempFile") - - WriteContentWithEncoding -Path $tempFile -Content $newContent -Encoding $encoding - } - else { - $PSCmdlet.WriteDebug("Skipping template file expansion for $($Node.localName) '$srcPath', file is empty.") - } - } - - CopyFileWithConflictDetection $srcPath $dstPath - - if ($PassThru -and ($Node.openInEditor -eq 'true')) { - $InvokePlasterInfo.OpenFiles += $dstPath - } - } - finally { - if ($tempFile -and (Test-Path $tempFile)) { - Remove-Item -LiteralPath $tempFile - $PSCmdlet.WriteDebug("Removed temp file for expanded templateFile - $tempFile") - } - } - } - } - - function ProcessModifyFile([ValidateNotNull()]$Node) { - $path = InterpolateAttributeValue $Node.path (GetErrorLocationModifyAttrVal path) - - # We could choose to not check this if the condition eval'd to false - # but I think it is better to let the template author know they've broken the - # rules for any of the file directives (not just the ones they're testing/enabled). - if ([System.IO.Path]::IsPathRooted($path)) { - throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $path,$Node.LocalName) - } - - $filePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath((Join-Path $DestinationPath $path)) - - # The file's path must be under the DestinationPath specified by the user. - VerifyPathIsUnderDestinationPath $filePath - - $condition = $Node.condition - if ($condition -and !(EvaluateConditionAttribute $condition "'<$($Node.LocalName)>'")) { - $PSCmdlet.WriteDebug("Skipping $($Node.LocalName) of '$filePath', condition evaluated to false.") - return - } - - $fileContent = [string]::Empty - if (Test-Path -LiteralPath $filePath) { - $fileContent = Get-Content -LiteralPath $filePath -Raw - } - - # Set a Plaster (non-parameter) variable in this and the constrained runspace. - SetPlasterVariable -Name FileContent -Value $fileContent -IsParam $false - - $encoding = $Node.encoding - if (!$encoding) { - $encoding = $DefaultEncoding - } - - # If processing a directive, write the modified contents to a temp file, - # then apply the normal file conflict detection/resolution handling. - $target = $LocalizedData.TempFileTarget_F1 -f $filePath - if ($PSCmdlet.ShouldProcess($target, $LocalizedData.OpModify)) { - WriteOperationStatus $LocalizedData.OpModify ($LocalizedData.TempFileOperation_F1 -f (ConvertToDestinationRelativePath $filePath)) - - $modified = $false - - foreach ($childNode in $Node.ChildNodes) { - if ($childNode -isnot [System.Xml.XmlElement]) { continue } - - switch ($childNode.LocalName) { - 'replace' { - $condition = $childNode.condition - if ($condition -and !(EvaluateConditionAttribute $condition "'<$($Node.LocalName)><$($childNode.LocalName)>'")) { - $PSCmdlet.WriteDebug("Skipping $($Node.LocalName) $($childNode.LocalName) of '$filePath', condition evaluated to false.") - continue - } - - if ($childNode.original -is [string]) { - $original = $childNode.original - } - else { - $original = $childNode.original.InnerText - } - - if ($childNode.original.expand -eq 'true') { - $original = InterpolateAttributeValue $original (GetErrorLocationModifyAttrVal original) - } - - if ($childNode.substitute -is [string]) { - $substitute = $childNode.substitute - } - else { - $substitute = $childNode.substitute.InnerText - } - - if ($childNode.substitute.isFile -eq 'true') { - $substitute = GetPSSnippetFunction $substitute - } elseif ($childNode.substitute.expand -eq 'true') { - $substitute = InterpolateAttributeValue $substitute (GetErrorLocationModifyAttrVal substitute) - } - - # Perform Literal Replacement on FileContent (since it will have regex characters) - if ($childNode.substitute.isFile) { - $fileContent = $fileContent.Replace($original,$substitute) - } else { - $fileContent = $fileContent -replace $original,$substitute - } - - # Update the Plaster (non-parameter) variable's value in this and the constrained runspace. - SetPlasterVariable -Name FileContent -Value $fileContent -IsParam $false - - $modified = $true - } - default { throw ($LocalizedData.UnrecognizedContentElement_F1 -f $childNode.LocalName) } - } - } - - $tempFile = $null - - try { - # We could use CopyFileWithConflictDetection to handle the "identical" (not modified) case - # but if nothing was changed, I'd prefer not to generate a temp file, copy the unmodified contents - # into that temp file with hopefully the right encoding and then potentially overwrite the original file - # (different encoding will make the files look different) with the same contents but different encoding. - # If the intent of the was simply to change an existing file's encoding then the directive will - # need to make a whitespace change to the file. - if ($modified) { - $tempFile = [System.IO.Path]::GetTempFileName() - $PSCmdlet.WriteDebug("Created temp file for modified file - $tempFile") - - WriteContentWithEncoding -Path $tempFile -Content $PLASTER_FileContent -Encoding $encoding - CopyFileWithConflictDetection $tempFile $filePath - - if ($PassThru -and ($Node.openInEditor -eq 'true')) { - $InvokePlasterInfo.OpenFiles += $filePath - } - } - else { - WriteOperationStatus $LocalizedData.OpIdentical (ConvertToDestinationRelativePath $filePath) - } - } - finally { - if ($tempFile -and (Test-Path $tempFile)) { - Remove-Item -LiteralPath $tempFile - $PSCmdlet.WriteDebug("Removed temp file for modified file - $tempFile") - } - } - } - } - - function ProcessRequireModule([ValidateNotNull()]$Node) { - $name = $Node.name - - $condition = $Node.condition - if ($condition -and !(EvaluateConditionAttribute $condition "'<$($Node.LocalName)>'")) { - $PSCmdlet.WriteDebug("Skipping $($Node.localName) for module '$name', condition evaluated to false.") - return - } - - $message = InterpolateAttributeValue $Node.message (GetErrorLocationRequireModuleAttrVal $name message) - $minimumVersion = $Node.minimumVersion - $maximumVersion = $Node.maximumVersion - $requiredVersion = $Node.requiredVersion - - $getModuleParams = @{ - ListAvailable = $true - ErrorAction = 'SilentlyContinue' - } - - # Configure $getModuleParams with correct parameters based on parameterset to be used. - # Also construct an array of version strings that can be displayed to the user. - $versionInfo = @() - if ($requiredVersion) { - $getModuleParams["FullyQualifiedName"] = @{ModuleName = $name; RequiredVersion = $requiredVersion} - $versionInfo += $LocalizedData.RequireModuleRequiredVersion_F1 -f $requiredVersion - } - elseif ($minimumVersion -or $maximumVersion) { - $getModuleParams["FullyQualifiedName"] = @{ModuleName = $name} - - if ($minimumVersion) { - $getModuleParams.FullyQualifiedName["ModuleVersion"] = $minimumVersion - $versionInfo += $LocalizedData.RequireModuleMinVersion_F1 -f $minimumVersion - } - if ($maximumVersion) { - $getModuleParams.FullyQualifiedName["MaximumVersion"] = $maximumVersion - $versionInfo += $LocalizedData.RequireModuleMaxVersion_F1 -f $maximumVersion - } - } - else { - $getModuleParams["Name"] = $name - } - - # Flatten array of version strings into a single string. - $versionRequirements = "" - if ($versionInfo.Length -gt 0) { - $OFS = ", " - $versionRequirements = " ($versionInfo)" - } - - # PowerShell v3 Get-Module command does not have the FullyQualifiedName parameter. - if ($PSVersionTable.PSVersion.Major -lt 4) { - $getModuleParams.Remove("FullyQualifiedName") - $getModuleParams["Name"] = $name - } - - $module = Get-Module @getModuleParams - - $moduleDesc = if ($versionRequirements) { "${name}:$versionRequirements" } else { $name } - - if ($null -eq $module) { - WriteOperationStatus $LocalizedData.OpMissing ($LocalizedData.RequireModuleMissing_F2 -f $name,$versionRequirements) - if ($message) { - WriteOperationAdditionalStatus $message - } - if ($PassThru) { - $InvokePlasterInfo.MissingModules += $moduleDesc - } - } - else { - if ($PSVersionTable.PSVersion.Major -gt 3) { - WriteOperationStatus $LocalizedData.OpVerify ($LocalizedData.RequireModuleVerified_F2 -f $name,$versionRequirements) - } - else { - # On V3, we have to the version matching with the results that Get-Module return. - $installedVersion = $module | Sort-Object Version -Descending | Select-Object -First 1 | Foreach-Object Version - if ($installedVersion.Build -eq -1) { - $installedVersion = [System.Version]"${installedVersion}.0.0" - } - elseif ($installedVersion.Revision -eq -1) { - $installedVersion = [System.Version]"${installedVersion}.0" - } - - if (($requiredVersion -and ($installedVersion -ne $requiredVersion)) -or - ($minimumVersion -and ($installedVersion -lt $minimumVersion)) -or - ($maximumVersion -and ($installedVersion -gt $maximumVersion))) { - - WriteOperationStatus $LocalizedData.OpMissing ($LocalizedData.RequireModuleMissing_F2 -f $name,$versionRequirements) - if ($PassThru) { - $InvokePlasterInfo.MissingModules += $moduleDesc - } - } - else { - WriteOperationStatus $LocalizedData.OpVerify ($LocalizedData.RequireModuleVerified_F2 -f $name,$versionRequirements) - } - } - } - } - } - - end { - try { - # Process parameters - foreach ($node in $manifest.plasterManifest.parameters.ChildNodes) { - if ($node -isnot [System.Xml.XmlElement]) { continue } - switch ($node.LocalName) { - 'parameter' { ProcessParameter $node } - default { throw ($LocalizedData.UnrecognizedParametersElement_F1 -f $node.LocalName) } - } - } - - # Outputs the processed template parameters to the debug stream - $parameters = Get-Variable -Name PLASTER_* | Out-String - $PSCmdlet.WriteDebug("Parameter values are:`n$($parameters -split "`n")") - - # Stores any updated default values back to the store file. - if ($flags.DefaultValueStoreDirty) { - $directory = Split-Path $defaultValueStorePath -Parent - if (!(Test-Path $directory)) { - $PSCmdlet.WriteDebug("Creating directory for template's DefaultValueStore '$directory'.") - New-Item $directory -ItemType Directory > $null - } - - $PSCmdlet.WriteDebug("DefaultValueStore is dirty, saving updated values to '$defaultValueStorePath'.") - $defaultValueStore | Export-Clixml -LiteralPath $defaultValueStorePath - } - - # Output the DestinationPath - Write-Host ($LocalizedData.DestPath_F1 -f $destinationAbsolutePath) - - # Process content - foreach ($node in $manifest.plasterManifest.content.ChildNodes) { - if ($node -isnot [System.Xml.XmlElement]) { continue } - - switch -Regex ($node.LocalName) { - 'file|templateFile' { ProcessFile $node; break } - 'message' { ProcessMessage $node; break } - 'modify' { ProcessModifyFile $node; break } - 'newModuleManifest' { ProcessNewModuleManifest $node; break } - 'requireModule' { ProcessRequireModule $node; break } - default { throw ($LocalizedData.UnrecognizedContentElement_F1 -f $node.LocalName) } - } - } - - if ($PassThru) { - $InvokePlasterInfo.Success = $true - $InvokePlasterInfo - } - } - finally { - # Dispose of the ConstrainedRunspace. - if ($constrainedRunspace) { - $constrainedRunspace.Dispose() - $constrainedRunspace = $null - } - } - } -} - -############################################################################### -# Helper functions -############################################################################### - -function InitializePredefinedVariables([string]$TemplatePath, [string]$DestPath) { - # Always set these variables, even if the command has been run with -WhatIf - $WhatIfPreference = $false - - Set-Variable -Name PLASTER_TemplatePath -Value $TemplatePath.TrimEnd('\','/') -Scope Script - - $destName = Split-Path -Path $DestPath -Leaf - Set-Variable -Name PLASTER_DestinationPath -Value $DestPath.TrimEnd('\','/') -Scope Script - Set-Variable -Name PLASTER_DestinationName -Value $destName -Scope Script - Set-Variable -Name PLASTER_DirSepChar -Value ([System.IO.Path]::DirectorySeparatorChar) -Scope Script - Set-Variable -Name PLASTER_HostName -Value $Host.Name -Scope Script - Set-Variable -Name PLASTER_Version -Value $MyInvocation.MyCommand.Module.Version -Scope Script - - Set-Variable -Name PLASTER_Guid1 -Value ([Guid]::NewGuid()) -Scope Script - Set-Variable -Name PLASTER_Guid2 -Value ([Guid]::NewGuid()) -Scope Script - Set-Variable -Name PLASTER_Guid3 -Value ([Guid]::NewGuid()) -Scope Script - Set-Variable -Name PLASTER_Guid4 -Value ([Guid]::NewGuid()) -Scope Script - Set-Variable -Name PLASTER_Guid5 -Value ([Guid]::NewGuid()) -Scope Script - - $now = [DateTime]::Now - Set-Variable -Name PLASTER_Date -Value ($now.ToShortDateString()) -Scope Script - Set-Variable -Name PLASTER_Time -Value ($now.ToShortTimeString()) -Scope Script - Set-Variable -Name PLASTER_Year -Value ($now.Year) -Scope Script -} - -function GetPlasterManifestPathForCulture([string]$TemplatePath, [ValidateNotNull()][CultureInfo]$Culture) { - if (![System.IO.Path]::IsPathRooted($TemplatePath)) { - $TemplatePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($TemplatePath) - } - - # Check for culture-locale first. - $plasterManifestBasename = "plasterManifest" - $plasterManifestFilename = "${plasterManifestBasename}_$($culture.Name).xml" - $plasterManifestPath = Join-Path $TemplatePath $plasterManifestFilename - if (Test-Path $plasterManifestPath) { - return $plasterManifestPath - } - - # Check for culture next. - if ($culture.Parent.Name) { - $plasterManifestFilename = "${plasterManifestBasename}_$($culture.Parent.Name).xml" - $plasterManifestPath = Join-Path $TemplatePath $plasterManifestFilename - if (Test-Path $plasterManifestPath) { - return $plasterManifestPath - } - } - - # Fallback to invariant culture manifest. - $plasterManifestPath = Join-Path $TemplatePath "${plasterManifestBasename}.xml" - if (Test-Path $plasterManifestPath) { - return $plasterManifestPath - } - - $null -} diff --git a/Plaster/NewPlasterManifest.ps1 b/Plaster/NewPlasterManifest.ps1 deleted file mode 100644 index 9494adf..0000000 --- a/Plaster/NewPlasterManifest.ps1 +++ /dev/null @@ -1,134 +0,0 @@ -function New-PlasterManifest { - [CmdletBinding(SupportsShouldProcess=$true)] - param( - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] - $Path = "$pwd\plasterManifest.xml", - - [Parameter(Mandatory=$true)] - [ValidatePattern('^[0-9a-zA-Z_-]+$')] - [string] - $TemplateName, - - [Parameter(Mandatory=$true)] - [ValidateSet('Item','Project')] - [string] - $TemplateType, - - [Parameter()] - [Guid] - $Id = [guid]::NewGuid(), - - [Parameter()] - [ValidateNotNullOrEmpty()] - [ValidatePattern('^\d+\.\d+(\.\d+((\.\d+|(\+|-).*)?)?)?$')] - [string] - $TemplateVersion = "1.0.0", - - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] - $Title = $TemplateName, - - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] - $Description, - - [Parameter()] - [string[]] - $Tags, - - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] - $Author, - - [Parameter()] - [switch] - $AddContent - ) - - begin { - $resolvedPath = $PSCmdLet.GetUnresolvedProviderPathFromPSPath($Path) - - $caseCorrectedTemplateType = [System.Char]::ToUpper($TemplateType[0]) + $TemplateType.Substring(1).ToLower() - - $manifestStr = @" - - - - - - - - - - - - - - - - - -"@ - } - - end { - $manifest = [xml]$manifestStr - - # Set via .innerText to get .NET to encode special XML chars as entity references. - $manifest.plasterManifest.metadata["name"].innerText = "$TemplateName" - $manifest.plasterManifest.metadata["id"].innerText = "$Id" - $manifest.plasterManifest.metadata["version"].innerText = "$TemplateVersion" - $manifest.plasterManifest.metadata["title"].innerText = "$Title" - $manifest.plasterManifest.metadata["description"].innerText = "$Description" - $manifest.plasterManifest.metadata["author"].innerText = "$Author" - - $OFS = ", " - $manifest.plasterManifest.metadata["tags"].innerText = "$Tags" - - if ($AddContent) { - $baseDir = Split-Path $Path -Parent - $filenames = Get-ChildItem $baseDir -Recurse -File -Name - foreach ($filename in $filenames) { - if ($filename -match "plasterManifest.*\.xml") { - continue - } - - $fileElem = $manifest.CreateElement('file', $TargetNamespace) - - $srcAttr = $manifest.CreateAttribute("source") - $srcAttr.Value = $filename - $fileElem.Attributes.Append($srcAttr) > $null - - $dstAttr = $manifest.CreateAttribute("destination") - $dstAttr.Value = $filename - $fileElem.Attributes.Append($dstAttr) > $null - - $manifest.plasterManifest["content"].AppendChild($fileElem) > $null - } - } - - # This configures the XmlWriter to put attributes on a new line - $xmlWriterSettings = New-Object System.Xml.XmlWriterSettings - $xmlWriterSettings.Indent = $true - $xmlWriterSettings.NewLineOnAttributes = $true - - try { - if ($PSCmdlet.ShouldProcess($resolvedPath, $LocalizedData.ShouldCreateNewPlasterManifest)) { - $xmlWriter = [System.Xml.XmlWriter]::Create($resolvedPath, $xmlWriterSettings) - $manifest.Save($xmlWriter) - } - } - finally { - if ($xmlWriter) { - $xmlWriter.Dispose() - } - } - } -} diff --git a/Plaster/Plaster.psd1 b/Plaster/Plaster.psd1 index fa7417b..0f28db9 100644 --- a/Plaster/Plaster.psd1 +++ b/Plaster/Plaster.psd1 @@ -1,19 +1,22 @@ -@{ +@{ # Script module or binary module file associated with this manifest. - RootModule = 'Plaster.psm1' + RootModule = 'Plaster.psm1' # ID used to uniquely identify this module - GUID = 'cfce3c5e-402f-412a-a83a-7b7ee9832ff4' + GUID = 'cfce3c5e-402f-412a-a83a-7b7ee9832ff4' # Version number of this module. - ModuleVersion = '1.1.4' + ModuleVersion = '2.0.0' + + # Supported PSEditions + CompatiblePSEditions = @('Desktop', 'Core') # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a # PSData hashtable with additional module metadata used by PowerShell. - PrivateData = @{ + PrivateData = @{ PSData = @{ # Tags applied to this module. These help with module discovery in online galleries. - Tags = @('Plaster', 'CodeGenerator', 'Scaffold') + Tags = @('Plaster', 'CodeGenerator', 'Scaffold', 'Template', 'JSON', 'PowerShell7') # A URL to the license for this module. LicenseUri = 'https://github.com/PowerShellOrg/Plaster/blob/master/LICENSE' @@ -24,44 +27,101 @@ # A URL to an icon representing this module. #IconUri = 'https://github.com/PowerShell/Plaster/icon.png' - # ReleaseNotes of this module - our ReleaseNotes are in - # the file ReleaseNotes.md - # ReleaseNotes = '' - + # ReleaseNotes of this module + ReleaseNotes = @' +Plaster 2.0.0 Release Notes: + +BREAKING CHANGES: +- Minimum PowerShell version updated to 5.1 +- Updated to support PowerShell 7.x across all platforms + +NEW FEATURES: +- Full PowerShell 7.x compatibility (Windows, Linux, macOS) +- Enhanced cross-platform support +- Modern parameter validation +- Improved error handling and logging +- Updated build system with PowerShellBuild + +IMPROVEMENTS: +- Better performance on PowerShell Core +- Enhanced XML schema validation +- Improved template processing +- Modern PowerShell coding practices +- Comprehensive test coverage with Pester 5.x + +BUG FIXES: +- Fixed .NET Core XML schema validation issues +- Resolved path handling on non-Windows platforms +- Fixed constrained runspace compatibility issues +- Improved error messages and debugging + +For the complete changelog, see: https://github.com/PowerShellOrg/Plaster/blob/master/CHANGELOG.md +'@ } } # Author of this module - Author = 'PowerShell.org' + Author = 'PowerShell.org' # Company or vendor of this module - CompanyName = 'The DevOps Collective Inc.' + CompanyName = 'PowerShell.org' # Copyright statement for this module - Copyright = '(c) The DevOps Collective Inc.2016-2021. All rights reserved.' + Copyright = '(c) PowerShell.org 2016-2025. All rights reserved.' # Description of the functionality provided by this module - Description = 'Plaster scaffolds PowerShell projects and files.' + 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.' # Minimum version of the Windows PowerShell engine required by this module - PowerShellVersion = '3.0' + PowerShellVersion = '5.1' + + # Processor architecture (None, X86, Amd64) required by this module + # ProcessorArchitecture = '' + + # Modules that must be imported into the global environment prior to importing this module + # RequiredModules = @() + + # Assemblies that must be loaded prior to importing this module + # RequiredAssemblies = @() + + # Script files (.ps1) that are run in the caller's environment prior to importing this module. + # ScriptsToProcess = @() + + # Type files (.ps1xml) to be loaded when importing this module + # TypesToProcess = @() + + # Format files (.ps1xml) to be loaded when importing this module + # FormatsToProcess = @() + + # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess + # NestedModules = @() # 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 = @( - 'Invoke-Plaster' - 'New-PlasterManifest' - 'Get-PlasterTemplate', - 'Test-PlasterManifest' - ) + FunctionsToExport = '*' # 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 = @() # Aliases 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 aliases to export. - AliasesToExport = @() + AliasesToExport = @() + + # DSC resources to export from this module + # DscResourcesToExport = @() + + # List of all modules packaged with this module + # ModuleList = @() + + # List of all files packaged with this module + # FileList = @() # HelpInfo URI of this module - # HelpInfoURI = '' -} + HelpInfoURI = 'https://github.com/PowerShellOrg/Plaster/tree/master/docs' + + # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. + # DefaultCommandPrefix = '' +} \ No newline at end of file diff --git a/Plaster/Plaster.psm1 b/Plaster/Plaster.psm1 index 35292eb..2dd3e8c 100644 --- a/Plaster/Plaster.psm1 +++ b/Plaster/Plaster.psm1 @@ -1,4 +1,5 @@ - +# spell-checker:ignore Multichoice Assigments +# Import localized data data LocalizedData { # culture="en-US" ConvertFrom-StringData @' @@ -67,36 +68,108 @@ data LocalizedData { '@ } -Microsoft.PowerShell.Utility\Import-LocalizedData LocalizedData -FileName Plaster.Resources.psd1 -ErrorAction SilentlyContinue +# Import localized data with improved error handling +try { + Microsoft.PowerShell.Utility\Import-LocalizedData LocalizedData -FileName 'Plaster.Resources.psd1' -ErrorAction SilentlyContinue +} catch { + Write-Warning "Failed to import localized data: $_" +} -# Module variables +# Module variables with proper scoping and type safety [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] -$PlasterVersion = (Test-ModuleManifest -Path $PSScriptRoot\Plaster.psd1).Version +$PlasterVersion = (Test-ModuleManifest -Path (Join-Path $PSScriptRoot 'Plaster.psd1')).Version + +[System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] +$JsonSchemaPath = Join-Path $PSScriptRoot "Schema\plaster-manifest-v2.json" + [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] $LatestSupportedSchemaVersion = [System.Version]'1.2' + [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] $TargetNamespace = "http://www.microsoft.com/schemas/PowerShell/Plaster/v1" + [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] -$DefaultEncoding = 'Default' +$DefaultEncoding = 'UTF8-NoBOM' + +# Cross-platform parameter store path configuration +[System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] +$ParameterDefaultValueStoreRootPath = switch ($true) { + # Windows (both Desktop and Core) + (($PSVersionTable.PSVersion.Major -le 5) -or ($PSVersionTable.PSEdition -eq 'Desktop') -or ($IsWindows -eq $true)) { + if ($env:LOCALAPPDATA) { + "$env:LOCALAPPDATA\Plaster" + } else { + "$env:USERPROFILE\AppData\Local\Plaster" + } + } + # Linux - Follow XDG Base Directory Specification + ($IsLinux -eq $true) { + if ($env:XDG_DATA_HOME) { + "$env:XDG_DATA_HOME/plaster" + } else { + "$Home/.local/share/plaster" + } + } + # macOS and other Unix-like systems + default { + "$Home/.plaster" + } +} + +# Enhanced platform detection with fallback +if (-not (Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue)) { + $script:IsWindows = $PSVersionTable.PSVersion.Major -le 5 -or $PSVersionTable.PSEdition -eq 'Desktop' +} + +if (-not (Get-Variable -Name 'IsLinux' -ErrorAction SilentlyContinue)) { + $script:IsLinux = $false +} -if (($PSVersionTable.PSVersion.Major -le 5) -or ($PSVersionTable.PSEdition -eq 'Desktop') -or $IsWindows) { - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] - $ParameterDefaultValueStoreRootPath = "$env:LOCALAPPDATA\Plaster" +if (-not (Get-Variable -Name 'IsMacOS' -ErrorAction SilentlyContinue)) { + $script:IsMacOS = $false } -elseif ($IsLinux) { - # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] - $ParameterDefaultValueStoreRootPath = if ($XDG_DATA_HOME) { "$XDG_DATA_HOME/plaster" } else { "$Home/.local/share/plaster" } + +# .NET Core compatibility check for XML Schema validation +$script:XmlSchemaValidationSupported = $null -ne ('System.Xml.Schema.XmlSchemaSet' -as [type]) + +if (-not $script:XmlSchemaValidationSupported) { + Write-Verbose "XML Schema validation is not supported on this platform. Limited validation will be performed." } -else { - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] - $ParameterDefaultValueStoreRootPath = "$Home/.plaster" + +# Module logging configuration +$script:LogLevel = if ($env:PLASTER_LOG_LEVEL) { $env:PLASTER_LOG_LEVEL } else { 'Information' } + +# Global variables and constants for Plaster 2.0 + +# Enhanced $TargetNamespace definition with proper scoping +if (-not (Get-Variable -Name 'TargetNamespace' -Scope Script -ErrorAction SilentlyContinue)) { + Set-Variable -Name 'TargetNamespace' -Value 'http://www.microsoft.com/schemas/PowerShell/Plaster/v1' -Scope Script -Option ReadOnly } -# Dot source the individual module command scripts. -. $PSScriptRoot\NewPlasterManifest.ps1 -. $PSScriptRoot\TestPlasterManifest.ps1 -. $PSScriptRoot\GetPlasterTemplate.ps1 -. $PSScriptRoot\InvokePlaster.ps1 +# Enhanced $DefaultEncoding definition +if (-not (Get-Variable -Name 'DefaultEncoding' -Scope Script -ErrorAction SilentlyContinue)) { + Set-Variable -Name 'DefaultEncoding' -Value 'UTF8-NoBOM' -Scope Script -Option ReadOnly +} + +# JSON Schema version for new manifests +if (-not (Get-Variable -Name 'JsonSchemaVersion' -Scope Script -ErrorAction SilentlyContinue)) { + Set-Variable -Name 'JsonSchemaVersion' -Value '2.0' -Scope Script -Option ReadOnly +} + +# Export the variables that need to be available globally +Export-ModuleMember -Variable @('TargetNamespace', 'DefaultEncoding', 'JsonSchemaVersion') + +# Module cleanup on removal +$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { + Write-PlasterLog -Level Information -Message "Plaster module is being removed" + + # Clean up any module-scoped variables or resources + Remove-Variable -Name 'PlasterVersion' -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name 'LatestSupportedSchemaVersion' -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name 'TargetNamespace' -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name 'DefaultEncoding' -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name 'ParameterDefaultValueStoreRootPath' -Scope Script -ErrorAction SilentlyContinue +} -Export-ModuleMember -Function *-* +# Module initialization complete +Write-PlasterLog -Level Information -Message "Plaster v$PlasterVersion module loaded successfully (PowerShell $($PSVersionTable.PSVersion))" diff --git a/Plaster/Private/ConvertFrom-JsonContentAction.ps1 b/Plaster/Private/ConvertFrom-JsonContentAction.ps1 new file mode 100644 index 0000000..ad9f03a --- /dev/null +++ b/Plaster/Private/ConvertFrom-JsonContentAction.ps1 @@ -0,0 +1,128 @@ +function ConvertFrom-JsonContentAction { + [CmdletBinding()] + [OutputType([System.Xml.XmlElement])] + param( + [Parameter(Mandatory)] + [object]$Action, + + [Parameter(Mandatory)] + [System.Xml.XmlDocument]$XmlDocument + ) + + switch ($Action.type) { + 'message' { + $element = $XmlDocument.CreateElement('message', $TargetNamespace) + $element.InnerText = $Action.text + + if ($Action.noNewline) { + $element.SetAttribute('nonewline', 'true') + } + } + 'file' { + $element = $XmlDocument.CreateElement('file', $TargetNamespace) + $element.SetAttribute('source', $Action.source) + $element.SetAttribute('destination', $Action.destination) + + if ($Action.encoding) { + $element.SetAttribute('encoding', $Action.encoding) + } + + if ($Action.openInEditor) { + $element.SetAttribute('openInEditor', 'true') + } + } + 'templateFile' { + $element = $XmlDocument.CreateElement('templateFile', $TargetNamespace) + $element.SetAttribute('source', $Action.source) + $element.SetAttribute('destination', $Action.destination) + + if ($Action.encoding) { + $element.SetAttribute('encoding', $Action.encoding) + } + + if ($Action.openInEditor) { + $element.SetAttribute('openInEditor', 'true') + } + } + 'directory' { + $element = $XmlDocument.CreateElement('file', $TargetNamespace) + $element.SetAttribute('source', '') + $element.SetAttribute('destination', $Action.destination) + } + 'newModuleManifest' { + $element = $XmlDocument.CreateElement('newModuleManifest', $TargetNamespace) + $element.SetAttribute('destination', $Action.destination) + + $manifestProperties = @('moduleVersion', 'rootModule', 'author', 'companyName', 'description', 'powerShellVersion', 'copyright', 'encoding') + foreach ($property in $manifestProperties) { + if ($Action.PSObject.Properties[$property]) { + $element.SetAttribute($property, $Action.$property) + } + } + + if ($Action.openInEditor) { + $element.SetAttribute('openInEditor', 'true') + } + } + 'modify' { + $element = $XmlDocument.CreateElement('modify', $TargetNamespace) + $element.SetAttribute('path', $Action.path) + + if ($Action.encoding) { + $element.SetAttribute('encoding', $Action.encoding) + } + + # Add modifications + foreach ($modification in $Action.modifications) { + if ($modification.type -eq 'replace') { + $replaceElement = $XmlDocument.CreateElement('replace', $TargetNamespace) + + $originalElement = $XmlDocument.CreateElement('original', $TargetNamespace) + $originalElement.InnerText = $modification.search + if ($modification.isRegex) { + $originalElement.SetAttribute('expand', 'true') + } + $replaceElement.AppendChild($originalElement) + + $substituteElement = $XmlDocument.CreateElement('substitute', $TargetNamespace) + $substituteElement.InnerText = $modification.replace + $substituteElement.SetAttribute('expand', 'true') + $replaceElement.AppendChild($substituteElement) + + if ($modification.condition) { + $replaceElement.SetAttribute('condition', $modification.condition) + } + + $element.AppendChild($replaceElement) + } + } + } + 'requireModule' { + $element = $XmlDocument.CreateElement('requireModule', $TargetNamespace) + $element.SetAttribute('name', $Action.name) + + $moduleProperties = @('minimumVersion', 'maximumVersion', 'requiredVersion', 'message') + foreach ($property in $moduleProperties) { + if ($Action.PSObject.Properties[$property]) { + $element.SetAttribute($property, $Action.$property) + } + } + } + 'execute' { + # Execute action doesn't have direct XML equivalent, convert to message with warning + $element = $XmlDocument.CreateElement('message', $TargetNamespace) + $element.InnerText = "Warning: Execute action not supported in XML format. Script: $($Action.script)" + Write-PlasterLog -Level Warning -Message "Execute action converted to message - not supported in XML format" + } + default { + throw "Unknown action type: $($Action.type)" + } + } + + # Add condition if present + if ($Action.condition) { + $element.SetAttribute('condition', $Action.condition) + } + + return $element +} diff --git a/Plaster/Private/ConvertFrom-JsonManifest.ps1 b/Plaster/Private/ConvertFrom-JsonManifest.ps1 new file mode 100644 index 0000000..8af7bda --- /dev/null +++ b/Plaster/Private/ConvertFrom-JsonManifest.ps1 @@ -0,0 +1,126 @@ +function ConvertFrom-JsonManifest { + [CmdletBinding()] + [OutputType([System.Xml.XmlDocument])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [string]$JsonContent, + + [Parameter()] + [switch]$Validate = $true + ) + + begin { + Write-PlasterLog -Level Debug -Message "Converting JSON manifest to internal format" + } + + process { + try { + # Validate JSON if requested + if ($Validate) { + $isValid = Test-JsonManifest -JsonContent $JsonContent -Detailed + if (-not $isValid) { + throw "JSON manifest validation failed" + } + } + + # Parse JSON + $jsonObject = $JsonContent | ConvertFrom-Json + + # Create XML document + $xmlDoc = New-Object System.Xml.XmlDocument + $xmlDoc.LoadXml('') + + $manifest = $xmlDoc.DocumentElement + $manifest.SetAttribute('schemaVersion', '1.2') # Use XML schema version for compatibility + + if ($jsonObject.metadata.templateType) { + $manifest.SetAttribute('templateType', $jsonObject.metadata.templateType) + } + + # Add metadata + $metadataElement = $xmlDoc.CreateElement('metadata', $TargetNamespace) + $manifest.AppendChild($metadataElement) + + # Add metadata properties + $metadataProperties = @('name', 'id', 'version', 'title', 'description', 'author', 'tags') + foreach ($property in $metadataProperties) { + if ($jsonObject.metadata.PSObject.Properties[$property]) { + $element = $xmlDoc.CreateElement($property, $TargetNamespace) + $value = $jsonObject.metadata.$property + + if ($property -eq 'tags' -and $value -is [array]) { + $element.InnerText = $value -join ', ' + } else { + $element.InnerText = $value + } + $metadataElement.AppendChild($element) + } + } + + # Add parameters + $parametersElement = $xmlDoc.CreateElement('parameters', $TargetNamespace) + $manifest.AppendChild($parametersElement) + + if ($jsonObject.parameters) { + foreach ($param in $jsonObject.parameters) { + $paramElement = $xmlDoc.CreateElement('parameter', $TargetNamespace) + $paramElement.SetAttribute('name', $param.name) + $paramElement.SetAttribute('type', $param.type) + + if ($param.prompt) { + $paramElement.SetAttribute('prompt', $param.prompt) + } + + if ($param.default) { + if ($param.default -is [array]) { + $paramElement.SetAttribute('default', ($param.default -join ',')) + } else { + $paramElement.SetAttribute('default', $param.default) + } + } + + if ($param.condition) { + $paramElement.SetAttribute('condition', $param.condition) + } + + if ($param.store) { + $paramElement.SetAttribute('store', $param.store) + } + + # Add choices for choice/multichoice parameters + if ($param.choices) { + foreach ($choice in $param.choices) { + $choiceElement = $xmlDoc.CreateElement('choice', $TargetNamespace) + $choiceElement.SetAttribute('label', $choice.label) + $choiceElement.SetAttribute('value', $choice.value) + + if ($choice.help) { + $choiceElement.SetAttribute('help', $choice.help) + } + + $paramElement.AppendChild($choiceElement) + } + } + + $parametersElement.AppendChild($paramElement) + } + } + + # Add content + $contentElement = $xmlDoc.CreateElement('content', $TargetNamespace) + $manifest.AppendChild($contentElement) + + foreach ($action in $jsonObject.content) { + $actionElement = ConvertFrom-JsonContentAction -Action $action -XmlDocument $xmlDoc + $contentElement.AppendChild($actionElement) + } + + Write-PlasterLog -Level Debug -Message "JSON to XML conversion completed successfully" + return $xmlDoc + } catch { + $errorMessage = "Failed to convert JSON manifest: $($_.Exception.Message)" + Write-PlasterLog -Level Error -Message $errorMessage + throw $_ + } + } +} diff --git a/Plaster/Private/ConvertTo-DestinationRelativePath.ps1 b/Plaster/Private/ConvertTo-DestinationRelativePath.ps1 new file mode 100644 index 0000000..271a645 --- /dev/null +++ b/Plaster/Private/ConvertTo-DestinationRelativePath.ps1 @@ -0,0 +1,17 @@ +function ConvertTo-DestinationRelativePath { + param( + [ValidateNotNullOrEmpty()] + [string]$Path + ) + $fullDestPath = $DestinationPath + if (![System.IO.Path]::IsPathRooted($fullDestPath)) { + $fullDestPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) + } + + $fullPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) + if (!$fullPath.StartsWith($fullDestPath, 'OrdinalIgnoreCase')) { + throw ($LocalizedData.ErrorPathMustBeUnderDestPath_F2 -f $fullPath, $fullDestPath) + } + + $fullPath.Substring($fullDestPath.Length).TrimStart('\', '/') +} diff --git a/Plaster/Private/ConvertTo-JsonContentAction.ps1 b/Plaster/Private/ConvertTo-JsonContentAction.ps1 new file mode 100644 index 0000000..d41d7dc --- /dev/null +++ b/Plaster/Private/ConvertTo-JsonContentAction.ps1 @@ -0,0 +1,125 @@ +function ConvertTo-JsonContentAction { + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$ActionNode + ) + + $action = [ordered]@{ + 'type' = $ActionNode.LocalName + } + + switch ($ActionNode.LocalName) { + 'message' { + $action['text'] = $ActionNode.InnerText + if ($ActionNode.nonewline -eq 'true') { + $action['noNewline'] = $true + } + } + 'file' { + $action['source'] = $ActionNode.source + $action['destination'] = $ActionNode.destination + + if ($ActionNode.encoding) { + $action['encoding'] = $ActionNode.encoding + } + + if ($ActionNode.openInEditor -eq 'true') { + $action['openInEditor'] = $true + } + + # Handle directory creation (empty source) + if ([string]::IsNullOrEmpty($ActionNode.source)) { + $action['type'] = 'directory' + $action.Remove('source') + } + } + 'templateFile' { + $action['source'] = $ActionNode.source + $action['destination'] = $ActionNode.destination + + if ($ActionNode.encoding) { + $action['encoding'] = $ActionNode.encoding + } + + if ($ActionNode.openInEditor -eq 'true') { + $action['openInEditor'] = $true + } + } + 'newModuleManifest' { + $action['destination'] = $ActionNode.destination + + $manifestProperties = @('moduleVersion', 'rootModule', 'author', 'companyName', 'description', 'powerShellVersion', 'copyright', 'encoding') + foreach ($property in $manifestProperties) { + if ($ActionNode.$property) { + $action[$property] = $ActionNode.$property + } + } + + if ($ActionNode.openInEditor -eq 'true') { + $action['openInEditor'] = $true + } + } + 'modify' { + $action['path'] = $ActionNode.path + + if ($ActionNode.encoding) { + $action['encoding'] = $ActionNode.encoding + } + + # Extract modifications + $modifications = @() + foreach ($child in $ActionNode.ChildNodes) { + if ($child.NodeType -eq 'Element' -and $child.LocalName -eq 'replace') { + $modification = [ordered]@{ + 'type' = 'replace' + } + + $originalNode = $child.SelectSingleNode('*[local-name()="original"]') + $substituteNode = $child.SelectSingleNode('*[local-name()="substitute"]') + + if ($originalNode) { + $modification['search'] = $originalNode.InnerText + if ($originalNode.expand -eq 'true') { + $modification['isRegex'] = $true + } + } + + if ($substituteNode) { + $modification['replace'] = $substituteNode.InnerText + } + + if ($child.condition) { + $modification['condition'] = $child.condition + } + + $modifications += $modification + } + } + + $action['modifications'] = $modifications + } + 'requireModule' { + $action['name'] = $ActionNode.name + + $moduleProperties = @('minimumVersion', 'maximumVersion', 'requiredVersion', 'message') + foreach ($property in $moduleProperties) { + if ($ActionNode.$property) { + $action[$property] = $ActionNode.$property + } + } + } + default { + Write-PlasterLog -Level Warning -Message "Unknown XML action type: $($ActionNode.LocalName)" + return $null + } + } + + # Add condition if present + if ($ActionNode.condition) { + $action['condition'] = $ActionNode.condition + } + + return $action +} diff --git a/Plaster/Private/ConvertTo-JsonManifest.ps1 b/Plaster/Private/ConvertTo-JsonManifest.ps1 new file mode 100644 index 0000000..8668839 --- /dev/null +++ b/Plaster/Private/ConvertTo-JsonManifest.ps1 @@ -0,0 +1,148 @@ +function ConvertTo-JsonManifest { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [System.Xml.XmlDocument]$XmlManifest, + + [Parameter()] + [switch]$Compress + ) + + begin { + Write-PlasterLog -Level Debug -Message "Converting XML manifest to JSON format" + } + + process { + try { + $jsonObject = [ordered]@{ + '$schema' = 'https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json' + 'schemaVersion' = '2.0' + } + + # Extract metadata + $metadata = [ordered]@{} + $metadataNode = $XmlManifest.plasterManifest.metadata + + if ($metadataNode) { + foreach ($child in $metadataNode.ChildNodes) { + if ($child.NodeType -eq 'Element') { + $value = $child.InnerText + if ($child.LocalName -eq 'tags' -and $value) { + $metadata[$child.LocalName] = $value -split ',' | ForEach-Object { $_.Trim() } + } else { + $metadata[$child.LocalName] = $value + } + } + } + } + + # Add template type if present + if ($XmlManifest.plasterManifest.templateType) { + $metadata['templateType'] = $XmlManifest.plasterManifest.templateType + } else { + $metadata['templateType'] = 'Project' + } + + $jsonObject['metadata'] = $metadata + + # Extract parameters + $parameters = @() + $parametersNode = $XmlManifest.plasterManifest.parameters + + if ($parametersNode) { + foreach ($paramNode in $parametersNode.ChildNodes) { + if ($paramNode.NodeType -eq 'Element' -and $paramNode.LocalName -eq 'parameter') { + $param = [ordered]@{ + 'name' = $paramNode.name + 'type' = $paramNode.type + } + + if ($paramNode.prompt) { + $param['prompt'] = $paramNode.prompt + } + + if ($paramNode.default) { + if ($paramNode.type -eq 'multichoice') { + $param['default'] = $paramNode.default -split ',' + } else { + $param['default'] = $paramNode.default + } + } + + if ($paramNode.condition) { + $param['condition'] = $paramNode.condition + } + + if ($paramNode.store) { + $param['store'] = $paramNode.store + } + + # Extract choices + $choices = @() + foreach ($choiceNode in $paramNode.ChildNodes) { + if ($choiceNode.NodeType -eq 'Element' -and $choiceNode.LocalName -eq 'choice') { + $choice = [ordered]@{ + 'label' = $choiceNode.label + 'value' = $choiceNode.value + } + + if ($choiceNode.help) { + $choice['help'] = $choiceNode.help + } + + $choices += $choice + } + } + + if ($choices.Count -gt 0) { + $param['choices'] = $choices + } + + $parameters += $param + } + } + } + + if ($parameters.Count -gt 0) { + $jsonObject['parameters'] = $parameters + } + + # Extract content + $content = @() + $contentNode = $XmlManifest.plasterManifest.content + + if ($contentNode) { + foreach ($actionNode in $contentNode.ChildNodes) { + if ($actionNode.NodeType -eq 'Element') { + $action = ConvertTo-JsonContentAction -ActionNode $actionNode + if ($action) { + $content += $action + } + } + } + } + + $jsonObject['content'] = $content + + # Convert to JSON + $jsonParams = @{ + InputObject = $jsonObject + Depth = 10 + } + + if (-not $Compress) { + $jsonParams['Compress'] = $false + } + + $jsonResult = $jsonObject | ConvertTo-Json @jsonParams + + Write-PlasterLog -Level Debug -Message "XML to JSON conversion completed successfully" + return $jsonResult + } catch { + $errorMessage = "Failed to convert XML manifest to JSON: $($_.Exception.Message)" + Write-PlasterLog -Level Error -Message $errorMessage + throw $_ + } + } +} diff --git a/Plaster/Private/Copy-FileWithConflictDetection.ps1 b/Plaster/Private/Copy-FileWithConflictDetection.ps1 new file mode 100644 index 0000000..5cb7276 --- /dev/null +++ b/Plaster/Private/Copy-FileWithConflictDetection.ps1 @@ -0,0 +1,82 @@ +<# +Plaster zen for file handling. All file related operations should use this +method to actually write/overwrite/modify files in the DestinationPath. This +method handles detecting conflicts, gives the user a chance to determine how to +handle conflicts. The user can choose to use the Force parameter to force the +overwriting of existing files at the destination path. File processing +(expanding substitution variable, modifying file contents) should always be done +to a temp file (be sure to always remove temp file when done). That temp file is +what gets passed to this function as the $SrcPath. This allows Plaster to alert +the user when the repeated application of a template will modify any existing +file. + +NOTE: Plaster keeps track of which files it has "created" (as opposed to +overwritten) so that any later change to that file doesn't trigger conflict +handling. +#> +function Copy-FileWithConflictDetection { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [string]$SrcPath, + [string]$DstPath + ) + # Just double-checking that DstPath parameter is an absolute path otherwise + # it could fail the check that the DstPath is under the overall DestinationPath. + if (![System.IO.Path]::IsPathRooted($DstPath)) { + $DstPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DstPath) + } + + # Check if DstPath file conflicts with an existing SrcPath file. + $operation = $LocalizedData.OpCreate + $opMessage = ConvertTo-DestinationRelativePath $DstPath + if (Test-Path -LiteralPath $DstPath) { + if (Test-FilesIdentical $SrcPath $DstPath) { + $operation = $LocalizedData.OpIdentical + } elseif ($script:templateCreatedFiles.ContainsKey($DstPath)) { + # Plaster created this file previously during template invocation + # therefore, there is no conflict. We're simply updating the file. + $operation = $LocalizedData.OpUpdate + } elseif ($Force) { + $operation = $LocalizedData.OpForce + } else { + $operation = $LocalizedData.OpConflict + } + } + + # Copy the file to the destination + if ($PSCmdlet.ShouldProcess($DstPath, $operation)) { + Write-OperationStatus -Operation $operation -Message $opMessage + + if ($operation -eq $LocalizedData.OpIdentical) { + # If the files are identical, no need to do anything + return + } + + if ( + ($operation -eq $LocalizedData.OpCreate) -or + ($operation -eq $LocalizedData.OpUpdate) + ) { + Copy-Item -LiteralPath $SrcPath -Destination $DstPath + if ($PassThru) { + $InvokePlasterInfo.CreatedFiles += $DstPath + } + $script:templateCreatedFiles[$DstPath] = $null + } elseif ( + $Force -or + $PSCmdlet.ShouldContinue( + ($LocalizedData.OverwriteFile_F1 -f $DstPath), + $LocalizedData.FileConflict, + [ref]$script:fileConflictConfirmYesToAll, + [ref]$script:fileConflictConfirmNoToAll + ) + ) { + $backupFilename = New-BackupFilename $DstPath + Copy-Item -LiteralPath $DstPath -Destination $backupFilename + Copy-Item -LiteralPath $SrcPath -Destination $DstPath + if ($PassThru) { + $InvokePlasterInfo.UpdatedFiles += $DstPath + } + $script:templateCreatedFiles[$DstPath] = $null + } + } +} diff --git a/Plaster/Private/Expand-FileSourceSpec.ps1 b/Plaster/Private/Expand-FileSourceSpec.ps1 new file mode 100644 index 0000000..6f6cbe3 --- /dev/null +++ b/Plaster/Private/Expand-FileSourceSpec.ps1 @@ -0,0 +1,65 @@ +function Expand-FileSourceSpec { + [CmdletBinding()] + param( + [string]$SourceRelativePath, + [string]$DestinationRelativePath + ) + $srcPath = Join-Path $templateAbsolutePath $SourceRelativePath + $dstPath = Join-Path $destinationAbsolutePath $DestinationRelativePath + + if ($SourceRelativePath.IndexOfAny([char[]]('*', '?')) -lt 0) { + # No wildcard spec in srcRelPath so return info on single file. + # Also, if dstRelPath is empty, then use source rel path. + if (!$DestinationRelativePath) { + $dstPath = Join-Path $destinationAbsolutePath $SourceRelativePath + } + + return (New-FileSystemCopyInfo $srcPath $dstPath) + } + + # Prepare parameter values for call to Get-ChildItem to get list of files + # based on wildcard spec. + $gciParams = @{} + $parent = Split-Path $srcPath -Parent + $leaf = Split-Path $srcPath -Leaf + $gciParams['LiteralPath'] = $parent + $gciParams['File'] = $true + + if ($leaf -eq '**') { + $gciParams['Recurse'] = $true + } else { + if ($leaf.IndexOfAny([char[]]('*', '?')) -ge 0) { + $gciParams['Filter'] = $leaf + } + + $leaf = Split-Path $parent -Leaf + if ($leaf -eq '**') { + $parent = Split-Path $parent -Parent + $gciParams['LiteralPath'] = $parent + $gciParams['Recurse'] = $true + } + } + + $srcRelRootPathLength = $gciParams['LiteralPath'].Length + + # Generate a FileCopyInfo object for every file expanded by the wildcard spec. + $files = @(Microsoft.PowerShell.Management\Get-ChildItem @gciParams) + foreach ($file in $files) { + $fileSrcPath = $file.FullName + $relPath = $fileSrcPath.Substring($srcRelRootPathLength) + $fileDstPath = Join-Path $dstPath $relPath + New-FileSystemCopyInfo $fileSrcPath $fileDstPath + } + + # Copy over empty directories - if any. + $gciParams.Remove('File') + $gciParams['Directory'] = $true + $dirs = @(Microsoft.PowerShell.Management\Get-ChildItem @gciParams | + Where-Object { $_.GetFileSystemInfos().Length -eq 0 }) + foreach ($dir in $dirs) { + $dirSrcPath = $dir.FullName + $relPath = $dirSrcPath.Substring($srcRelRootPathLength) + $dirDstPath = Join-Path $dstPath $relPath + New-FileSystemCopyInfo $dirSrcPath $dirDstPath + } +} diff --git a/Plaster/Private/Get-ColorForOperation.ps1 b/Plaster/Private/Get-ColorForOperation.ps1 new file mode 100644 index 0000000..a541dc3 --- /dev/null +++ b/Plaster/Private/Get-ColorForOperation.ps1 @@ -0,0 +1,16 @@ +function Get-ColorForOperation { + param( + $operation + ) + switch ($operation) { + $LocalizedData.OpConflict { 'Red' } + $LocalizedData.OpCreate { 'Green' } + $LocalizedData.OpForce { 'Yellow' } + $LocalizedData.OpIdentical { 'Cyan' } + $LocalizedData.OpModify { 'Magenta' } + $LocalizedData.OpUpdate { 'Green' } + $LocalizedData.OpMissing { 'Red' } + $LocalizedData.OpVerify { 'Green' } + default { $Host.UI.RawUI.ForegroundColor } + } +} diff --git a/Plaster/Private/Get-ErrorLocationFileAttrVal.ps1 b/Plaster/Private/Get-ErrorLocationFileAttrVal.ps1 new file mode 100644 index 0000000..4331bcc --- /dev/null +++ b/Plaster/Private/Get-ErrorLocationFileAttrVal.ps1 @@ -0,0 +1,7 @@ +function Get-ErrorLocationFileAttrVal { + param( + [string]$ElementName, + [string]$AttributeName + ) + $LocalizedData.ExpressionErrorLocationFile_F2 -f $ElementName, $AttributeName +} diff --git a/Plaster/Private/Get-ErrorLocationModifyAttrVal.ps1 b/Plaster/Private/Get-ErrorLocationModifyAttrVal.ps1 new file mode 100644 index 0000000..4167567 --- /dev/null +++ b/Plaster/Private/Get-ErrorLocationModifyAttrVal.ps1 @@ -0,0 +1,6 @@ +function Get-ErrorLocationModifyAttrVal { + param( + [string]$AttributeName + ) + $LocalizedData.ExpressionErrorLocationModify_F1 -f $AttributeName +} diff --git a/Plaster/Private/Get-ErrorLocationNewModManifestAttrVal.ps1 b/Plaster/Private/Get-ErrorLocationNewModManifestAttrVal.ps1 new file mode 100644 index 0000000..60e0cf3 --- /dev/null +++ b/Plaster/Private/Get-ErrorLocationNewModManifestAttrVal.ps1 @@ -0,0 +1,6 @@ +function Get-ErrorLocationNewModManifestAttrVal { + param( + [string]$AttributeName + ) + $LocalizedData.ExpressionErrorLocationNewModManifest_F1 -f $AttributeName +} diff --git a/Plaster/Private/Get-ErrorLocationParameterAttrVal.ps1 b/Plaster/Private/Get-ErrorLocationParameterAttrVal.ps1 new file mode 100644 index 0000000..8f62254 --- /dev/null +++ b/Plaster/Private/Get-ErrorLocationParameterAttrVal.ps1 @@ -0,0 +1,7 @@ +function Get-ErrorLocationParameterAttrVal { + param( + [string]$ParameterName, + [string]$AttributeName + ) + $LocalizedData.ExpressionErrorLocationParameter_F2 -f $ParameterName, $AttributeName +} diff --git a/Plaster/Private/Get-ErrorLocationRequireModuleAttrVal.ps1 b/Plaster/Private/Get-ErrorLocationRequireModuleAttrVal.ps1 new file mode 100644 index 0000000..0f85b4d --- /dev/null +++ b/Plaster/Private/Get-ErrorLocationRequireModuleAttrVal.ps1 @@ -0,0 +1,7 @@ +function Get-ErrorLocationRequireModuleAttrVal { + param( + [string]$ModuleName, + [string]$AttributeName + ) + $LocalizedData.ExpressionErrorLocationRequireModule_F2 -f $ModuleName, $AttributeName +} diff --git a/Plaster/Private/Get-GitConfigValue.ps1 b/Plaster/Private/Get-GitConfigValue.ps1 new file mode 100644 index 0000000..b495c44 --- /dev/null +++ b/Plaster/Private/Get-GitConfigValue.ps1 @@ -0,0 +1,25 @@ +function Get-GitConfigValue { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$name + ) + # Very simplistic git config lookup + # Won't work with namespace, just use final element, e.g. 'name' instead of 'user.name' + + # The $Home dir may not be reachable e.g. if on network share and/or script not running as admin. + # See issue https://github.com/PowerShell/Plaster/issues/92 + if (!(Test-Path -LiteralPath $Home)) { + return + } + + $gitConfigPath = Join-Path $Home '.gitconfig' + $PSCmdlet.WriteDebug("Looking for '$name' value in Git config: $gitConfigPath") + + if (Test-Path -LiteralPath $gitConfigPath) { + $matches = Select-String -LiteralPath $gitConfigPath -Pattern "\s+$name\s+=\s+(.+)$" + if (@($matches).Count -gt 0) { + $matches.Matches.Groups[1].Value + } + } +} diff --git a/Plaster/Private/Get-ManifestsUnderPath.ps1 b/Plaster/Private/Get-ManifestsUnderPath.ps1 new file mode 100644 index 0000000..394da9d --- /dev/null +++ b/Plaster/Private/Get-ManifestsUnderPath.ps1 @@ -0,0 +1,61 @@ +function Get-ManifestsUnderPath { + <# + .SYNOPSIS + Retrieves Plaster manifest files under a specified path. + + .DESCRIPTION + This function searches for Plaster manifest files (`plasterManifest.xml`) + under a specified root path and returns template objects created from those + manifests. + + .PARAMETER RootPath + The root path to search for Plaster manifest files. + + .PARAMETER Recurse + Whether to search subdirectories for manifest files. + + .PARAMETER Name + The name of the template to retrieve. + If not specified, all templates will be returned. + + .PARAMETER Tag + The tag of the template to retrieve. + If not specified, templates with any tag will be returned. + + .EXAMPLE + Get-ManifestsUnderPath -RootPath "C:\Templates" -Recurse -Name "MyTemplate" -Tag "Tag1" + + Retrieves all Plaster templates named "MyTemplate" with the tag "Tag1" + under the "C:\Templates" directory and its subdirectories. + + .NOTES + This is a private function used internally by Plaster to manage templates. + It is not intended for direct use by end users. + #> + [CmdletBinding()] + param( + [string] + $RootPath, + [bool] + $Recurse, + [string] + $Name, + [string] + $Tag + ) + $getChildItemSplat = @{ + Path = $RootPath + Include = "plasterManifest.xml", "plasterManifest.json" + Recurse = $Recurse + } + $manifestPaths = Get-ChildItem @getChildItemSplat + foreach ($manifestPath in $manifestPaths) { + $newTemplateObjectFromManifestSplat = @{ + ManifestPath = $manifestPath + Name = $Name + Tag = $Tag + ErrorAction = 'SilentlyContinue' + } + New-TemplateObjectFromManifest @newTemplateObjectFromManifestSplat + } +} diff --git a/Plaster/Private/Get-MaxOperationLabelLength.ps1 b/Plaster/Private/Get-MaxOperationLabelLength.ps1 new file mode 100644 index 0000000..54e191b --- /dev/null +++ b/Plaster/Private/Get-MaxOperationLabelLength.ps1 @@ -0,0 +1,15 @@ +function Get-MaxOperationLabelLength { + [CmdletBinding()] + [OutputType([int])] + param() + ( + $LocalizedData.OpCreate, + $LocalizedData.OpIdentical, + $LocalizedData.OpConflict, + $LocalizedData.OpForce, + $LocalizedData.OpMissing, + $LocalizedData.OpModify, + $LocalizedData.OpUpdate, + $LocalizedData.OpVerify | + Measure-Object -Property Length -Maximum).Maximum +} diff --git a/Plaster/Private/Get-ModuleExtension.ps1 b/Plaster/Private/Get-ModuleExtension.ps1 new file mode 100644 index 0000000..db89cab --- /dev/null +++ b/Plaster/Private/Get-ModuleExtension.ps1 @@ -0,0 +1,91 @@ +function Get-ModuleExtension { + <# + .SYNOPSIS + Retrieves module extensions based on specified criteria. + + .DESCRIPTION + This function retrieves module extensions that match the specified module + name and version criteria. + + .PARAMETER ModuleName + The name of the module to retrieve extensions for. + + .PARAMETER ModuleVersion + The version of the module to retrieve extensions for. + + .PARAMETER ListAvailable + Indicates whether to list all available modules or only the the latest + version of each module. + + .EXAMPLE + Get-ModuleExtension -ModuleName "MyModule" -ModuleVersion "1.0.0" + + Retrieves extensions for the module "MyModule" with version "1.0.0". + .NOTES + + #> + [CmdletBinding()] + param( + [string] + $ModuleName, + + [Version] + $ModuleVersion, + + [Switch] + $ListAvailable + ) + + # Only get the latest version of each module + $modules = Get-Module -ListAvailable + if (!$ListAvailable.IsPresent) { + $modules = $modules | + Group-Object Name | + ForEach-Object { + $_.group | + Sort-Object Version | + Select-Object -Last 1 + } + } + + Write-Verbose "Found $($modules.Length) installed modules to scan for extensions." + + foreach ($module in $modules) { + if ($module.PrivateData -and + $module.PrivateData.PSData -and + $module.PrivateData.PSData.Extensions) { + + Write-Verbose "Found module with extensions: $($module.Name)" + + foreach ($extension in $module.PrivateData.PSData.Extensions) { + + Write-Verbose "Comparing against module extension: $($extension.Module)" + + if ([String]::IsNullOrEmpty($extension.MinimumVersion)) { + # Fill with a default value if not specified + $minimumVersion = $null + } else { + $minimumVersion = Resolve-ModuleVersionString $extension.MinimumVersion + } + if ([String]::IsNullOrEmpty($extension.MaximumVersion)) { + # Fill with a default value if not specified + $maximumVersion = $null + } else { + $maximumVersion = Resolve-ModuleVersionString $extension.MaximumVersion + } + + if (($extension.Module -eq $ModuleName) -and + (!$minimumVersion -or $ModuleVersion -ge $minimumVersion) -and + (!$maximumVersion -or $ModuleVersion -le $maximumVersion)) { + # Return a new object with the extension information + [PSCustomObject]@{ + Module = $module + MinimumVersion = $minimumVersion + MaximumVersion = $maximumVersion + Details = $extension.Details + } + } + } + } + } +} diff --git a/Plaster/Private/Get-PSSnippetFunction.ps1 b/Plaster/Private/Get-PSSnippetFunction.ps1 new file mode 100644 index 0000000..f78dc6b --- /dev/null +++ b/Plaster/Private/Get-PSSnippetFunction.ps1 @@ -0,0 +1,12 @@ +function Get-PSSnippetFunction { + param( + [String]$FilePath + ) + + # Test if Path Exists + if (!(Test-Path $substitute -PathType Leaf)) { + throw ($LocalizedData.ErrorPathDoesNotExist_F1 -f $FilePath) + } + # Load File + return Get-Content -LiteralPath $substitute -Raw +} diff --git a/Plaster/Private/Get-PlasterManifestPathForCulture.ps1 b/Plaster/Private/Get-PlasterManifestPathForCulture.ps1 new file mode 100644 index 0000000..693e214 --- /dev/null +++ b/Plaster/Private/Get-PlasterManifestPathForCulture.ps1 @@ -0,0 +1,69 @@ +function Get-PlasterManifestPathForCulture { + <# + .SYNOPSIS + Returns the path to the Plaster manifest file for a specific culture. + + .DESCRIPTION + This function checks for the existence of a Plaster manifest file that + matches the specified culture. It first looks for a culture-specific + manifest, then checks for a parent culture manifest, and finally falls back + to an invariant culture manifest if no specific match is found. The function + returns the path to the manifest file if found, or $null if no matching + manifest is found. + + .PARAMETER TemplatePath + The path to the template directory. + This should be a fully qualified path to the directory containing the + Plaster manifest files. + + .PARAMETER Culture + The culture information for which to retrieve the Plaster manifest file. + + .EXAMPLE + Get-PlasterManifestPathForCulture -TemplatePath "C:\Templates" -Culture (Get-Culture) + + This example retrieves the path to the Plaster manifest file for the current culture. + .NOTES + This is a private function used by Plaster to locate the appropriate + manifest file based on the specified culture. + #> + [CmdletBinding()] + [OutputType([String])] + param ( + [string] + $TemplatePath, + [ValidateNotNull()] + [CultureInfo] + $Culture + ) + if (![System.IO.Path]::IsPathRooted($TemplatePath)) { + $TemplatePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($TemplatePath) + } + + # Check for culture-locale first. + $plasterManifestBasename = "plasterManifest" + $plasterManifestFilename = "${plasterManifestBasename}_$($culture.Name).xml" + $plasterManifestPath = Join-Path $TemplatePath $plasterManifestFilename + if (Test-Path $plasterManifestPath) { + return $plasterManifestPath + } + + # Check for culture next. + if ($culture.Parent.Name) { + $plasterManifestFilename = "${plasterManifestBasename}_$($culture.Parent.Name).xml" + $plasterManifestPath = Join-Path $TemplatePath $plasterManifestFilename + if (Test-Path $plasterManifestPath) { + return $plasterManifestPath + } + } + + # Fallback to invariant culture manifest. + $plasterManifestPath = Join-Path $TemplatePath "${plasterManifestBasename}.xml" + if (Test-Path $plasterManifestPath) { + return $plasterManifestPath + } + + # If no manifest is found, return $null. + # TODO: Should we throw an error instead? + return $null +} diff --git a/Plaster/Private/Get-PlasterManifestType.ps1 b/Plaster/Private/Get-PlasterManifestType.ps1 new file mode 100644 index 0000000..a7415d0 --- /dev/null +++ b/Plaster/Private/Get-PlasterManifestType.ps1 @@ -0,0 +1,79 @@ +function Get-PlasterManifestType { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string]$ManifestPath + ) + + if (-not (Test-Path -LiteralPath $ManifestPath)) { + throw "Manifest file not found: $ManifestPath" + } + + try { + $content = Get-Content -LiteralPath $ManifestPath -Raw -ErrorAction Stop + + # Check file extension first + $extension = [System.IO.Path]::GetExtension($ManifestPath).ToLower() + if ($extension -eq '.json') { + # Validate it's actually JSON + try { + $jsonObject = $content | ConvertFrom-Json -ErrorAction Stop + # Check for Plaster 2.0 JSON schema + if ($jsonObject.schemaVersion -eq '2.0') { + return 'JSON' + } + # Also accept older JSON formats without strict version check + if ($jsonObject.PSObject.Properties['metadata'] -and $jsonObject.PSObject.Properties['content']) { + return 'JSON' + } + } catch { + throw "File has .json extension but contains invalid JSON: $($_.Exception.Message)" + } + } elseif ($extension -eq '.xml') { + # Validate it's actually XML + try { + $xmlDoc = New-Object System.Xml.XmlDocument + $xmlDoc.LoadXml($content) + if ($xmlDoc.DocumentElement.LocalName -eq 'plasterManifest') { + return 'XML' + } + } catch { + throw "File has .xml extension but contains invalid XML: $($_.Exception.Message)" + } + } + + # If no extension or ambiguous, try to detect by content + $trimmedContent = $content.TrimStart() + + # Check for JSON format (starts with { or [) + if ($trimmedContent -match '^[\s]*[\{\[]') { + try { + $jsonObject = $content | ConvertFrom-Json -ErrorAction Stop + # Validate it's a Plaster JSON manifest + if ($jsonObject.PSObject.Properties['metadata'] -and $jsonObject.PSObject.Properties['content']) { + return 'JSON' + } + } catch { + # Not valid JSON, continue to XML check + } + } + + # Check for XML format + if ($trimmedContent -match '^[\s]*<\?xml' -or $trimmedContent -match ' + [CmdletBinding()] + param( + [string] + $TemplatePath, + [string] + $DestPath + ) + + # Always set these variables, even if the command has been run with -WhatIf + $WhatIfPreference = $false + + Set-Variable -Name PLASTER_TemplatePath -Value $TemplatePath.TrimEnd('\', '/') -Scope Script + + $destName = Split-Path -Path $DestPath -Leaf + Set-Variable -Name PLASTER_DestinationPath -Value $DestPath.TrimEnd('\', '/') -Scope Script + Set-Variable -Name PLASTER_DestinationName -Value $destName -Scope Script + Set-Variable -Name PLASTER_DirSepChar -Value ([System.IO.Path]::DirectorySeparatorChar) -Scope Script + Set-Variable -Name PLASTER_HostName -Value $Host.Name -Scope Script + Set-Variable -Name PLASTER_Version -Value $MyInvocation.MyCommand.Module.Version -Scope Script + + Set-Variable -Name PLASTER_Guid1 -Value ([Guid]::NewGuid()) -Scope Script + Set-Variable -Name PLASTER_Guid2 -Value ([Guid]::NewGuid()) -Scope Script + Set-Variable -Name PLASTER_Guid3 -Value ([Guid]::NewGuid()) -Scope Script + Set-Variable -Name PLASTER_Guid4 -Value ([Guid]::NewGuid()) -Scope Script + Set-Variable -Name PLASTER_Guid5 -Value ([Guid]::NewGuid()) -Scope Script + + $now = [DateTime]::Now + Set-Variable -Name PLASTER_Date -Value ($now.ToShortDateString()) -Scope Script + Set-Variable -Name PLASTER_Time -Value ($now.ToShortTimeString()) -Scope Script + Set-Variable -Name PLASTER_Year -Value ($now.Year) -Scope Script +} diff --git a/Plaster/Private/Invoke-ExpressionImpl.ps1 b/Plaster/Private/Invoke-ExpressionImpl.ps1 new file mode 100644 index 0000000..8906e94 --- /dev/null +++ b/Plaster/Private/Invoke-ExpressionImpl.ps1 @@ -0,0 +1,37 @@ +function Invoke-ExpressionImpl { + [CmdletBinding()] + param ( + [string]$Expression + ) + try { + $powershell = [PowerShell]::Create() + + if ($null -eq $constrainedRunspace) { + $constrainedRunspace = New-ConstrainedRunspace + } + $powershell.Runspace = $constrainedRunspace + + try { + $powershell.AddScript($Expression) > $null + $res = $powershell.Invoke() + + # Enhanced logging for JSON expressions + if ($Expression -match '\$\{.*\}' -and $manifestType -eq 'JSON') { + Write-PlasterLog -Level Debug -Message "JSON expression evaluated: $Expression -> $res" + } + + return $res + } catch { + throw ($LocalizedData.ExpressionInvalid_F2 -f $Expression, $_) + } + + if ($powershell.Streams.Error.Count -gt 0) { + $err = $powershell.Streams.Error[0] + throw ($LocalizedData.ExpressionNonTermErrors_F2 -f $Expression, $err) + } + } finally { + if ($powershell) { + $powershell.Dispose() + } + } +} diff --git a/Plaster/Private/Invoke-PlasterOperation.ps1 b/Plaster/Private/Invoke-PlasterOperation.ps1 new file mode 100644 index 0000000..64c24ae --- /dev/null +++ b/Plaster/Private/Invoke-PlasterOperation.ps1 @@ -0,0 +1,58 @@ +# Enhanced error handling wrapper +function Invoke-PlasterOperation { + <# + .SYNOPSIS + Wraps the execution of a script block with enhanced error handling and + logging capabilities. + + .DESCRIPTION + This function wraps the execution of a script block with enhanced error + handling and logging capabilities. + + .PARAMETER ScriptBlock + The script block to execute. + + .PARAMETER OperationName + The name of the operation being performed. + + .PARAMETER PassThru + If specified, the output of the script block output will be returned. + + .EXAMPLE + Invoke-PlasterOperation -ScriptBlock { Get-Process } -OperationName 'GetProcesses' -PassThru + + This example executes the `Get-Process` cmdlet within the context of the + `Invoke-PlasterOperation` function, logging the operation and returning the + output. + + .NOTES + This function is designed to be used within the Plaster module to ensure + consistent logging and error handling across various operations. + It is not intended for direct use outside of the Plaster context. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [scriptblock] + $ScriptBlock, + + [string] + $OperationName = 'PlasterOperation', + + [switch] + $PassThru + ) + try { + Write-PlasterLog -Level Debug -Message "Starting operation: $OperationName" + $result = & $ScriptBlock + Write-PlasterLog -Level Debug -Message "Completed operation: $OperationName" + + if ($PassThru) { + return $result + } + } catch { + $errorMessage = "Operation '$OperationName' failed: $($_.Exception.Message)" + Write-PlasterLog -Level Error -Message $errorMessage + throw $_ + } +} diff --git a/Plaster/Private/New-BackupFilename.ps1 b/Plaster/Private/New-BackupFilename.ps1 new file mode 100644 index 0000000..533c6be --- /dev/null +++ b/Plaster/Private/New-BackupFilename.ps1 @@ -0,0 +1,16 @@ +function New-BackupFilename { + [CmdletBinding()] + param( + [string]$Path + ) + $dir = [System.IO.Path]::GetDirectoryName($Path) + $filename = [System.IO.Path]::GetFileName($Path) + $backupPath = Join-Path -Path $dir -ChildPath "${filename}.bak" + $i = 1 + while (Test-Path -LiteralPath $backupPath) { + $backupPath = Join-Path -Path $dir -ChildPath "${filename}.bak$i" + $i++ + } + + $backupPath +} diff --git a/Plaster/Private/New-ConstrainedRunspace.ps1 b/Plaster/Private/New-ConstrainedRunspace.ps1 new file mode 100644 index 0000000..1954c62 --- /dev/null +++ b/Plaster/Private/New-ConstrainedRunspace.ps1 @@ -0,0 +1,62 @@ +function New-ConstrainedRunspace { + [CmdletBinding()] + param () + $iss = [System.Management.Automation.Runspaces.InitialSessionState]::Create() + if (!$IsCoreCLR) { + $iss.ApartmentState = [System.Threading.ApartmentState]::STA + } + $iss.LanguageMode = [System.Management.Automation.PSLanguageMode]::ConstrainedLanguage + $iss.DisableFormatUpdates = $true + + # Add providers + $sspe = New-Object System.Management.Automation.Runspaces.SessionStateProviderEntry 'Environment', ([Microsoft.PowerShell.Commands.EnvironmentProvider]), $null + $iss.Providers.Add($sspe) + + $sspe = New-Object System.Management.Automation.Runspaces.SessionStateProviderEntry 'FileSystem', ([Microsoft.PowerShell.Commands.FileSystemProvider]), $null + $iss.Providers.Add($sspe) + + # Add cmdlets with enhanced set for JSON processing + $cmdlets = @( + 'Get-Content', 'Get-Date', 'Get-ChildItem', 'Get-Item', 'Get-ItemProperty', + 'Get-Module', 'Get-Variable', 'Test-Path', 'Out-String', 'Compare-Object', + 'ConvertFrom-Json', 'ConvertTo-Json' # JSON support + ) + + foreach ($cmdletName in $cmdlets) { + #$cmdletType = [Microsoft.PowerShell.Commands.GetContentCommand].Assembly.GetType("Microsoft.PowerShell.Commands.$($cmdletName -replace '-')Command") + $cmdletType = "Microsoft.PowerShell.Commands.$($cmdletName -replace '-')Command" -as [Type] + if ($cmdletType) { + $ssce = New-Object System.Management.Automation.Runspaces.SessionStateCmdletEntry $cmdletName, $cmdletType, $null + $iss.Commands.Add($ssce) + } + } + + # Add enhanced variable set including JSON manifest type + $scopedItemOptions = [System.Management.Automation.ScopedItemOptions]::AllScope + $plasterVars = Get-Variable -Name PLASTER_*, PSVersionTable + + # Add platform detection variables + if (Test-Path Variable:\IsLinux) { $plasterVars += Get-Variable -Name IsLinux } + if (Test-Path Variable:\IsOSX) { $plasterVars += Get-Variable -Name IsOSX } + if (Test-Path Variable:\IsMacOS) { $plasterVars += Get-Variable -Name IsMacOS } + if (Test-Path Variable:\IsWindows) { $plasterVars += Get-Variable -Name IsWindows } + + # Add manifest type variable (new for 2.0) + $manifestTypeVar = New-Object System.Management.Automation.PSVariable 'PLASTER_ManifestType', $manifestType, 'None' + $plasterVars += $manifestTypeVar + + foreach ($var in $plasterVars) { + $ssve = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry ` + $var.Name, $var.Value, $var.Description, $scopedItemOptions + $iss.Variables.Add($ssve) + } + + $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($iss) + $runspace.Open() + if ($destinationAbsolutePath) { + $runspace.SessionStateProxy.Path.SetLocation($destinationAbsolutePath) > $null + } + + Write-PlasterLog -Level Debug -Message "Created enhanced constrained runspace with $manifestType support" + return $runspace +} diff --git a/Plaster/Private/New-FileSystemCopyInfo.ps1 b/Plaster/Private/New-FileSystemCopyInfo.ps1 new file mode 100644 index 0000000..7c4c76a --- /dev/null +++ b/Plaster/Private/New-FileSystemCopyInfo.ps1 @@ -0,0 +1,11 @@ +function New-FileSystemCopyInfo { + [CmdletBinding()] + param( + [string]$srcPath, + [string]$dstPath + ) + [PSCustomObject]@{ + SrcFileName = $srcPath + DstFileName = $dstPath + } +} diff --git a/Plaster/Private/New-JsonManifestStructure.ps1 b/Plaster/Private/New-JsonManifestStructure.ps1 new file mode 100644 index 0000000..f66209c --- /dev/null +++ b/Plaster/Private/New-JsonManifestStructure.ps1 @@ -0,0 +1,51 @@ +function New-JsonManifestStructure { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$TemplateName, + + [Parameter(Mandatory)] + [string]$TemplateType, + + [Parameter(Mandatory)] + [string]$Id, + + [Parameter()] + [string]$TemplateVersion = "1.0.0", + + [Parameter()] + [string]$Title = $TemplateName, + + [Parameter()] + [string]$Description = "", + + [Parameter()] + [string]$Author = "", + + [Parameter()] + [string[]]$Tags = @() + ) + + $manifest = [ordered]@{ + '$schema' = 'https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json' + 'schemaVersion' = '2.0' + 'metadata' = [ordered]@{ + 'name' = $TemplateName + 'id' = $Id + 'version' = $TemplateVersion + 'title' = $Title + 'description' = $Description + 'author' = $Author + 'templateType' = $TemplateType + } + 'parameters' = @() + 'content' = @() + } + + if ($Tags.Count -gt 0) { + $manifest.metadata['tags'] = $Tags + } + + return $manifest +} diff --git a/Plaster/Private/New-TemplateObjectFromManifest.ps1 b/Plaster/Private/New-TemplateObjectFromManifest.ps1 new file mode 100644 index 0000000..5bed6dd --- /dev/null +++ b/Plaster/Private/New-TemplateObjectFromManifest.ps1 @@ -0,0 +1,80 @@ +function New-TemplateObjectFromManifest { + <# + .SYNOPSIS + Creates a Plaster template object from a manifest file. + + .DESCRIPTION + This function takes a path to a Plaster manifest file and creates a + template object from its contents. + + .PARAMETER ManifestPath + The path to the Plaster manifest file. + + .PARAMETER Name + The name of the template. + If not specified, all templates will be returned. + + .PARAMETER Tag + The tag of the template. + If not specified, templates with any tag will be returned. + + .EXAMPLE + Get-TemplateObjectFromManifest -ManifestPath "C:\Templates\MyTemplate\plasterManifest.xml" -Name "MyTemplate" -Tag "Tag1" + + Retrieves a template object for the specified manifest file with the given name and tag. + .NOTES + This function is used internally by Plaster to manage templates. + It is not intended for direct use by end users. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param ( + [System.IO.FileInfo]$ManifestPath, + [string]$Name, + [string]$Tag + ) + + try{ + $manifestXml = Test-PlasterManifest -Path $ManifestPath + $metadata = $manifestXml["plasterManifest"]["metadata"] + + $manifestObj = [PSCustomObject]@{ + Name = [string]$metadata.name + Title = [string]$metadata.title + Author = [string]$metadata.author + Version = [System.Version]::Parse([string]$metadata.version) + Description = if ($metadata.description) { [string]$metadata.description } else { "" } + Tags = if ($metadata.tags) { ([string]$metadata.tags).split(",") | ForEach-Object { $_.Trim() } } else { @() } + TemplatePath = $manifestPath.Directory.FullName + Format = if ($manifestPath.Extension -eq '.json') { 'JSON' } else { 'XML' } + } + + $manifestObj.PSTypeNames.Insert(0, "Microsoft.PowerShell.Plaster.PlasterTemplate") + $addMemberSplat = @{ + MemberType = 'ScriptMethod' + InputObject = $manifestObj + Name = "InvokePlaster" + Value = { Invoke-Plaster -TemplatePath $this.TemplatePath } + } + Add-Member @addMemberSplat + + # Fix the filtering logic + $result = $manifestObj + if ($name -and $name -ne "*") { + $result = $result | Where-Object Name -like $name + } + if ($tag -and $tag -ne "*") { + # Only filter by tags if the template actually has tags + if ($result.Tags -and $result.Tags.Count -gt 0) { + $result = $result | Where-Object { $_.Tags -contains $tag -or ($_.Tags | Where-Object { $_ -like $tag }) } + } elseif ($tag -ne "*") { + # If template has no tags but we're filtering for a specific tag, exclude it + $result = $null + } + } + return $result + } catch { + Write-Debug "Failed to process manifest at $($manifestPath.FullName): $($_.Exception.Message)" + return $null + } +} diff --git a/Plaster/Private/Read-PromptForChoice.ps1 b/Plaster/Private/Read-PromptForChoice.ps1 new file mode 100644 index 0000000..3f47ed2 --- /dev/null +++ b/Plaster/Private/Read-PromptForChoice.ps1 @@ -0,0 +1,48 @@ +function Read-PromptForChoice { + [CmdletBinding()] + param( + [string] + $ParameterName, + [ValidateNotNull()] + $ChoiceNodes, + [string] + $prompt, + [int[]] + $defaults, + [switch] + $IsMultiChoice + ) + $choices = New-Object 'System.Collections.ObjectModel.Collection[System.Management.Automation.Host.ChoiceDescription]' + $values = New-Object object[] $ChoiceNodes.Count + $i = 0 + + foreach ($choiceNode in $ChoiceNodes) { + $label = Resolve-AttributeValue $choiceNode.label (Get-ErrorLocationParameterAttrVal $ParameterName label) + $help = Resolve-AttributeValue $choiceNode.help (Get-ErrorLocationParameterAttrVal $ParameterName help) + $value = Resolve-AttributeValue $choiceNode.value (Get-ErrorLocationParameterAttrVal $ParameterName value) + + $choice = New-Object System.Management.Automation.Host.ChoiceDescription -Arg $label, $help + $choices.Add($choice) + $values[$i++] = $value + } + + $returnValue = [PSCustomObject]@{Values = @(); Indices = @() } + + if ($IsMultiChoice) { + $selections = $Host.UI.PromptForChoice('', $prompt, $choices, $defaults) + foreach ($selection in $selections) { + $returnValue.Values += $values[$selection] + $returnValue.Indices += $selection + } + } else { + if ($defaults.Count -gt 1) { + throw ($LocalizedData.ParameterTypeChoiceMultipleDefault_F1 -f $ChoiceNodes.ParentNode.name) + } + + $selection = $Host.UI.PromptForChoice('', $prompt, $choices, $defaults[0]) + $returnValue.Values = $values[$selection] + $returnValue.Indices = $selection + } + + $returnValue +} diff --git a/Plaster/Private/Read-PromptForInput.ps1 b/Plaster/Private/Read-PromptForInput.ps1 new file mode 100644 index 0000000..e4aa2bf --- /dev/null +++ b/Plaster/Private/Read-PromptForInput.ps1 @@ -0,0 +1,27 @@ +function Read-PromptForInput { + [CmdletBinding()] + param( + $prompt, + $default, + $pattern + ) + if (!$pattern) { + $patternMatch = $true + } + + do { + $value = Read-Host -Prompt $prompt + if (!$value -and $default) { + $value = $default + $patternMatch = $true + } elseif ($value -and $pattern) { + if ($value -match $pattern) { + $patternMatch = $true + } else { + $PSCmdlet.WriteDebug("Value '$value' did not match the pattern '$pattern'") + } + } + } while (!$value -or !$patternMatch) + + $value +} diff --git a/Plaster/Private/Resolve-AttributeValue.ps1 b/Plaster/Private/Resolve-AttributeValue.ps1 new file mode 100644 index 0000000..934608f --- /dev/null +++ b/Plaster/Private/Resolve-AttributeValue.ps1 @@ -0,0 +1,26 @@ +function Resolve-AttributeValue { + [CmdletBinding()] + param( + [string]$Value, + [string]$Location + ) + + if ($null -eq $Value) { + return [string]::Empty + } elseif ([string]::IsNullOrWhiteSpace($Value)) { + return $Value + } + + try { + # Handle both XML-style ${PLASTER_PARAM_Name} and JSON-style ${Name} variables + if ($manifestType -eq 'JSON') { + # Convert JSON-style variables to XML-style for processing + $Value = $Value -replace '\$\{(?!PLASTER_)([A-Za-z][A-Za-z0-9_]*)\}', '${PLASTER_PARAM_$1}' + } + + $res = @(Invoke-ExpressionImpl "`"$Value`"") + [string]$res[0] + } catch { + throw ($LocalizedData.InterpolationError_F3 -f $Value.Trim(), $Location, $_) + } +} diff --git a/Plaster/Private/Resolve-ModuleVersionString.ps1 b/Plaster/Private/Resolve-ModuleVersionString.ps1 new file mode 100644 index 0000000..f451aad --- /dev/null +++ b/Plaster/Private/Resolve-ModuleVersionString.ps1 @@ -0,0 +1,52 @@ +function Resolve-ModuleVersionString { + <# + .SYNOPSIS + Resolve a module version string to a System.Version or + System.Management.Automation.SemanticVersion object. + + .DESCRIPTION + This function takes a version string and returns a parsed version object. + It ensures that the version string is in a valid format, particularly for + Semantic Versioning 2.0, which requires at least three components + (major.minor.patch). If the patch component is missing, the function will + append ".0" to the version string. + + .PARAMETER versionString + The version string to resolve. + + .EXAMPLE + Resolve-ModuleVersionString -versionString "1.2" + + This example resolves the version string "1.2" to a valid version object. + .NOTES + This function is designed to be used within the Plaster module to ensure consistent version handling. + It is not intended for direct use outside of the Plaster context. + #> + param( + [Parameter(Mandatory, Position = 0)] + [ValidateNotNullOrEmpty()] + $VersionString + ) + + # We're targeting Semantic Versioning 2.0 so make sure the version has + # at least 3 components (X.X.X). This logic ensures that the "patch" + # (third) component has been specified. + $versionParts = $VersionString.Split('.') + if ($versionParts.Length -lt 3) { + $VersionString = "$VersionString.0" + } + + if ($PSVersionTable.PSEdition -eq "Core") { + $newObjectSplat = @{ + TypeName = "System.Management.Automation.SemanticVersion" + ArgumentList = $VersionString + } + return New-Object @newObjectSplat + } else { + $newObjectSplat = @{ + TypeName = "System.Version" + ArgumentList = $VersionString + } + return New-Object @newObjectSplat + } +} diff --git a/Plaster/Private/Resolve-ProcessMessage.ps1 b/Plaster/Private/Resolve-ProcessMessage.ps1 new file mode 100644 index 0000000..d119044 --- /dev/null +++ b/Plaster/Private/Resolve-ProcessMessage.ps1 @@ -0,0 +1,23 @@ +function Resolve-ProcessMessage { + [CmdletBinding()] + param( + [ValidateNotNull()] + $Node + ) + $text = Resolve-AttributeValue $Node.InnerText '' + $noNewLine = $Node.nonewline -eq 'true' + + # Eliminate whitespace before and after the text that just happens to get inserted because you want + # the text on different lines than the start/end element tags. + $trimmedText = $text -replace '^[ \t]*\n', '' -replace '\n[ \t]*$', '' + + $condition = $Node.condition + if ($condition -and !(Test-ConditionAttribute $condition "'<$($Node.LocalName)>'")) { + $debugText = $trimmedText -replace '\r|\n', ' ' + $maxLength = [Math]::Min(40, $debugText.Length) + $PSCmdlet.WriteDebug("Skipping message '$($debugText.Substring(0, $maxLength))', condition evaluated to false.") + return + } + + Write-Host $trimmedText -NoNewline:($noNewLine -eq 'true') +} diff --git a/Plaster/Private/Resolve-ProcessNewModuleManifest.ps1 b/Plaster/Private/Resolve-ProcessNewModuleManifest.ps1 new file mode 100644 index 0000000..42d9afd --- /dev/null +++ b/Plaster/Private/Resolve-ProcessNewModuleManifest.ps1 @@ -0,0 +1,118 @@ +function Resolve-ProcessNewModuleManifest { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [ValidateNotNull()]$Node + ) + $moduleVersion = Resolve-AttributeValue $Node.moduleVersion (Get-ErrorLocationNewModManifestAttrVal moduleVersion) + $rootModule = Resolve-AttributeValue $Node.rootModule (Get-ErrorLocationNewModManifestAttrVal rootModule) + $author = Resolve-AttributeValue $Node.author (Get-ErrorLocationNewModManifestAttrVal author) + $companyName = Resolve-AttributeValue $Node.companyName (Get-ErrorLocationNewModManifestAttrVal companyName) + $description = Resolve-AttributeValue $Node.description (Get-ErrorLocationNewModManifestAttrVal description) + $dstRelPath = Resolve-AttributeValue $Node.destination (Get-ErrorLocationNewModManifestAttrVal destination) + $powerShellVersion = Resolve-AttributeValue $Node.powerShellVersion (Get-ErrorLocationNewModManifestAttrVal powerShellVersion) + $nestedModules = Resolve-AttributeValue $Node.NestedModules (Get-ErrorLocationNewModManifestAttrVal NestedModules) + $dscResourcesToExport = Resolve-AttributeValue $Node.DscResourcesToExport (Get-ErrorLocationNewModManifestAttrVal DscResourcesToExport) + $copyright = Resolve-AttributeValue $Node.copyright (Get-ErrorLocationNewModManifestAttrVal copyright) + + # We could choose to not check this if the condition eval'd to false + # but I think it is better to let the template author know they've broken the + # rules for any of the file directives (not just the ones they're testing/enabled). + if ([System.IO.Path]::IsPathRooted($dstRelPath)) { + throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $dstRelPath, $Node.LocalName) + } + + $dstPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath((Join-Path $DestinationPath $dstRelPath)) + + $condition = $Node.condition + if ($condition -and !(Test-ConditionAttribute $condition "'<$($Node.LocalName)>'")) { + $PSCmdlet.WriteDebug("Skipping module manifest generation for '$dstPath', condition evaluated to false.") + return + } + + $encoding = $Node.encoding + if (!$encoding) { + $encoding = $DefaultEncoding + } + + if ($PSCmdlet.ShouldProcess($dstPath, $LocalizedData.ShouldProcessNewModuleManifest)) { + $manifestDir = Split-Path $dstPath -Parent + if (!(Test-Path $manifestDir)) { + Test-PathIsUnderDestinationPath $manifestDir + Write-Verbose ($LocalizedData.NewModManifest_CreatingDir_F1 -f $manifestDir) + New-Item $manifestDir -ItemType Directory > $null + } + + $newModuleManifestParams = @{} + + # If there is an existing module manifest, load it so we can reuse old values not specified by + # template. + if (Test-Path -LiteralPath $dstPath) { + $manifestFileName = Split-Path $dstPath -Leaf + $newModuleManifestParams = Import-LocalizedData -BaseDirectory $manifestDir -FileName $manifestFileName + if ($newModuleManifestParams.PrivateData) { + $newModuleManifestParams += $newModuleManifestParams.PrivateData.psdata + $newModuleManifestParams.Remove('PrivateData') + } + } + + if (![string]::IsNullOrWhiteSpace($moduleVersion)) { + $newModuleManifestParams['ModuleVersion'] = $moduleVersion + } + if (![string]::IsNullOrWhiteSpace($rootModule)) { + $newModuleManifestParams['RootModule'] = $rootModule + } + if (![string]::IsNullOrWhiteSpace($author)) { + $newModuleManifestParams['Author'] = $author + } + if (![string]::IsNullOrWhiteSpace($companyName)) { + $newModuleManifestParams['CompanyName'] = $companyName + } + if (![string]::IsNullOrWhiteSpace($copyright)) { + $newModuleManifestParams['Copyright'] = $copyright + } + if (![string]::IsNullOrWhiteSpace($description)) { + $newModuleManifestParams['Description'] = $description + } + if (![string]::IsNullOrWhiteSpace($powerShellVersion)) { + $newModuleManifestParams['PowerShellVersion'] = $powerShellVersion + } + if (![string]::IsNullOrWhiteSpace($nestedModules)) { + $newModuleManifestParams['NestedModules'] = $nestedModules + } + if (![string]::IsNullOrWhiteSpace($dscResourcesToExport)) { + $newModuleManifestParams['DscResourcesToExport'] = $dscResourcesToExport + } + + $tempFile = $null + + try { + $tempFileBaseName = "moduleManifest-" + [Guid]::NewGuid() + $tempFile = [System.IO.Path]::GetTempPath() + "${tempFileBaseName}.psd1" + $PSCmdlet.WriteDebug("Created temp file for new module manifest - $tempFile") + $newModuleManifestParams['Path'] = $tempFile + + # Generate manifest into a temp file. + New-ModuleManifest @newModuleManifestParams + + # Typically the manifest is re-written with a new encoding (UTF8-NoBOM) because Git hates UTF-16. + $content = Get-Content -LiteralPath $tempFile -Raw + + # Replace the temp filename in the generated manifest file's comment header with the actual filename. + $dstBaseName = [System.IO.Path]::GetFileNameWithoutExtension($dstPath) + $content = $content -replace "(?<=\s*#.*?)$tempFileBaseName", $dstBaseName + + Write-ContentWithEncoding -Path $tempFile -Content $content -Encoding $encoding + + Copy-FileWithConflictDetection $tempFile $dstPath + + if ($PassThru -and ($Node.openInEditor -eq 'true')) { + $InvokePlasterInfo.OpenFiles += $dstPath + } + } finally { + if ($tempFile -and (Test-Path $tempFile)) { + Remove-Item -LiteralPath $tempFile + $PSCmdlet.WriteDebug("Removed temp file for new module manifest - $tempFile") + } + } + } +} diff --git a/Plaster/Private/Resolve-ProcessParameter.ps1 b/Plaster/Private/Resolve-ProcessParameter.ps1 new file mode 100644 index 0000000..17acb9d --- /dev/null +++ b/Plaster/Private/Resolve-ProcessParameter.ps1 @@ -0,0 +1,153 @@ +function Resolve-ProcessParameter { + [CmdletBinding()] + param( + [ValidateNotNull()]$Node + ) + + $name = $Node.name + $type = $Node.type + $store = $Node.store + + $pattern = $Node.pattern + + $condition = $Node.condition + + $default = Resolve-AttributeValue $Node.default (Get-ErrorLocationParameterAttrVal $name default) + + if ($condition -and !(Test-ConditionAttribute $condition "'<$($Node.LocalName)>'")) { + if (-not [string]::IsNullOrEmpty($default) -and $type -eq 'text') { + Set-PlasterVariable -Name $name -Value $default -IsParam $true + $PSCmdlet.WriteDebug("The condition of the parameter $($name) with the type 'text' evaluated to false. The parameter has a default value which will be used.") + } else { + # Define the parameter so later conditions can use it but its value will be $null + Set-PlasterVariable -Name $name -Value $null -IsParam $true + $PSCmdlet.WriteDebug("Skipping parameter $($name), condition evaluated to false.") + } + + return + } + + $prompt = Resolve-AttributeValue $Node.prompt (Get-ErrorLocationParameterAttrVal $name prompt) + + # Check if parameter was provided via a dynamic parameter. + if ($script:boundParameters.ContainsKey($name)) { + $value = $script:boundParameters[$name] + } else { + # Not a dynamic parameter so prompt user for the value but first check for a stored default value. + if ($store -and ($null -ne $script:defaultValueStore[$name])) { + $default = $script:defaultValueStore[$name] + $PSCmdlet.WriteDebug("Read default value '$default' for parameter '$name' from default value store.") + + if (($store -eq 'encrypted') -and ($default -is [System.Security.SecureString])) { + try { + $cred = New-Object -TypeName PSCredential -ArgumentList 'jsbplh', $default + $default = $cred.GetNetworkCredential().Password + $PSCmdlet.WriteDebug("Unencrypted default value for parameter '$name'.") + } catch [System.Exception] { + Write-Warning ($LocalizedData.ErrorUnencryptingSecureString_F1 -f $name) + } + } + } + + # If the prompt message failed to evaluate or was empty, supply a diagnostic prompt message + if (!$prompt) { + $prompt = $LocalizedData.MissingParameterPrompt_F1 -f $name + } + + # Some default values might not come from the template e.g. some are harvested from .gitconfig if it exists. + $defaultNotFromTemplate = $false + + $splat = @{} + + if ($null -ne $pattern) { + $splat.Add('pattern', $pattern) + } + + # Now prompt user for parameter value based on the parameter type. + switch -regex ($type) { + 'text' { + # Display an appropriate "default" value in the prompt string. + if ($default) { + if ($store -eq 'encrypted') { + $obscuredDefault = $default -replace '(....).*', '$1****' + $prompt += " ($obscuredDefault)" + } else { + $prompt += " ($default)" + } + } + # Prompt the user for text input. + $value = Read-PromptForInput $prompt $default @splat + $valueToStore = $value + } + 'user-fullname' { + # If no default, try to get a name from git config. + if (!$default) { + $default = Get-GitConfigValue('name') + $defaultNotFromTemplate = $true + } + + if ($default) { + if ($store -eq 'encrypted') { + $obscuredDefault = $default -replace '(....).*', '$1****' + $prompt += " ($obscuredDefault)" + } else { + $prompt += " ($default)" + } + } + + # Prompt the user for text input. + $value = Read-PromptForInput $prompt $default @splat + $valueToStore = $value + } + 'user-email' { + # If no default, try to get an email from git config + if (-not $default) { + $default = Get-GitConfigValue('email') + $defaultNotFromTemplate = $true + } + + if ($default) { + if ($store -eq 'encrypted') { + $obscuredDefault = $default -replace '(....).*', '$1****' + $prompt += " ($obscuredDefault)" + } else { + $prompt += " ($default)" + } + } + + # Prompt the user for text input. + $value = Read-PromptForInput $prompt $default @splat + $valueToStore = $value + } + 'choice|multichoice' { + $choices = $Node.ChildNodes + $defaults = [int[]]($default -split ',') + + # Prompt the user for choice or multichoice selection input. + $selections = Read-PromptForChoice $name $choices $prompt $defaults -IsMultiChoice:($type -eq 'multichoice') + $value = $selections.Values + $OFS = "," + $valueToStore = "$($selections.Indices)" + } + default { throw ($LocalizedData.UnrecognizedParameterType_F2 -f $type, $Node.LocalName) } + } + + # If parameter specifies that user's input be stored as the default value, + # store it to file if the value has changed. + if ($store -and (($default -ne $valueToStore) -or $defaultNotFromTemplate)) { + if ($store -eq 'encrypted') { + $PSCmdlet.WriteDebug("Storing new, encrypted default value for parameter '$name' to default value store.") + $script:defaultValueStore[$name] = ConvertTo-SecureString -String $valueToStore -AsPlainText -Force + } else { + $PSCmdlet.WriteDebug("Storing new default value '$valueToStore' for parameter '$name' to default value store.") + $script:defaultValueStore[$name] = $valueToStore + } + + $script:flags.DefaultValueStoreDirty = $true + } + } + + # Make template defined parameters available as a PowerShell variable PLASTER_PARAM_. + Set-PlasterVariable -Name $name -Value $value -IsParam $true + Write-PlasterLog -Level Debug -Message "Set parameter variable: PLASTER_PARAM_$name = $value" +} diff --git a/Plaster/Private/Set-PlasterVariable.ps1 b/Plaster/Private/Set-PlasterVariable.ps1 new file mode 100644 index 0000000..13a19e2 --- /dev/null +++ b/Plaster/Private/Set-PlasterVariable.ps1 @@ -0,0 +1,61 @@ +function Set-PlasterVariable { + <# + .SYNOPSIS + Sets a Plaster variable in the script scope and updates the + ConstrainedRunspace if it exists. + + .DESCRIPTION + This function sets a variable in the script scope and updates the + ConstrainedRunspace if it exists. It is used to manage Plaster variables, + which can be parameters or other types of variables. + + .PARAMETER Name + The name of the variable to set. + + .PARAMETER Value + The value to assign to the variable. + + .PARAMETER IsParam + Indicates if the variable is a parameter. + If true, the variable is treated as a Plaster parameter and prefixed with + "PLASTER_PARAM_". + + .EXAMPLE + Set-PlasterVariable -Name "MyVariable" -Value "MyValue" -IsParam $true + + Sets a Plaster parameter variable named "PLASTER_PARAM_MyVariable" with the + value "MyValue". + .NOTES + All Plaster variables should be set via this method so that the + ConstrainedRunspace can be configured to use the new variable. This method + will null out the ConstrainedRunspace so that later, when we need to + evaluate script in that runspace, it will get recreated first with all + the latest Plaster variables. + #> + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Name, + + [Parameter(Mandatory = $true)] + [AllowNull()] + $Value, + + [Parameter()] + [bool] + $IsParam = $true + ) + + # Variables created from a in the Plaster manifest are prefixed + # PLASTER_PARAM all others are just PLASTER_. + $variableName = if ($IsParam) { "PLASTER_PARAM_$Name" } else { "PLASTER_$Name" } + + Set-Variable -Name $variableName -Value $Value -Scope Script -WhatIf:$false + + # If the constrained runspace has been created, it needs to be disposed so that the next string + # expansion (or condition eval) gets an updated runspace that contains this variable or its new value. + if ($null -ne $script:ConstrainedRunspace) { + $script:ConstrainedRunspace.Dispose() + $script:ConstrainedRunspace = $null + } +} diff --git a/Plaster/Private/Start-ProcessFile.ps1 b/Plaster/Private/Start-ProcessFile.ps1 new file mode 100644 index 0000000..52f5bd8 --- /dev/null +++ b/Plaster/Private/Start-ProcessFile.ps1 @@ -0,0 +1,123 @@ +# Processes both the and directives. +function Start-ProcessFile { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [ValidateNotNull()] + $Node + ) + $srcRelPath = Resolve-AttributeValue $Node.source (Get-ErrorLocationFileAttrVal $Node.localName source) + $dstRelPath = Resolve-AttributeValue $Node.destination (Get-ErrorLocationFileAttrVal $Node.localName destination) + + $condition = $Node.condition + if ($condition -and !(Test-ConditionAttribute $condition "'<$($Node.LocalName)>'")) { + $PSCmdlet.WriteDebug("Skipping $($Node.localName) '$srcRelPath' -> '$dstRelPath', condition evaluated to false.") + return + } + + # Only validate paths for conditions that evaluate to true. + # The path may not be valid if it evaluates to false depending + # on whether or not conditional parameters are used in the template. + if ([System.IO.Path]::IsPathRooted($srcRelPath)) { + throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $srcRelPath, $Node.LocalName) + } + + if ([System.IO.Path]::IsPathRooted($dstRelPath)) { + throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $dstRelPath, $Node.LocalName) + } + + # Check if node is the specialized, node. + # Only nodes expand templates and use the encoding attribute. + $isTemplateFile = $Node.localName -eq 'templateFile' + if ($isTemplateFile) { + $encoding = $Node.encoding + if (!$encoding) { + $encoding = $DefaultEncoding + } + } + + # Check if source specifies a wildcard and if so, expand the wildcard + # and then process each file system object (file or empty directory). + $expandFileSourceSpecSplat = @{ + SourceRelativePath = $srcRelPath + DestinationRelativePath = $dstRelPath + } + $fileSystemCopyInfoObjs = Expand-FileSourceSpec @expandFileSourceSpecSplat + foreach ($fileSystemCopyInfo in $fileSystemCopyInfoObjs) { + $srcPath = $fileSystemCopyInfo.SrcFileName + $dstPath = $fileSystemCopyInfo.DstFileName + + # The file's destination path must be under the DestinationPath specified by the user. + Test-PathIsUnderDestinationPath $dstPath + + # Check to see if we're copying an empty dir + if (Test-Path -LiteralPath $srcPath -PathType Container) { + if (!(Test-Path -LiteralPath $dstPath)) { + if ($PSCmdlet.ShouldProcess($parentDir, $LocalizedData.ShouldProcessCreateDir)) { + Write-OperationStatus $LocalizedData.OpCreate ` + ($dstRelPath.TrimEnd(([char]'\'), ([char]'/')) + [System.IO.Path]::DirectorySeparatorChar) + New-Item -Path $dstPath -ItemType Directory > $null + } + } + + continue + } + + # If the file's parent dir doesn't exist, create it. + $parentDir = Split-Path $dstPath -Parent + if (!(Test-Path -LiteralPath $parentDir)) { + if ($PSCmdlet.ShouldProcess($parentDir, $LocalizedData.ShouldProcessCreateDir)) { + New-Item -Path $parentDir -ItemType Directory > $null + } + } + + $tempFile = $null + + try { + # If processing a , copy to a temp file to expand the template file, + # then apply the normal file conflict detection/resolution handling. + $target = $LocalizedData.TempFileTarget_F1 -f (ConvertTo-DestinationRelativePath $dstPath) + if ($isTemplateFile -and $PSCmdlet.ShouldProcess($target, $LocalizedData.ShouldProcessExpandTemplate)) { + $content = Get-Content -LiteralPath $srcPath -Raw + + # Eval script expression delimiters + if ($content -and ($content.Count -gt 0)) { + $newContent = [regex]::Replace($content, '(<%=)(.*?)(%>)', { + param($match) + $expr = $match.groups[2].value + $res = Test-Expression $expr "templateFile '$srcRelPath'" + $PSCmdlet.WriteDebug("Replacing '$expr' with '$res' in contents of template file '$srcPath'") + $res + }, @('IgnoreCase')) + + # Eval script block delimiters + $newContent = [regex]::Replace($newContent, '(^<%)(.*?)(^%>)', { + param($match) + $expr = $match.groups[2].value + $res = Test-Script $expr "templateFile '$srcRelPath'" + $res = $res -join [System.Environment]::NewLine + $PSCmdlet.WriteDebug("Replacing '$expr' with '$res' in contents of template file '$srcPath'") + $res + }, @('IgnoreCase', 'SingleLine', 'MultiLine')) + + $srcPath = $tempFile = [System.IO.Path]::GetTempFileName() + $PSCmdlet.WriteDebug("Created temp file for expanded templateFile - $tempFile") + + Write-ContentWithEncoding -Path $tempFile -Content $newContent -Encoding $encoding + } else { + $PSCmdlet.WriteDebug("Skipping template file expansion for $($Node.localName) '$srcPath', file is empty.") + } + } + + Copy-FileWithConflictDetection $srcPath $dstPath + + if ($PassThru -and ($Node.openInEditor -eq 'true')) { + $InvokePlasterInfo.OpenFiles += $dstPath + } + } finally { + if ($tempFile -and (Test-Path $tempFile)) { + Remove-Item -LiteralPath $tempFile + $PSCmdlet.WriteDebug("Removed temp file for expanded templateFile - $tempFile") + } + } + } +} diff --git a/Plaster/Private/Start-ProcessFileProcessRequireModule.ps1 b/Plaster/Private/Start-ProcessFileProcessRequireModule.ps1 new file mode 100644 index 0000000..218dd9e --- /dev/null +++ b/Plaster/Private/Start-ProcessFileProcessRequireModule.ps1 @@ -0,0 +1,97 @@ +function Start-ProcessFileProcessRequireModule { + [CmdletBinding()] + param( + [ValidateNotNull()] + $Node + ) + + $name = $Node.name + + $condition = $Node.condition + if ($condition -and !(Test-ConditionAttribute $condition "'<$($Node.LocalName)>'")) { + $PSCmdlet.WriteDebug("Skipping $($Node.localName) for module '$name', condition evaluated to false.") + return + } + + $message = Resolve-AttributeValue $Node.message (Get-ErrorLocationRequireModuleAttrVal $name message) + $minimumVersion = $Node.minimumVersion + $maximumVersion = $Node.maximumVersion + $requiredVersion = $Node.requiredVersion + + $getModuleParams = @{ + ListAvailable = $true + ErrorAction = 'SilentlyContinue' + } + + # Configure $getModuleParams with correct parameters based on parameterset to be used. + # Also construct an array of version strings that can be displayed to the user. + $versionInfo = @() + if ($requiredVersion) { + $getModuleParams["FullyQualifiedName"] = @{ModuleName = $name; RequiredVersion = $requiredVersion } + $versionInfo += $LocalizedData.RequireModuleRequiredVersion_F1 -f $requiredVersion + } elseif ($minimumVersion -or $maximumVersion) { + $getModuleParams["FullyQualifiedName"] = @{ModuleName = $name } + + if ($minimumVersion) { + $getModuleParams.FullyQualifiedName["ModuleVersion"] = $minimumVersion + $versionInfo += $LocalizedData.RequireModuleMinVersion_F1 -f $minimumVersion + } + if ($maximumVersion) { + $getModuleParams.FullyQualifiedName["MaximumVersion"] = $maximumVersion + $versionInfo += $LocalizedData.RequireModuleMaxVersion_F1 -f $maximumVersion + } + } else { + $getModuleParams["Name"] = $name + } + + # Flatten array of version strings into a single string. + $versionRequirements = "" + if ($versionInfo.Length -gt 0) { + $OFS = ", " + $versionRequirements = " ($versionInfo)" + } + + # PowerShell v3 Get-Module command does not have the FullyQualifiedName parameter. + if ($PSVersionTable.PSVersion.Major -lt 4) { + $getModuleParams.Remove("FullyQualifiedName") + $getModuleParams["Name"] = $name + } + + $module = Get-Module @getModuleParams + + $moduleDesc = if ($versionRequirements) { "${name}:$versionRequirements" } else { $name } + + if ($null -eq $module) { + Write-OperationStatus $LocalizedData.OpMissing ($LocalizedData.RequireModuleMissing_F2 -f $name, $versionRequirements) + if ($message) { + Write-OperationAdditionalStatus $message + } + if ($PassThru) { + $InvokePlasterInfo.MissingModules += $moduleDesc + } + } else { + if ($PSVersionTable.PSVersion.Major -gt 3) { + Write-OperationStatus $LocalizedData.OpVerify ($LocalizedData.RequireModuleVerified_F2 -f $name, $versionRequirements) + } else { + # On V3, we have to the version matching with the results that Get-Module return. + $installedVersion = $module | Sort-Object Version -Descending | Select-Object -First 1 | ForEach-Object Version + if ($installedVersion.Build -eq -1) { + $installedVersion = [System.Version]"${installedVersion}.0.0" + } elseif ($installedVersion.Revision -eq -1) { + $installedVersion = [System.Version]"${installedVersion}.0" + } + + if (($requiredVersion -and ($installedVersion -ne $requiredVersion)) -or + ($minimumVersion -and ($installedVersion -lt $minimumVersion)) -or + ($maximumVersion -and ($installedVersion -gt $maximumVersion))) { + + Write-OperationStatus $LocalizedData.OpMissing ($LocalizedData.RequireModuleMissing_F2 -f $name, $versionRequirements) + if ($PassThru) { + $InvokePlasterInfo.MissingModules += $moduleDesc + } + } else { + Write-OperationStatus $LocalizedData.OpVerify ($LocalizedData.RequireModuleVerified_F2 -f $name, $versionRequirements) + } + } + } +} diff --git a/Plaster/Private/Start-ProcessModifyFile.ps1 b/Plaster/Private/Start-ProcessModifyFile.ps1 new file mode 100644 index 0000000..6c61c77 --- /dev/null +++ b/Plaster/Private/Start-ProcessModifyFile.ps1 @@ -0,0 +1,126 @@ +function Start-ProcessModifyFile { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [ValidateNotNull()] + $Node + ) + $path = Resolve-AttributeValue $Node.path (Get-ErrorLocationModifyAttrVal path) + + # We could choose to not check this if the condition eval'd to false + # but I think it is better to let the template author know they've broken the + # rules for any of the file directives (not just the ones they're testing/enabled). + if ([System.IO.Path]::IsPathRooted($path)) { + throw ($LocalizedData.ErrorPathMustBeRelativePath_F2 -f $path, $Node.LocalName) + } + + $filePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath((Join-Path $DestinationPath $path)) + + # The file's path must be under the DestinationPath specified by the user. + Test-PathIsUnderDestinationPath $filePath + + $condition = $Node.condition + if ($condition -and !(Test-ConditionAttribute $condition "'<$($Node.LocalName)>'")) { + $PSCmdlet.WriteDebug("Skipping $($Node.LocalName) of '$filePath', condition evaluated to false.") + return + } + + $fileContent = [string]::Empty + if (Test-Path -LiteralPath $filePath) { + $fileContent = Get-Content -LiteralPath $filePath -Raw + } + + # Set a Plaster (non-parameter) variable in this and the constrained runspace. + Set-PlasterVariable -Name 'FileContent' -Value $fileContent -IsParam $false + + $encoding = $Node.encoding + if (!$encoding) { + $encoding = $DefaultEncoding + } + + # If processing a directive, write the modified contents to a temp file, + # then apply the normal file conflict detection/resolution handling. + $target = $LocalizedData.TempFileTarget_F1 -f $filePath + if ($PSCmdlet.ShouldProcess($target, $LocalizedData.OpModify)) { + Write-OperationStatus $LocalizedData.OpModify ($LocalizedData.TempFileOperation_F1 -f (ConvertTo-DestinationRelativePath $filePath)) + + $modified = $false + + foreach ($childNode in $Node.ChildNodes) { + if ($childNode -isnot [System.Xml.XmlElement]) { continue } + + switch ($childNode.LocalName) { + 'replace' { + $condition = $childNode.condition + if ($condition -and !(Test-ConditionAttribute $condition "'<$($Node.LocalName)><$($childNode.LocalName)>'")) { + $PSCmdlet.WriteDebug("Skipping $($Node.LocalName) $($childNode.LocalName) of '$filePath', condition evaluated to false.") + continue + } + + if ($childNode.original -is [string]) { + $original = $childNode.original + } else { + $original = $childNode.original.InnerText + } + + if ($childNode.original.expand -eq 'true') { + $original = Resolve-AttributeValue $original (Get-ErrorLocationModifyAttrVal original) + } + + if ($childNode.substitute -is [string]) { + $substitute = $childNode.substitute + } else { + $substitute = $childNode.substitute.InnerText + } + + if ($childNode.substitute.isFile -eq 'true') { + $substitute = Get-PSSnippetFunction $substitute + } elseif ($childNode.substitute.expand -eq 'true') { + $substitute = Resolve-AttributeValue $substitute (Get-ErrorLocationModifyAttrVal substitute) + } + + # Perform Literal Replacement on FileContent (since it will have regex characters) + if ($childNode.substitute.isFile) { + $fileContent = $fileContent.Replace($original, $substitute) + } else { + $fileContent = $fileContent -replace $original, $substitute + } + + # Update the Plaster (non-parameter) variable's value in this and the constrained runspace. + Set-PlasterVariable -Name FileContent -Value $fileContent -IsParam $false + + $modified = $true + } + default { throw ($LocalizedData.UnrecognizedContentElement_F1 -f $childNode.LocalName) } + } + } + + $tempFile = $null + + try { + # We could use Copy-FileWithConflictDetection to handle the "identical" (not modified) case + # but if nothing was changed, I'd prefer not to generate a temp file, copy the unmodified contents + # into that temp file with hopefully the right encoding and then potentially overwrite the original file + # (different encoding will make the files look different) with the same contents but different encoding. + # If the intent of the was simply to change an existing file's encoding then the directive will + # need to make a whitespace change to the file. + if ($modified) { + $tempFile = [System.IO.Path]::GetTempFileName() + $PSCmdlet.WriteDebug("Created temp file for modified file - $tempFile") + + Write-ContentWithEncoding -Path $tempFile -Content $PLASTER_FileContent -Encoding $encoding + Copy-FileWithConflictDetection $tempFile $filePath + + if ($PassThru -and ($Node.openInEditor -eq 'true')) { + $InvokePlasterInfo.OpenFiles += $filePath + } + } else { + Write-OperationStatus $LocalizedData.OpIdentical (ConvertTo-DestinationRelativePath $filePath) + } + } finally { + if ($tempFile -and (Test-Path $tempFile)) { + Remove-Item -LiteralPath $tempFile + $PSCmdlet.WriteDebug("Removed temp file for modified file - $tempFile") + } + } + } +} diff --git a/Plaster/Private/Test-ConditionAttribute.ps1 b/Plaster/Private/Test-ConditionAttribute.ps1 new file mode 100644 index 0000000..05cbb2b --- /dev/null +++ b/Plaster/Private/Test-ConditionAttribute.ps1 @@ -0,0 +1,19 @@ +function Test-ConditionAttribute { + [CmdletBinding()] + param( + [string]$Expression, + [string]$Location + ) + if ($null -eq $Expression) { + return [string]::Empty + } elseif ([string]::IsNullOrWhiteSpace($Expression)) { + return $Expression + } + + try { + $res = @(Invoke-ExpressionImpl $Expression) + [bool]$res[0] + } catch { + throw ($LocalizedData.ExpressionInvalidCondition_F3 -f $Expression, $Location, $_) + } +} diff --git a/Plaster/Private/Test-Expression.ps1 b/Plaster/Private/Test-Expression.ps1 new file mode 100644 index 0000000..4e88cf8 --- /dev/null +++ b/Plaster/Private/Test-Expression.ps1 @@ -0,0 +1,19 @@ +function Test-Expression { + [CmdletBinding()] + param( + [string]$Expression, + [string]$Location + ) + if ($null -eq $Expression) { + return [string]::Empty + } elseif ([string]::IsNullOrWhiteSpace($Expression)) { + return $Expression + } + + try { + $res = @(Invoke-ExpressionImpl $Expression) + [string]$res[0] + } catch { + throw ($LocalizedData.ExpressionExecError_F2 -f $Location, $_) + } +} diff --git a/Plaster/Private/Test-FilesIdentical.ps1 b/Plaster/Private/Test-FilesIdentical.ps1 new file mode 100644 index 0000000..4256496 --- /dev/null +++ b/Plaster/Private/Test-FilesIdentical.ps1 @@ -0,0 +1,18 @@ +function Test-FilesIdentical { + [CmdletBinding()] + param( + $Path1, + $Path2 + ) + $file1 = Get-Item -LiteralPath $Path1 -Force + $file2 = Get-Item -LiteralPath $Path2 -Force + + if ($file1.Length -ne $file2.Length) { + return $false + } + + $hash1 = (Get-FileHash -LiteralPath $path1 -Algorithm SHA1).Hash + $hash2 = (Get-FileHash -LiteralPath $path2 -Algorithm SHA1).Hash + + $hash1 -eq $hash2 +} diff --git a/Plaster/Private/Test-JsonManifest.ps1 b/Plaster/Private/Test-JsonManifest.ps1 new file mode 100644 index 0000000..d6e1dd9 --- /dev/null +++ b/Plaster/Private/Test-JsonManifest.ps1 @@ -0,0 +1,96 @@ +function Test-JsonManifest { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [string]$JsonContent, + + [Parameter()] + [string]$SchemaPath, + + [Parameter()] + [switch]$Detailed + ) + + begin { + Write-PlasterLog -Level Debug -Message "Starting JSON manifest validation" + + # Default schema path + if (-not $SchemaPath) { + $SchemaPath = Join-Path $PSScriptRoot "..\schema\plaster-manifest-v2.json" + } + } + + process { + try { + # Parse JSON content + $jsonObject = $JsonContent | ConvertFrom-Json -ErrorAction Stop + + # Basic structure validation + $requiredProperties = @('schemaVersion', 'metadata', 'content') + foreach ($property in $requiredProperties) { + if (-not $jsonObject.PSObject.Properties[$property]) { + throw "Missing required property: $property" + } + } + + # Schema version validation + if ($jsonObject.schemaVersion -ne '2.0') { + throw "Unsupported schema version: $($jsonObject.schemaVersion). Expected: 2.0" + } + + # Metadata validation + $metadata = $jsonObject.metadata + $requiredMetadata = @('name', 'id', 'version', 'title', 'author') + foreach ($property in $requiredMetadata) { + if (-not $metadata.PSObject.Properties[$property] -or [string]::IsNullOrWhiteSpace($metadata.$property)) { + throw "Missing or empty required metadata property: $property" + } + } + + # Validate GUID format for ID + try { + [Guid]::Parse($metadata.id) | Out-Null + } catch { + throw "Invalid GUID format for metadata.id: $($metadata.id)" + } + + # Validate semantic version format + if ($metadata.version -notmatch '^\d+\.\d+\.\d+([+-].*)?$') { + throw "Invalid version format: $($metadata.version). Expected semantic versioning (e.g., 1.0.0)" + } + + # Validate template name pattern + if ($metadata.name -notmatch '^[A-Za-z][A-Za-z0-9_-]*$') { + 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 + } else { + throw "Content section cannot be empty" + } + + Write-PlasterLog -Level Debug -Message "JSON manifest validation successful" + return $true + } catch { + $errorMessage = "JSON manifest validation failed: $($_.Exception.Message)" + Write-PlasterLog -Level Error -Message $errorMessage + + if ($Detailed) { + throw $_ + } + + return $false + } + } +} diff --git a/Plaster/Private/Test-JsonManifestContent.ps1 b/Plaster/Private/Test-JsonManifestContent.ps1 new file mode 100644 index 0000000..9556e7f --- /dev/null +++ b/Plaster/Private/Test-JsonManifestContent.ps1 @@ -0,0 +1,83 @@ +function Test-JsonManifestContent { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Content + ) + + if ($Content.Count -eq 0) { + throw "Content section cannot be empty" + } + + foreach ($action in $Content) { + if (-not $action.type) { + throw "Content action missing required 'type' property" + } + + # Validate action type and required properties + switch ($action.type) { + 'message' { + if (-not $action.text) { + throw "Message action missing required 'text' property" + } + } + 'file' { + if (-not $action.source -or -not $action.destination) { + throw "File action missing required 'source' or 'destination' property" + } + } + 'templateFile' { + if (-not $action.source -or -not $action.destination) { + throw "TemplateFile action missing required 'source' or 'destination' property" + } + } + 'directory' { + if (-not $action.destination) { + throw "Directory action missing required 'destination' property" + } + } + 'newModuleManifest' { + if (-not $action.destination) { + throw "NewModuleManifest action missing required 'destination' property" + } + } + 'modify' { + if (-not $action.path -or -not $action.modifications) { + throw "Modify action missing required 'path' or 'modifications' property" + } + + # Validate modifications + foreach ($modification in $action.modifications) { + if (-not $modification.type) { + throw "Modification missing required 'type' property" + } + + if ($modification.type -eq 'replace') { + if (-not $modification.PSObject.Properties['search'] -or -not $modification.PSObject.Properties['replace']) { + throw "Replace modification missing required 'search' or 'replace' property" + } + } + } + } + 'requireModule' { + if (-not $action.name) { + throw "RequireModule action missing required 'name' property" + } + } + 'execute' { + if (-not $action.script) { + throw "Execute action missing required 'script' property" + } + } + default { + throw "Unknown content action type: $($action.type)" + } + } + + # Validate condition if present + if ($action.condition) { + Test-PlasterCondition -Condition $action.condition -Context "Content action ($($action.type))" + } + } +} diff --git a/Plaster/Private/Test-JsonManifestParameters.ps1 b/Plaster/Private/Test-JsonManifestParameters.ps1 new file mode 100644 index 0000000..34e8b63 --- /dev/null +++ b/Plaster/Private/Test-JsonManifestParameters.ps1 @@ -0,0 +1,63 @@ +function Test-JsonManifestParameters { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Parameters + ) + + $parameterNames = @() + + foreach ($param in $Parameters) { + # Required properties + if (-not $param.name -or -not $param.type) { + throw "Parameter missing required 'name' or 'type' property" + } + + # Validate parameter name pattern + if ($param.name -notmatch '^[A-Za-z][A-Za-z0-9_]*$') { + throw "Invalid parameter name: $($param.name). Must start with letter and contain only letters, numbers, or underscore" + } + + # Check for duplicate parameter names + if ($param.name -in $parameterNames) { + throw "Duplicate parameter name: $($param.name)" + } + $parameterNames += $param.name + + # Validate parameter type + $validTypes = @('text', 'user-fullname', 'user-email', 'choice', 'multichoice', 'switch') + if ($param.type -notin $validTypes) { + throw "Invalid parameter type: $($param.type). Valid types: $($validTypes -join ', ')" + } + + # Choice parameters must have choices + if ($param.type -in @('choice', 'multichoice') -and -not $param.choices) { + throw "Parameter '$($param.name)' of type '$($param.type)' must have 'choices' property" + } + + # Validate choices if present + if ($param.choices) { + foreach ($choice in $param.choices) { + if (-not $choice.label -or -not $choice.value) { + throw "Choice in parameter '$($param.name)' missing required 'label' or 'value' property" + } + } + } + + # Validate dependsOn references + if ($param.dependsOn) { + foreach ($dependency in $param.dependsOn) { + if ($dependency -notin $parameterNames -and $dependency -ne $param.name) { + # Note: We'll validate this after processing all parameters + Write-PlasterLog -Level Debug -Message "Parameter '$($param.name)' depends on '$dependency'" + } + } + } + + # Validate condition syntax if present + if ($param.condition) { + Test-PlasterCondition -Condition $param.condition -ParameterName $param.name + } + } +} diff --git a/Plaster/Private/Test-PathIsUnderDestinationPath.ps1 b/Plaster/Private/Test-PathIsUnderDestinationPath.ps1 new file mode 100644 index 0000000..58cfddd --- /dev/null +++ b/Plaster/Private/Test-PathIsUnderDestinationPath.ps1 @@ -0,0 +1,20 @@ +function Test-PathIsUnderDestinationPath() { + [CmdletBinding()] + param( + [ValidateNotNullOrEmpty()] + [string] + $FullPath + ) + if (![System.IO.Path]::IsPathRooted($FullPath)) { + $PSCmdlet.WriteDebug("The FullPath parameter '$FullPath' must be an absolute path.") + } + + $fullDestPath = $DestinationPath + if (![System.IO.Path]::IsPathRooted($fullDestPath)) { + $fullDestPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) + } + + if (!$FullPath.StartsWith($fullDestPath, [StringComparison]::OrdinalIgnoreCase)) { + throw ($LocalizedData.ErrorPathMustBeUnderDestPath_F2 -f $FullPath, $fullDestPath) + } +} diff --git a/Plaster/Private/Test-PlasterCondition.ps1 b/Plaster/Private/Test-PlasterCondition.ps1 new file mode 100644 index 0000000..2e85ae8 --- /dev/null +++ b/Plaster/Private/Test-PlasterCondition.ps1 @@ -0,0 +1,34 @@ +function Test-PlasterCondition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Condition, + + [Parameter()] + [string]$ParameterName, + + [Parameter()] + [string]$Context = 'condition' + ) + + try { + # Basic syntax validation - ensure it's valid PowerShell + $tokens = $errors = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput($Condition, [ref]$tokens, [ref]$errors) + + if ($errors.Count -gt 0) { + $errorMsg = if ($ParameterName) { + "Invalid condition in parameter '$ParameterName': $($errors[0].Message)" + } else { + "Invalid condition in ${Context}: $($errors[0].Message)" + } + throw $errorMsg + } + + Write-PlasterLog -Level Debug -Message "Condition validation passed: $Condition" + return $true + } catch { + Write-PlasterLog -Level Error -Message "Condition validation failed: $($_.Exception.Message)" + throw $_ + } +} diff --git a/Plaster/Private/Test-Script.ps1 b/Plaster/Private/Test-Script.ps1 new file mode 100644 index 0000000..b8be199 --- /dev/null +++ b/Plaster/Private/Test-Script.ps1 @@ -0,0 +1,19 @@ +function Test-Script { + [CmdletBinding()] + param( + [string]$Script, + [string]$Location + ) + if ($null -eq $Script) { + return @([string]::Empty) + } elseif ([string]::IsNullOrWhiteSpace($Script)) { + return $Script + } + + try { + $res = @(Invoke-ExpressionImpl $Script) + [string[]]$res + } catch { + throw ($LocalizedData.ExpressionExecError_F2 -f $Location, $_) + } +} diff --git a/Plaster/Private/Write-ContentWithEncoding.ps1 b/Plaster/Private/Write-ContentWithEncoding.ps1 new file mode 100644 index 0000000..d69c4cd --- /dev/null +++ b/Plaster/Private/Write-ContentWithEncoding.ps1 @@ -0,0 +1,28 @@ +function Write-ContentWithEncoding { + [CmdletBinding()] + param( + [string] + $Path, + [string[]] + $Content, + [string] + $Encoding + ) + + if ($Encoding -match '-nobom') { + $Encoding, $dummy = $Encoding -split '-' + + $noBomEncoding = $null + switch ($Encoding) { + 'utf8' { $noBomEncoding = New-Object System.Text.UTF8Encoding($false) } + } + + if ($null -eq $Content) { + $Content = [string]::Empty + } + + [System.IO.File]::WriteAllLines($Path, $Content, $noBomEncoding) + } else { + Set-Content -LiteralPath $Path -Value $Content -Encoding $Encoding + } +} diff --git a/Plaster/Private/Write-OperationAdditionalStatus.ps1 b/Plaster/Private/Write-OperationAdditionalStatus.ps1 new file mode 100644 index 0000000..f2f261a --- /dev/null +++ b/Plaster/Private/Write-OperationAdditionalStatus.ps1 @@ -0,0 +1,13 @@ +function Write-OperationAdditionalStatus { + [CmdletBinding()] + param( + [string[]]$Message + ) + $maxLen = Get-MaxOperationLabelLength + foreach ($msg in $Message) { + $lines = $msg -split "`n" + foreach ($line in $lines) { + Write-Host ("{0,$maxLen} {1}" -f "", $line) + } + } +} diff --git a/Plaster/Private/Write-OperationStatus.ps1 b/Plaster/Private/Write-OperationStatus.ps1 new file mode 100644 index 0000000..2c1bff4 --- /dev/null +++ b/Plaster/Private/Write-OperationStatus.ps1 @@ -0,0 +1,10 @@ +function Write-OperationStatus { + [CmdletBinding()] + param( + $Operation, + $Message + ) + $maxLen = Get-MaxOperationLabelLength + Write-Host ("{0,$maxLen} " -f $Operation) -ForegroundColor (Get-ColorForOperation $Operation) -NoNewline + Write-Host $Message +} diff --git a/Plaster/Private/Write-PlasterLog.ps1 b/Plaster/Private/Write-PlasterLog.ps1 new file mode 100644 index 0000000..39d337c --- /dev/null +++ b/Plaster/Private/Write-PlasterLog.ps1 @@ -0,0 +1,91 @@ +function Write-PlasterLog { + <# + .SYNOPSIS + Logs messages with different severity levels for Plaster operations. + + .DESCRIPTION + This function logs messages with different severity levels for Plaster + operations. + + .PARAMETER Level + The severity level of the log message. Possible values are 'Error', + 'Warning', 'Information', 'Verbose', and 'Debug'. The log message will be + formatted with a timestamp and the source of the log. + + .PARAMETER Message + The message to log. + + .PARAMETER Source + The source of the log message. + + .EXAMPLE + Write-PlasterLog -Level 'Information' -Message 'This is an informational message.' + + This example logs an informational message with the specified level and + source. + .NOTES + This function is designed to be used within the Plaster module to ensure + consistent logging across various operations. + It is not intended for direct use outside of the Plaster context. + #> + param( + [Parameter(Mandatory)] + [ValidateSet('Error', 'Warning', 'Information', 'Verbose', 'Debug')] + [string]$Level, + + [Parameter(Mandatory)] + [string]$Message, + + [Parameter()] + [string]$Source = 'Plaster' + ) + + # Check if we should log at this level + $logLevels = @{ + 'Error' = 0 + 'Warning' = 1 + 'Information' = 2 + 'Verbose' = 3 + 'Debug' = 4 + } + + $currentLogLevel = $script:LogLevel ?? 'Information' + $currentLevelValue = $logLevels[$currentLogLevel] ?? 2 + $messageLevelValue = $logLevels[$Level] ?? 2 + + if ($messageLevelValue -gt $currentLevelValue) { + return + } + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $logMessage = "[$timestamp] [$Level] [$Source] $Message" + + # Handle different log levels appropriately + switch ($Level) { + 'Error' { + Write-Error $logMessage -ErrorAction Continue + } + 'Warning' { + Write-Warning $logMessage + } + 'Information' { + Write-Information $logMessage -InformationAction Continue + } + 'Verbose' { + Write-Verbose $logMessage + } + 'Debug' { + 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/GetPlasterTemplate.ps1 b/Plaster/Public/Get-PlasterTemplate.ps1 similarity index 56% rename from Plaster/GetPlasterTemplate.ps1 rename to Plaster/Public/Get-PlasterTemplate.ps1 index 3f8ebea..00d4466 100644 --- a/Plaster/GetPlasterTemplate.ps1 +++ b/Plaster/Public/Get-PlasterTemplate.ps1 @@ -1,5 +1,3 @@ -. $PSScriptRoot\GetModuleExtension.ps1 - function Get-PlasterTemplate { [CmdletBinding()] param( @@ -48,71 +46,83 @@ function Get-PlasterTemplate { ) process { - function CreateTemplateObjectFromManifest([System.IO.FileInfo]$manifestPath, [string]$name, [string]$tag) { - - $manifestXml = Test-PlasterManifest -Path $manifestPath - $metadata = $manifestXml["plasterManifest"]["metadata"] - - $manifestObj = [PSCustomObject]@{ - Name = $metadata["name"].InnerText - Title = $metadata["title"].InnerText - Author = $metadata["author"].InnerText - Version = New-Object -TypeName "System.Version" -ArgumentList $metadata["version"].InnerText - Description = $metadata["description"].InnerText - Tags = $metadata["tags"].InnerText.split(",") | ForEach-Object { $_.Trim() } - TemplatePath = $manifestPath.Directory.FullName - } - - $manifestObj.PSTypeNames.Insert(0, "Microsoft.PowerShell.Plaster.PlasterTemplate") - Add-Member -MemberType ScriptMethod -InputObject $manifestObj -Name "InvokePlaster" -Value { Invoke-Plaster -TemplatePath $this.TemplatePath } - return $manifestObj | Where-Object Name -like $name | Where-Object Tags -like $tag - } - - function GetManifestsUnderPath([string]$rootPath, [bool]$recurse, [string]$name, [string]$tag) { - $manifestPaths = Get-ChildItem -Path $rootPath -Include "plasterManifest.xml" -Recurse:$recurse - foreach ($manifestPath in $manifestPaths) { - CreateTemplateObjectFromManifest $manifestPath $name $tag -ErrorAction SilentlyContinue - } - } - if ($Path) { - # Is this a folder path or a Plaster manifest file path? if (!$Recurse.IsPresent) { if (Test-Path $Path -PathType Container) { - $Path = Resolve-Path "$Path/plasterManifest.xml" + # Check for JSON first, then XML + $jsonPath = Join-Path $Path "plasterManifest.json" + $xmlPath = Join-Path $Path "plasterManifest.xml" + + if (Test-Path $jsonPath) { + $Path = $jsonPath + } elseif (Test-Path $xmlPath) { + $Path = $xmlPath + } else { + $Path = Resolve-Path "$Path/plasterManifest.*" -ErrorAction SilentlyContinue | Select-Object -First 1 + } } - # Use Test-PlasterManifest to load the manifest file Write-Verbose "Attempting to get Plaster template at path: $Path" - CreateTemplateObjectFromManifest $Path $Name $Tag + $newTemplateObjectFromManifestSplat = @{ + ManifestPath = $Path + Name = $Name + Tag = $Tag + } + New-TemplateObjectFromManifest @newTemplateObjectFromManifestSplat } else { Write-Verbose "Attempting to get Plaster templates recursively under path: $Path" - GetManifestsUnderPath $Path $Recurse.IsPresent $Name $Tag + $getManifestsUnderPathSplat = @{ + RootPath = $Path + Recurse = $Recurse.IsPresent + Name = $Name + Tag = $Tag + } + Get-ManifestsUnderPath @getManifestsUnderPathSplat } } else { # Return all templates included with Plaster - GetManifestsUnderPath "$PSScriptRoot\Templates" $true $Name $Tag + $getManifestsUnderPathSplat = @{ + RootPath = "$PSScriptRoot\Templates" + Recurse = $true + Name = $Name + Tag = $Tag + } + Get-ManifestsUnderPath @getManifestsUnderPathSplat if ($IncludeInstalledModules.IsPresent) { # Search for templates in module path $GetModuleExtensionParams = @{ - ModuleName = "Plaster" + ModuleName = "Plaster" ModuleVersion = $PlasterVersion ListAvailable = $ListAvailable } - $extensions = Get-ModuleExtension @GetModuleExtensionParams foreach ($extension in $extensions) { # Scan all module paths registered in the module foreach ($templatePath in $extension.Details.TemplatePaths) { - $expandedTemplatePath = - [System.IO.Path]::Combine( + # Check for both JSON and XML manifests + $jsonManifestPath = [System.IO.Path]::Combine( + $extension.Module.ModuleBase, + $templatePath, + "plasterManifest.json") + + $xmlManifestPath = [System.IO.Path]::Combine( $extension.Module.ModuleBase, $templatePath, "plasterManifest.xml") - CreateTemplateObjectFromManifest $expandedTemplatePath $Name $Tag -ErrorAction SilentlyContinue + $newTemplateObjectFromManifestSplat = @{ + Name = $Name + Tag = $Tag + ErrorAction = 'SilentlyContinue' + } + if (Test-Path $jsonManifestPath) { + $newTemplateObjectFromManifestSplat.ManifestPath = $jsonManifestPath + } elseif (Test-Path $xmlManifestPath) { + $newTemplateObjectFromManifestSplat.ManifestPath = $xmlManifestPath + } + New-TemplateObjectFromManifest @newTemplateObjectFromManifestSplat } } } diff --git a/Plaster/Public/Invoke-Plaster.ps1 b/Plaster/Public/Invoke-Plaster.ps1 new file mode 100644 index 0000000..a78b999 --- /dev/null +++ b/Plaster/Public/Invoke-Plaster.ps1 @@ -0,0 +1,382 @@ +## TODO: Create tests to ensure check for these. +## DEVELOPERS NOTES & CONVENTIONS +## +## 1. All text displayed to the user except for Write-Debug (or $PSCmdlet.WriteDebug()) text must be added to the +## string tables in: +## en-US\Plaster.psd1 +## Plaster.psm1 +## 2. If a new manifest element is added, it must be added to the Schema\PlasterManifest-v1.xsd file and then +## processed in the appropriate function in this script. Any changes to attributes must be +## processed not only in the Resolve-ProcessParameter function but also in the dynamicparam function. +## +## 3. Non-exported functions should avoid using the PowerShell standard Verb-Noun naming convention. +## They should use PascalCase instead. +## +## 4. Please follow the scripting style of this file when adding new script. + +function Invoke-Plaster { + [CmdletBinding(DefaultParameterSetName = 'TemplatePath', SupportsShouldProcess = $true)] + param( + [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'TemplatePath')] + [ValidateNotNullOrEmpty()] + [string] + $TemplatePath, + + [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'TemplateDefinition')] + [ValidateNotNullOrEmpty()] + [string] + $TemplateDefinition, + + [Parameter(Position = 1, Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $DestinationPath, + + [Parameter()] + [switch] + $Force, + + [Parameter()] + [switch] + $NoLogo, + + # Enhanced dynamic parameter processing for both XML and JSON + [switch] + $PassThru + ) + + # Process the template's Plaster manifest file to convert parameters defined there into dynamic parameters. + dynamicparam { + $paramDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary + + $manifest = $null + $manifestPath = $null + $templateAbsolutePath = $null + + # Nothing to do until the TemplatePath parameter has been provided. + if ($null -eq $TemplatePath) { + return + } + + try { + # Let's convert non-terminating errors in this function to terminating so we + # catch and format the error message as a warning. + $ErrorActionPreference = 'Stop' + + <# The constrained runspace is not available in the dynamicparam + block. Shouldn't be needed since we are only evaluating the + parameters in the manifest - no need for Test-ConditionAttribute as + we are not building up multiple parametersets. And no need for + EvaluateAttributeValue since we are only grabbing the parameter's + value which is static.#> + + $templateAbsolutePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($TemplatePath) + + # Load manifest file using culture lookup - try both JSON and XML formats + $manifestPath = Get-PlasterManifestPathForCulture -TemplatePath $templateAbsolutePath -Culture $PSCulture + + # If XML not found, try JSON + if (($null -eq $manifestPath) -or (!(Test-Path $manifestPath))) { + $jsonManifestPath = Join-Path $templateAbsolutePath 'plasterManifest.json' + if (Test-Path $jsonManifestPath) { + $manifestPath = $jsonManifestPath + } + } + + # Determine manifest type and process accordingly + try { + $manifestType = Get-PlasterManifestType -ManifestPath $manifestPath + Write-Debug "Detected manifest type: $manifestType for path: $manifestPath" + } catch { + Write-Warning "Failed to determine manifest type for '$manifestPath': $($_.Exception.Message)" + return + } + + #Process JSON manifests + if ($manifestType -eq 'JSON') { + try { + $jsonContent = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop + $manifest = ConvertFrom-JsonManifest -JsonContent $jsonContent -ErrorAction Stop + Write-Debug "Successfully converted JSON manifest to XML for processing" + } catch { + Write-Warning "Failed to process JSON manifest '$manifestPath': $($_.Exception.Message)" + return + } + } else { + # Process XML manifests (existing logic) + $manifest = Test-PlasterManifest -Path $manifestPath -ErrorAction Stop 3>$null + } + + # The user-defined parameters in the Plaster manifest are converted to dynamic parameters + # which allows the user to provide the parameters via the command line. + # This enables non-interactive use cases. + foreach ($node in $manifest.plasterManifest.parameters.ChildNodes) { + if ($node -isnot [System.Xml.XmlElement]) { + continue + } + + $name = $node.name + $type = $node.type + $prompt = if ($node.prompt) { $node.prompt } else { $LocalizedData.MissingParameterPrompt_F1 -f $name } + + if (!$name -or !$type) { continue } + + # Configure ParameterAttribute and add to attr collection + $attributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute] + $paramAttribute = New-Object System.Management.Automation.ParameterAttribute + $paramAttribute.HelpMessage = $prompt + $attributeCollection.Add($paramAttribute) + + switch -regex ($type) { + 'text|user-fullname|user-email' { + $param = New-Object System.Management.Automation.RuntimeDefinedParameter ` + -ArgumentList ($name, [string], $attributeCollection) + break + } + + 'choice|multichoice' { + $choiceNodes = $node.ChildNodes + $setValues = New-Object string[] $choiceNodes.Count + $i = 0 + + foreach ($choiceNode in $choiceNodes) { + $setValues[$i++] = $choiceNode.value + } + + $validateSetAttr = New-Object System.Management.Automation.ValidateSetAttribute $setValues + $attributeCollection.Add($validateSetAttr) + $type = if ($type -eq 'multichoice') { [string[]] } else { [string] } + $param = New-Object System.Management.Automation.RuntimeDefinedParameter ` + -ArgumentList ($name, $type, $attributeCollection) + break + } + + default { throw ($LocalizedData.UnrecognizedParameterType_F2 -f $type, $name) } + } + + $paramDictionary.Add($name, $param) + } + } catch { + Write-Warning ($LocalizedData.ErrorProcessingDynamicParams_F1 -f $_) + } + + $paramDictionary + } + + begin { + # Enhanced logo with JSON support indicator + $plasterLogo = @' + ____ _ _ ____ ___ + | _ \| | __ _ ___| |_ ___ _ __ |___ \ / _ \ + | |_) | |/ _` / __| __/ _ \ '__| __) | | | | + | __/| | (_| \__ \ || __/ | / /| |_| | / + |_| |_|\__,_|___/\__\___|_| |____|\___/ +'@ + + if (!$NoLogo) { + $versionString = "v$PlasterVersion (JSON Enhanced)" + Write-Host $plasterLogo -ForegroundColor Blue + Write-Host ((" " * (50 - $versionString.Length)) + $versionString) -ForegroundColor Cyan + Write-Host ("=" * 50) -ForegroundColor Blue + } + + #region Script Scope Variables + # These are used across different private functions. + $script:boundParameters = $PSBoundParameters + $script:constrainedRunspace = $null + $script:templateCreatedFiles = @{} + $script:defaultValueStore = @{} + $script:fileConflictConfirmNoToAll = $false + $script:fileConflictConfirmYesToAll = $false + $script:flags = @{ + DefaultValueStoreDirty = $false + } + #endregion Script Scope Variables + + # Determine template source and type + if ($PSCmdlet.ParameterSetName -eq 'TemplatePath') { + $templateAbsolutePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($TemplatePath) + if (!(Test-Path -LiteralPath $templateAbsolutePath -PathType Container)) { + throw ($LocalizedData.ErrorTemplatePathIsInvalid_F1 -f $templateAbsolutePath) + } + + # Determine manifest type and path + $jsonManifestPath = Join-Path $templateAbsolutePath 'plasterManifest.json' + $xmlManifestPath = Get-PlasterManifestPathForCulture $templateAbsolutePath $PSCulture + + if (Test-Path -LiteralPath $jsonManifestPath) { + $manifestPath = $jsonManifestPath + $manifestType = 'JSON' + Write-PlasterLog -Level Information -Message "Using JSON manifest: $($manifestPath | Split-Path -Leaf)" + } elseif (($null -ne $xmlManifestPath) -and (Test-Path $xmlManifestPath)) { + $manifestPath = $xmlManifestPath + $manifestType = 'XML' + Write-PlasterLog -Level Information -Message "Using XML manifest: $($manifestPath | Split-Path -Leaf)" + } else { + throw ($LocalizedData.ManifestFileMissing_F1 -f "plasterManifest.json or plasterManifest.xml") + } + + } else { + # TemplateDefinition parameter set + $manifestType = if ($TemplateDefinition.TrimStart() -match '^[\s]*[\{\[]') { 'JSON' } else { 'XML' } + $templateAbsolutePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) + Write-PlasterLog -Level Information -Message "Using inline $manifestType template definition" + } + + # Process manifest based on type + + if ($null -eq $manifest) { + if ($manifestType -eq 'JSON') { + $manifestContent = if ($manifestPath) { + Get-Content -LiteralPath $manifestPath -Raw + } else { + $TemplateDefinition + } + + # Validate and convert JSON manifest + $isValid = Test-JsonManifest -JsonContent $manifestContent -Detailed + if (-not $isValid) { + throw "JSON manifest validation failed" + } + + $manifest = ConvertFrom-JsonManifest -JsonContent $manifestContent + Write-PlasterLog -Level Debug -Message "JSON manifest converted to internal format" + + } else { + # Load XML manifest + if ($manifestPath -and (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + $manifest = Test-PlasterManifest -Path $manifestPath -ErrorAction Stop 3>$null + $PSCmdlet.WriteDebug("Loading XML manifest file '$manifestPath'") + } else { + throw ($LocalizedData.ManifestFileMissing_F1 -f $manifestPath) + } + } + } + + # Validate destination path + $destinationAbsolutePath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) + if (!(Test-Path -LiteralPath $destinationAbsolutePath)) { + New-Item $destinationAbsolutePath -ItemType Directory > $null + Write-PlasterLog -Level Information -Message "Created destination directory: $destinationAbsolutePath" + } + + # Prepare output object if user has specified the -PassThru parameter. + if ($PassThru) { + $InvokePlasterInfo = [PSCustomObject]@{ + TemplatePath = if ($templateAbsolutePath) { $templateAbsolutePath } else { 'Inline Definition' } + DestinationPath = $destinationAbsolutePath + ManifestType = $manifestType + Success = $false + TemplateType = if ($manifest.plasterManifest.templateType) { $manifest.plasterManifest.templateType } else { 'Unspecified' } + CreatedFiles = [string[]]@() + UpdatedFiles = [string[]]@() + MissingModules = [string[]]@() + OpenFiles = [string[]]@() + ProcessingTime = $null + } + } + + # Initialize pre-defined variables + if ($templateAbsolutePath) { + Initialize-PredefinedVariables -TemplatePath $templateAbsolutePath -DestPath $destinationAbsolutePath + } else { + Initialize-PredefinedVariables -TemplatePath $destinationAbsolutePath -DestPath $destinationAbsolutePath + } + + # Enhanced default value store handling + $templateId = $manifest.plasterManifest.metadata.id + $templateVersion = $manifest.plasterManifest.metadata.version + $templateName = $manifest.plasterManifest.metadata.name + $storeFilename = "$templateName-$templateVersion-$templateId.clixml" + $script:defaultValueStorePath = Join-Path $ParameterDefaultValueStoreRootPath $storeFilename + if (Test-Path $script:defaultValueStorePath) { + try { + $PSCmdlet.WriteDebug("Loading default value store from '$script:defaultValueStorePath'.") + $script:defaultValueStore = Import-Clixml $script:defaultValueStorePath -ErrorAction Stop + Write-PlasterLog -Level Debug -Message "Loaded parameter defaults from store" + } catch { + Write-Warning ($LocalizedData.ErrorFailedToLoadStoreFile_F1 -f $script:defaultValueStorePath) + } + } + } + + end { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + try { + Write-PlasterLog -Level Information -Message "Starting template processing ($manifestType format)" + + # Process parameters with enhanced JSON support + foreach ($node in $manifest.plasterManifest.parameters.ChildNodes) { + if ($node -isnot [System.Xml.XmlElement]) { continue } + switch ($node.LocalName) { + 'parameter' { Resolve-ProcessParameter $node } + default { throw ($LocalizedData.UnrecognizedParametersElement_F1 -f $node.LocalName) } + } + } + + # Output processed parameters for debugging + $parameters = Get-Variable -Name PLASTER_* | Out-String + $PSCmdlet.WriteDebug("Parameter values are:`n$($parameters -split "`n")") + + # Stores any updated default values back to the store file. + if ($script:flags.DefaultValueStoreDirty) { + $directory = Split-Path $script:defaultValueStorePath -Parent + if (!(Test-Path $directory)) { + $PSCmdlet.WriteDebug("Creating directory for template's DefaultValueStore '$directory'.") + New-Item $directory -ItemType Directory > $null + } + + $PSCmdlet.WriteDebug("DefaultValueStore is dirty, saving updated values to '$script:defaultValueStorePath'.") + $script:defaultValueStore | Export-Clixml -LiteralPath $script:defaultValueStorePath + } + + # Output destination path + Write-Host ($LocalizedData.DestPath_F1 -f $destinationAbsolutePath) + + # Process content with enhanced logging + foreach ($node in $manifest.plasterManifest.content.ChildNodes) { + if ($node -isnot [System.Xml.XmlElement]) { continue } + + Write-PlasterLog -Level Debug -Message "Processing content action: $($node.LocalName)" + switch -Regex ($node.LocalName) { + 'file|templateFile' { Start-ProcessFile $node; break } + 'message' { Resolve-ProcessMessage $node; break } + 'modify' { Start-ProcessModifyFile $node; break } + 'newModuleManifest' { Resolve-ProcessNewModuleManifest $node; break } + 'requireModule' { Start-ProcessFileProcessRequireModule $node; break } + default { throw ($LocalizedData.UnrecognizedContentElement_F1 -f $node.LocalName) } + } + } + $stopwatch.Stop() + + if ($PassThru) { + $InvokePlasterInfo.Success = $true + $InvokePlasterInfo.ProcessingTime = $stopwatch.Elapsed + Write-PlasterLog -Level Information -Message "Template processing completed successfully in $($stopwatch.Elapsed.TotalSeconds) seconds" + return $InvokePlasterInfo + } else { + Write-PlasterLog -Level Information -Message "Template processing completed successfully in $($stopwatch.Elapsed.TotalSeconds) seconds" + } + } catch { + $stopwatch.Stop() + $errorMessage = "Template processing failed after $($stopwatch.Elapsed.TotalSeconds) seconds: $($_.Exception.Message)" + Write-PlasterLog -Level Error -Message $errorMessage + + if ($PassThru) { + $InvokePlasterInfo.Success = $false + $InvokePlasterInfo.ProcessingTime = $stopwatch.Elapsed + return $InvokePlasterInfo + } + + throw $_ + } finally { + # Enhanced cleanup + if ($script:constrainedRunspace) { + $script:constrainedRunspace.Dispose() + $script:constrainedRunspace = $null + Write-PlasterLog -Level Debug -Message "Disposed constrained runspace" + } + } + } +} diff --git a/Plaster/Public/New-PlasterManifest.ps1 b/Plaster/Public/New-PlasterManifest.ps1 new file mode 100644 index 0000000..5fd11a3 --- /dev/null +++ b/Plaster/Public/New-PlasterManifest.ps1 @@ -0,0 +1,193 @@ +function New-PlasterManifest { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $Path, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-zA-Z_-]+$')] + [string] + $TemplateName, + + [Parameter(Mandatory = $true)] + [ValidateSet('Item', 'Project')] + [string] + $TemplateType, + + [Parameter()] + [Guid] + $Id = [guid]::NewGuid(), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidatePattern('^\d+\.\d+(\.\d+((\.\d+|(\+|-).*)?)?)?$')] + [string] + $TemplateVersion = "1.0.0", + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $Title = $TemplateName, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $Description, + + [Parameter()] + [string[]] + $Tags, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $Author, + + [Parameter()] + [switch] + $AddContent, + + [Parameter()] + [ValidateSet('XML', 'JSON')] + [string] + $Format = 'JSON', + + [Parameter()] + [switch] + $ConvertFromXml + ) + + begin { + # Set default path based on format if not provided + if (-not $PSBoundParameters.ContainsKey('Path')) { + $Path = if ($Format -eq 'JSON') { "$pwd\plasterManifest.json" } else { "$pwd\plasterManifest.xml" } + } + + $resolvedPath = $PSCmdLet.GetUnresolvedProviderPathFromPSPath($Path) + + $caseCorrectedTemplateType = [System.Char]::ToUpper($TemplateType[0]) + $TemplateType.Substring(1).ToLower() + + $manifestStr = @" + + + + + + + + + + + + + + + + + +"@ + } + + end { + if ($Format -eq 'JSON') { + # Create JSON manifest + $jsonManifest = [ordered]@{ + '$schema' = 'https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json' + 'schemaVersion' = '2.0' + 'metadata' = [ordered]@{ + 'name' = $TemplateName + 'id' = $Id.ToString() + 'version' = $TemplateVersion + 'title' = $Title + 'description' = $Description + 'author' = $Author + 'templateType' = $caseCorrectedTemplateType + } + 'parameters' = @() + 'content' = @() + } + + if ($Tags) { + $jsonManifest.metadata['tags'] = $Tags + } + + if ($AddContent) { + $baseDir = Split-Path $resolvedPath -Parent + $filenames = Get-ChildItem $baseDir -Recurse -File -Name + foreach ($filename in $filenames) { + if ($filename -match "plasterManifest.*\.(xml|json)") { + continue + } + + $fileAction = [ordered]@{ + 'type' = 'file' + 'source' = $filename + 'destination' = $filename + } + $jsonManifest.content += $fileAction + } + } + + $jsonContent = $jsonManifest | ConvertTo-Json -Depth 10 + if ($PSCmdlet.ShouldProcess($resolvedPath, $LocalizedData.ShouldCreateNewPlasterManifest)) { + Set-Content -Path $resolvedPath -Value $jsonContent -Encoding UTF8 + } + + } else { + $manifest = [xml]$manifestStr + + # Set via .innerText to get .NET to encode special XML chars as entity references. + $manifest.plasterManifest.metadata["name"].innerText = "$TemplateName" + $manifest.plasterManifest.metadata["id"].innerText = "$Id" + $manifest.plasterManifest.metadata["version"].innerText = "$TemplateVersion" + $manifest.plasterManifest.metadata["title"].innerText = "$Title" + $manifest.plasterManifest.metadata["description"].innerText = "$Description" + $manifest.plasterManifest.metadata["author"].innerText = "$Author" + + $OFS = ", " + $manifest.plasterManifest.metadata["tags"].innerText = "$Tags" + + if ($AddContent) { + $baseDir = Split-Path $Path -Parent + $filenames = Get-ChildItem $baseDir -Recurse -File -Name + foreach ($filename in $filenames) { + if ($filename -match "plasterManifest.*\.xml") { + continue + } + + $fileElem = $manifest.CreateElement('file', $TargetNamespace) + + $srcAttr = $manifest.CreateAttribute("source") + $srcAttr.Value = $filename + $fileElem.Attributes.Append($srcAttr) > $null + + $dstAttr = $manifest.CreateAttribute("destination") + $dstAttr.Value = $filename + $fileElem.Attributes.Append($dstAttr) > $null + + $manifest.plasterManifest["content"].AppendChild($fileElem) > $null + } + } + + # This configures the XmlWriter to put attributes on a new line + $xmlWriterSettings = New-Object System.Xml.XmlWriterSettings + $xmlWriterSettings.Indent = $true + $xmlWriterSettings.NewLineOnAttributes = $true + + try { + if ($PSCmdlet.ShouldProcess($resolvedPath, $LocalizedData.ShouldCreateNewPlasterManifest)) { + $xmlWriter = [System.Xml.XmlWriter]::Create($resolvedPath, $xmlWriterSettings) + $manifest.Save($xmlWriter) + } + } finally { + if ($xmlWriter) { + $xmlWriter.Dispose() + } + } + } + } +} diff --git a/Plaster/TestPlasterManifest.ps1 b/Plaster/Public/Test-PlasterManifest.ps1 similarity index 75% rename from Plaster/TestPlasterManifest.ps1 rename to Plaster/Public/Test-PlasterManifest.ps1 index 5b58795..4b64452 100644 --- a/Plaster/TestPlasterManifest.ps1 +++ b/Plaster/Public/Test-PlasterManifest.ps1 @@ -2,11 +2,11 @@ function Test-PlasterManifest { [CmdletBinding()] [OutputType([System.Xml.XmlDocument])] param( - [Parameter(Position=0, - ParameterSetName="Path", - ValueFromPipeline=$true, - ValueFromPipelineByPropertyName=$true, - HelpMessage="Specifies a path to a plasterManifest.xml file.")] + [Parameter(Position = 0, + ParameterSetName = "Path", + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = "Specifies a path to a plasterManifest.xml or plasterManifest.json file.")] [Alias("PSPath")] [ValidateNotNullOrEmpty()] [string[]] @@ -20,8 +20,7 @@ function Test-PlasterManifest { if ('System.Xml.Schema.XmlSchemaSet' -as [type]) { $xmlSchemaSet = New-Object System.Xml.Schema.XmlSchemaSet $xmlSchemaSet.Add($TargetNamespace, $schemaPath) > $null - } - else { + } else { $PSCmdLet.WriteWarning($LocalizedData.TestPlasterNoXmlSchemaValidationWarning) } } @@ -33,7 +32,7 @@ function Test-PlasterManifest { if (!(Test-Path -LiteralPath $aPath)) { $ex = New-Object System.Management.Automation.ItemNotFoundException ($LocalizedData.ErrorPathDoesNotExist_F1 -f $aPath) $category = [System.Management.Automation.ErrorCategory]::ObjectNotFound - $errRecord = New-Object System.Management.Automation.ErrorRecord $ex,'PathNotFound',$category,$aPath + $errRecord = New-Object System.Management.Automation.ErrorRecord $ex, 'PathNotFound', $category, $aPath $PSCmdLet.WriteError($errRecord) return } @@ -41,20 +40,58 @@ function Test-PlasterManifest { $filename = Split-Path $aPath -Leaf # Verify the manifest has the correct filename. Allow for localized template manifest files as well. - if (!(($filename -eq 'plasterManifest.xml') -or ($filename -match 'plasterManifest_[a-zA-Z]+(-[a-zA-Z]+){0,2}.xml'))) { + $isXmlManifest = ($filename -eq 'plasterManifest.xml') -or ($filename -match 'plasterManifest_[a-zA-Z]+(-[a-zA-Z]+){0,2}.xml') + $isJsonManifest = ($filename -eq 'plasterManifest.json') -or ($filename -match 'plasterManifest_[a-zA-Z]+(-[a-zA-Z]+){0,2}.json') + + if (!$isXmlManifest -and !$isJsonManifest) { Write-Error ($LocalizedData.ManifestWrongFilename_F1 -f $filename) return } + # Detect manifest format and process accordingly + try { + $manifestType = Get-PlasterManifestType -ManifestPath $aPath + Write-Verbose "Detected manifest format: $manifestType" + } catch { + Write-Error "Failed to determine manifest format for '$aPath': $($_.Exception.Message)" + return + } + + # Handle JSON manifests + if ($manifestType -eq 'JSON') { + Write-Verbose "Processing JSON manifest: $aPath" + + try { + $jsonContent = Get-Content -LiteralPath $aPath -Raw -ErrorAction Stop + $validationResult = Test-JsonManifest -JsonContent $jsonContent -Detailed + + if ($validationResult) { + Write-Verbose "JSON manifest validation passed" + # Convert JSON to XML for consistent return type + $xmlManifest = ConvertFrom-JsonManifest -JsonContent $jsonContent + return $xmlManifest + } else { + Write-Error "JSON manifest validation failed for '$aPath'" + return $null + } + } catch { + $ex = New-Object System.Exception ("JSON manifest validation failed for '$aPath': $($_.Exception.Message)"), $_.Exception + $category = [System.Management.Automation.ErrorCategory]::InvalidData + $errRecord = New-Object System.Management.Automation.ErrorRecord $ex, 'InvalidJsonManifestFile', $category, $aPath + $PSCmdLet.WriteError($errRecord) + return $null + } + } + + # Handle XML manifests (existing logic) # Verify the manifest loads into an XmlDocument i.e. verify it is well-formed. $manifest = $null try { $manifest = [xml](Get-Content $aPath) - } - catch { + } catch { $ex = New-Object System.Exception ($LocalizedData.ManifestNotWellFormedXml_F2 -f $aPath, $_.Exception.Message), $_.Exception $category = [System.Management.Automation.ErrorCategory]::InvalidData - $errRecord = New-Object System.Management.Automation.ErrorRecord $ex,'InvalidManifestFile',$category,$aPath + $errRecord = New-Object System.Management.Automation.ErrorRecord $ex, 'InvalidManifestFile', $category, $aPath $psCmdlet.WriteError($errRecord) return } @@ -62,17 +99,17 @@ function Test-PlasterManifest { # Validate the manifest contains the required root element and target namespace that the following # XML schema validation will apply to. if (!$manifest.plasterManifest) { - Write-Error ($LocalizedData.ManifestMissingDocElement_F2 -f $aPath,$TargetNamespace) + Write-Error ($LocalizedData.ManifestMissingDocElement_F2 -f $aPath, $TargetNamespace) return } if ($manifest.plasterManifest.NamespaceURI -cne $TargetNamespace) { - Write-Error ($LocalizedData.ManifestMissingDocTargetNamespace_F2 -f $aPath,$TargetNamespace) + Write-Error ($LocalizedData.ManifestMissingDocTargetNamespace_F2 -f $aPath, $TargetNamespace) return } # Valid flag is stashed in a hashtable so the ValidationEventHandler scriptblock can set the value. - $manifestIsValid = @{Value = $true} + $manifestIsValid = @{Value = $true } # Configure an XmlReader and XmlReaderSettings to perform schema validation on xml file. $xmlReaderSettings = New-Object System.Xml.XmlReaderSettings @@ -90,9 +127,8 @@ function Test-PlasterManifest { $validationEventHandler = { param($sender, $eventArgs) - if ($eventArgs.Severity -eq [System.Xml.Schema.XmlSeverityType]::Error) - { - Write-Verbose ($LocalizedData.ManifestSchemaValidationError_F2 -f $aPath,$eventArgs.Message) + if ($eventArgs.Severity -eq [System.Xml.Schema.XmlSeverityType]::Error) { + Write-Verbose ($LocalizedData.ManifestSchemaValidationError_F2 -f $aPath, $eventArgs.Message) $manifestIsValid.Value = $false } } @@ -104,12 +140,10 @@ function Test-PlasterManifest { try { $xmlReader = [System.Xml.XmlReader]::Create($aPath, $xmlReaderSettings) while ($xmlReader.Read()) {} - } - catch { + } catch { Write-Error ($LocalizedData.ManifestErrorReading_F1 -f $_) $manifestIsValid.Value = $false - } - finally { + } finally { # Schema validation is not available on .NET Core - at the moment. if ($xmlSchemaSet) { $xmlReaderSettings.remove_ValidationEventHandler($validationEventHandler) @@ -119,20 +153,19 @@ function Test-PlasterManifest { # Validate default values for choice/multichoice parameters containing 1 or more ints $xpath = "//tns:parameter[@type='choice'] | //tns:parameter[@type='multichoice']" - $choiceParameters = Select-Xml -Xml $manifest -XPath $xpath -Namespace @{tns=$TargetNamespace} + $choiceParameters = Select-Xml -Xml $manifest -XPath $xpath -Namespace @{tns = $TargetNamespace } foreach ($choiceParameterXmlInfo in $choiceParameters) { $choiceParameter = $choiceParameterXmlInfo.Node if (!$choiceParameter.default) { continue } if ($choiceParameter.type -eq 'choice') { if ($null -eq ($choiceParameter.default -as [int])) { - $PSCmdLet.WriteVerbose(($LocalizedData.ManifestSchemaInvalidChoiceDefault_F3 -f $choiceParameter.default,$choiceParameter.name,$aPath)) + $PSCmdLet.WriteVerbose(($LocalizedData.ManifestSchemaInvalidChoiceDefault_F3 -f $choiceParameter.default, $choiceParameter.name, $aPath)) $manifestIsValid.Value = $false } - } - else { + } else { if ($null -eq (($choiceParameter.default -split ',') -as [int[]])) { - $PSCmdLet.WriteVerbose(($LocalizedData.ManifestSchemaInvalidMultichoiceDefault_F3 -f $choiceParameter.default,$choiceParameter.name,$aPath)) + $PSCmdLet.WriteVerbose(($LocalizedData.ManifestSchemaInvalidMultichoiceDefault_F3 -f $choiceParameter.default, $choiceParameter.name, $aPath)) $manifestIsValid.Value = $false } } @@ -140,11 +173,11 @@ function Test-PlasterManifest { # Validate that the requireModule attribute requiredVersion is mutually exclusive from both # the version and maximumVersion attributes. - $requireModules = Select-Xml -Xml $manifest -XPath '//tns:requireModule' -Namespace @{tns = $TargetNamespace} + $requireModules = Select-Xml -Xml $manifest -XPath '//tns:requireModule' -Namespace @{tns = $TargetNamespace } foreach ($requireModuleInfo in $requireModules) { $requireModuleNode = $requireModuleInfo.Node if ($requireModuleNode.requiredVersion -and ($requireModuleNode.minimumVersion -or $requireModuleNode.maximumVersion)) { - $PSCmdLet.WriteVerbose(($LocalizedData.ManifestSchemaInvalidRequireModuleAttrs_F2 -f $requireModuleNode.name,$aPath)) + $PSCmdLet.WriteVerbose(($LocalizedData.ManifestSchemaInvalidRequireModuleAttrs_F2 -f $requireModuleNode.name, $aPath)) $manifestIsValid.Value = $false } } @@ -162,9 +195,9 @@ function Test-PlasterManifest { } # Validate all interpolated attribute values are valid within a PowerShell string interpolation context. - $interpolatedAttrs = @(Select-Xml -Xml $manifest -XPath '//tns:parameter/@default' -Namespace @{tns = $TargetNamespace}) - $interpolatedAttrs += @(Select-Xml -Xml $manifest -XPath '//tns:parameter/@prompt' -Namespace @{tns = $TargetNamespace}) - $interpolatedAttrs += @(Select-Xml -Xml $manifest -XPath '//tns:content/tns:*/@*' -Namespace @{tns = $TargetNamespace}) + $interpolatedAttrs = @(Select-Xml -Xml $manifest -XPath '//tns:parameter/@default' -Namespace @{tns = $TargetNamespace }) + $interpolatedAttrs += @(Select-Xml -Xml $manifest -XPath '//tns:parameter/@prompt' -Namespace @{tns = $TargetNamespace }) + $interpolatedAttrs += @(Select-Xml -Xml $manifest -XPath '//tns:content/tns:*/@*' -Namespace @{tns = $TargetNamespace }) foreach ($interpolatedAttr in $interpolatedAttrs) { $name = $interpolatedAttr.Node.LocalName if ($name -eq 'condition') { continue } @@ -189,7 +222,7 @@ function Test-PlasterManifest { (($manifestSchemaVersion.Major -eq $LatestSupportedSchemaVersion.Major) -and ($manifestSchemaVersion.Minor -gt $LatestSupportedSchemaVersion.Minor))) { - Write-Error ($LocalizedData.ManifestSchemaVersionNotSupported_F2 -f $manifestSchemaVersion,$aPath) + Write-Error ($LocalizedData.ManifestSchemaVersionNotSupported_F2 -f $manifestSchemaVersion, $aPath) return } @@ -204,21 +237,19 @@ function Test-PlasterManifest { if ($requiredPlasterVersion -gt $MyInvocation.MyCommand.Module.Version) { $plasterVersion = $manifest.plasterManifest.plasterVersion - Write-Error ($LocalizedData.ManifestPlasterVersionNotSupported_F2 -f $aPath,$plasterVersion) + Write-Error ($LocalizedData.ManifestPlasterVersionNotSupported_F2 -f $aPath, $plasterVersion) return } } $manifest - } - else { + } else { if ($PSBoundParameters['Verbose']) { Write-Error ($LocalizedData.ManifestNotValid_F1 -f $aPath) - } - else { + } else { Write-Error ($LocalizedData.ManifestNotValidVerbose_F1 -f $aPath) } } } } -} +} \ No newline at end of file diff --git a/Plaster/Schema/plaster-manifest-v2.json b/Plaster/Schema/plaster-manifest-v2.json new file mode 100644 index 0000000..49af49c --- /dev/null +++ b/Plaster/Schema/plaster-manifest-v2.json @@ -0,0 +1,772 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/PowerShellOrg/Plaster/v2/schema/plaster-manifest-v2.json", + "title": "Plaster Template Manifest v2.0", + "description": "JSON schema for Plaster 2.0 template manifests", + "type": "object", + "required": [ + "schemaVersion", + "metadata", + "content" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference", + "format": "uri" + }, + "schemaVersion": { + "type": "string", + "description": "Plaster schema version", + "enum": [ + "2.0" + ] + }, + "metadata": { + "type": "object", + "description": "Template metadata", + "required": [ + "name", + "id", + "version", + "title", + "author" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Template name (must be a valid identifier)", + "pattern": "^[A-Za-z][A-Za-z0-9_-]*$", + "minLength": 1, + "maxLength": 100 + }, + "id": { + "type": "string", + "description": "Unique template identifier (GUID)", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "version": { + "type": "string", + "description": "Template version (semantic versioning)", + "pattern": "^\\d+\\.\\d+\\.\\d+([+-].*)?$" + }, + "title": { + "type": "string", + "description": "Human-readable template title", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": "string", + "description": "Template description", + "maxLength": 1000 + }, + "author": { + "type": "string", + "description": "Template author", + "minLength": 1, + "maxLength": 100 + }, + "tags": { + "type": "array", + "description": "Template tags for categorization", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "uniqueItems": true, + "maxItems": 20 + }, + "templateType": { + "type": "string", + "description": "Template type", + "enum": [ + "Project", + "Item" + ], + "default": "Project" + }, + "minimumPlasterVersion": { + "type": "string", + "description": "Minimum required Plaster version", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?$" + }, + "openInEditor": { + "type": "boolean", + "description": "Whether to open the template in an editor after creation", + "default": false + } + } + }, + "parameters": { + "type": "array", + "description": "Template parameters", + "items": { + "$ref": "#/definitions/parameter" + } + }, + "content": { + "type": "array", + "description": "Template content actions", + "items": { + "$ref": "#/definitions/contentAction" + }, + "minItems": 1 + }, + "functions": { + "type": "object", + "description": "Custom functions for template processing", + "additionalProperties": { + "type": "string", + "description": "PowerShell script block as string" + } + } + }, + "definitions": { + "parameter": { + "type": "object", + "required": [ + "name", + "type" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Parameter name", + "pattern": "^[A-Za-z][A-Za-z0-9_]*$", + "minLength": 1, + "maxLength": 50 + }, + "type": { + "type": "string", + "description": "Parameter type", + "enum": [ + "text", + "user-fullname", + "user-email", + "choice", + "multichoice", + "switch" + ] + }, + "prompt": { + "type": "string", + "description": "User prompt text", + "minLength": 1, + "maxLength": 200 + }, + "default": { + "description": "Default value", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "choices": { + "type": "array", + "description": "Available choices for choice/multichoice parameters", + "items": { + "type": "object", + "required": [ + "label", + "value" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "description": "Choice display label", + "minLength": 1, + "maxLength": 100 + }, + "value": { + "type": "string", + "description": "Choice value", + "minLength": 1, + "maxLength": 100 + }, + "help": { + "type": "string", + "description": "Choice help text", + "maxLength": 500 + } + } + }, + "minItems": 1, + "maxItems": 50 + }, + "validation": { + "type": "object", + "description": "Parameter validation rules", + "additionalProperties": false, + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern for validation", + "format": "regex" + }, + "minLength": { + "type": "integer", + "description": "Minimum string length", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "description": "Maximum string length", + "minimum": 1 + }, + "minimum": { + "type": "number", + "description": "Minimum numeric value" + }, + "maximum": { + "type": "number", + "description": "Maximum numeric value" + }, + "message": { + "type": "string", + "description": "Custom validation error message", + "maxLength": 200 + } + } + }, + "condition": { + "type": "string", + "description": "Condition for parameter visibility", + "maxLength": 500 + }, + "dependsOn": { + "type": "array", + "description": "Parameters this parameter depends on", + "items": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_]*$" + }, + "uniqueItems": true + }, + "store": { + "type": "string", + "description": "How to store the parameter value", + "enum": [ + "text", + "encrypted" + ] + }, + "help": { + "type": "string", + "description": "Parameter help text", + "maxLength": 500 + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "choice", + "multichoice" + ] + } + } + }, + "then": { + "required": [ + "choices" + ] + } + } + ] + }, + "contentAction": { + "type": "object", + "required": [ + "type" + ], + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/definitions/messageAction" + }, + { + "$ref": "#/definitions/fileAction" + }, + { + "$ref": "#/definitions/templateFileAction" + }, + { + "$ref": "#/definitions/directoryAction" + }, + { + "$ref": "#/definitions/newModuleManifestAction" + }, + { + "$ref": "#/definitions/modifyAction" + }, + { + "$ref": "#/definitions/requireModuleAction" + }, + { + "$ref": "#/definitions/executeAction" + } + ] + }, + "baseAction": { + "type": "object", + "properties": { + "condition": { + "type": "string", + "description": "Condition for executing this action", + "maxLength": 500 + } + } + }, + "messageAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "text" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "message" + }, + "text": { + "type": "string", + "description": "Message text to display", + "minLength": 1, + "maxLength": 1000 + }, + "noNewline": { + "type": "boolean", + "description": "Don't add newline after message", + "default": false + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + }, + "fileAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "source", + "destination" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "file" + }, + "source": { + "type": "string", + "description": "Source file path (supports wildcards)", + "minLength": 1, + "maxLength": 500 + }, + "destination": { + "type": "string", + "description": "Destination path", + "minLength": 1, + "maxLength": 500 + }, + "encoding": { + "type": "string", + "description": "File encoding", + "enum": [ + "UTF8", + "UTF8-NoBOM", + "ASCII", + "Unicode", + "UTF32", + "Default" + ] + }, + "openInEditor": { + "type": "boolean", + "description": "Open file in editor after creation", + "default": false + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + }, + "templateFileAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "source", + "destination" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "templateFile" + }, + "source": { + "type": "string", + "description": "Source template file path", + "minLength": 1, + "maxLength": 500 + }, + "destination": { + "type": "string", + "description": "Destination path", + "minLength": 1, + "maxLength": 500 + }, + "encoding": { + "type": "string", + "description": "File encoding", + "enum": [ + "UTF8", + "UTF8-NoBOM", + "ASCII", + "Unicode", + "UTF32", + "Default" + ], + "default": "UTF8-NoBOM" + }, + "openInEditor": { + "type": "boolean", + "description": "Open file in editor after creation", + "default": false + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + }, + "directoryAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "destination" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "directory" + }, + "destination": { + "type": "string", + "description": "Directory path to create", + "minLength": 1, + "maxLength": 500 + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + }, + "newModuleManifestAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "destination" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "newModuleManifest" + }, + "destination": { + "type": "string", + "description": "Module manifest destination path", + "minLength": 1, + "maxLength": 500 + }, + "moduleVersion": { + "type": "string", + "description": "Module version", + "pattern": "^\\d+\\.\\d+\\.\\d+([+-].*)?$" + }, + "rootModule": { + "type": "string", + "description": "Root module file", + "maxLength": 200 + }, + "author": { + "type": "string", + "description": "Module author", + "maxLength": 100 + }, + "companyName": { + "type": "string", + "description": "Company name", + "maxLength": 100 + }, + "description": { + "type": "string", + "description": "Module description", + "maxLength": 1000 + }, + "powerShellVersion": { + "type": "string", + "description": "Minimum PowerShell version", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?$" + }, + "copyright": { + "type": "string", + "description": "Copyright statement", + "maxLength": 200 + }, + "encoding": { + "type": "string", + "description": "File encoding", + "enum": [ + "UTF8", + "UTF8-NoBOM", + "ASCII", + "Unicode", + "UTF32", + "Default" + ], + "default": "UTF8-NoBOM" + }, + "openInEditor": { + "type": "boolean", + "description": "Open manifest in editor after creation", + "default": false + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + }, + "modifyAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "path", + "modifications" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "modify" + }, + "path": { + "type": "string", + "description": "Path to file to modify", + "minLength": 1, + "maxLength": 500 + }, + "modifications": { + "type": "array", + "description": "List of modifications to apply", + "items": { + "type": "object", + "required": [ + "type" + ], + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "search", + "replace" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "replace" + }, + "search": { + "type": "string", + "description": "Text/regex to search for", + "minLength": 1 + }, + "replace": { + "type": "string", + "description": "Replacement text" + }, + "isRegex": { + "type": "boolean", + "description": "Whether search is a regex pattern", + "default": false + }, + "condition": { + "type": "string", + "description": "Condition for this modification" + } + } + } + ] + }, + "minItems": 1 + }, + "encoding": { + "type": "string", + "description": "File encoding", + "enum": [ + "UTF8", + "UTF8-NoBOM", + "ASCII", + "Unicode", + "UTF32", + "Default" + ], + "default": "UTF8-NoBOM" + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + }, + "requireModuleAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "name" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "requireModule" + }, + "name": { + "type": "string", + "description": "Required module name", + "minLength": 1, + "maxLength": 100 + }, + "minimumVersion": { + "type": "string", + "description": "Minimum module version", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?([+-].*)?$" + }, + "maximumVersion": { + "type": "string", + "description": "Maximum module version", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?([+-].*)?$" + }, + "requiredVersion": { + "type": "string", + "description": "Exact required version", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?([+-].*)?$" + }, + "message": { + "type": "string", + "description": "Custom message when module is missing", + "maxLength": 500 + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + }, + "executeAction": { + "allOf": [ + { + "$ref": "#/definitions/baseAction" + }, + { + "type": "object", + "required": [ + "type", + "script" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "execute" + }, + "script": { + "type": "string", + "description": "PowerShell script to execute", + "minLength": 1, + "maxLength": 10000 + }, + "workingDirectory": { + "type": "string", + "description": "Working directory for script execution", + "maxLength": 500 + }, + "continueOnError": { + "type": "boolean", + "description": "Continue processing if script fails", + "default": false + }, + "condition": { + "$ref": "#/definitions/baseAction/properties/condition" + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/Plaster/Templates/AddPSScriptAnalyzerSettings/plasterManifest.xml b/Plaster/Templates/AddPSScriptAnalyzerSettings/plasterManifest.xml index a07623f..9ae1eea 100644 --- a/Plaster/Templates/AddPSScriptAnalyzerSettings/plasterManifest.xml +++ b/Plaster/Templates/AddPSScriptAnalyzerSettings/plasterManifest.xml @@ -1,4 +1,4 @@ - + diff --git a/Plaster/Templates/NewPowerShellScriptModule/plasterManifest.xml b/Plaster/Templates/NewPowerShellScriptModule/plasterManifest.xml index 03c73a9..6c17104 100644 --- a/Plaster/Templates/NewPowerShellScriptModule/plasterManifest.xml +++ b/Plaster/Templates/NewPowerShellScriptModule/plasterManifest.xml @@ -1,4 +1,4 @@ - + diff --git a/README.md b/README.md index 32cbae5..efa665a 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,281 @@ # Plaster -![Build status](https://img.shields.io/github/actions/workflow/status/PowerShellOrg/Plaster/PesterReports.yml?branch=master) +[![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) -> **This project has been transferred from Microsoft to PowerShell.org as of 16 June 2020. We hope to bring this project back up to speed. Please allow us some time to get re-organized**. -> +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. -## Current Status +## What's New in Plaster 2.0 -This project has been dormant for a while, which is one of the reasons for the ownership transfer. As the new maintainers get up to speed, the following actions are taking place. +Plaster 2.0 introduces **JSON manifest support** alongside the traditional XML format, bringing modern tooling and improved developer experience: -+ Many of the existing issues have been converted into Discussions. If you have an enhancement suggestion or question, please use Discussions. File an Issue for a bug or error. -+ No new pull requests will be considered at this time. It is very likely old, existing PRs will be closed without any action. Although we will take note of the change and possible incorporate it separately. +### 🆕 JSON Manifest Format +- **Modern Syntax**: Clean, readable JSON instead of verbose XML +- **Better Tooling**: VS Code IntelliSense with JSON Schema validation +- **Simplified Variables**: Use `${ParameterName}` instead of `${PLASTER_PARAM_ParameterName}` +- **Native Arrays**: No more comma-separated strings for multichoice defaults +- **No Escaping**: No XML entity escaping required (`&` instead of `&`) -### Roadmap +### 🔄 Backwards Compatibility +- **Zero Breaking Changes**: All existing XML templates continue to work +- **Automatic Detection**: Plaster automatically detects and processes both formats +- **Side-by-Side Support**: Templates can include both XML and JSON manifests +- **Seamless Migration**: Convert existing XML templates to JSON when ready -The long-term goal is to make sure that the module works in Windows PowerShell 5.1 and PowerShell 7.x, including support for Pester 5.x. The module should have VS Code integration. In the short-term, the focus will be on these items: +### 📊 Format Comparison -+ re-establish a build pipeline -+ revise to take Pester v5 into account -+ verify current documentation -+ verify the module can be used in PowerShell 7.x without error +| Feature | XML Format | JSON Format | +|---------|------------|-------------| +| Compatibility | Plaster 1.x+ | Plaster 2.0+ | +| Variable Syntax | `${PLASTER_PARAM_Name}` | `${Name}` | +| Multichoice Defaults | `"0,1,2"` | `[0, 1, 2]` | +| Schema Validation | ✅ XSD | ✅ JSON Schema | +| VS Code IntelliSense | Limited | Full Support | +| Special Characters | Requires escaping | No escaping needed | -Once these items have been addressed and the module is stable, we can re-visit ideas and suggestions. +## Installation -## Background +### From PowerShell Gallery (Recommended) +```powershell +Install-Module -Name Plaster -Scope CurrentUser +``` -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. +### From Source +```powershell +git clone https://github.com/PowerShell/Plaster.git +Import-Module .\Plaster\Plaster\Plaster.psd1 +``` -You can think of Plaster as [Yeoman](http://yeoman.io) for the PowerShell community. +## Quick Start -## Installation +### Using an Existing Template +```powershell +# Discover available templates +Get-PlasterTemplate -If you have the [PowerShellGet](https://msdn.microsoft.com/powershell/gallery/readme) module installed -you can enter the following command: +# Create a new PowerShell module (interactive) +$template = Get-PlasterTemplate | Where-Object Name -eq NewPowerShellModule +Invoke-Plaster -TemplatePath $template.TemplatePath -DestinationPath .\MyNewModule -```PowerShell -Install-Module Plaster -Scope CurrentUser +# Non-interactive with parameters +Invoke-Plaster -TemplatePath $template.TemplatePath -DestinationPath .\MyModule ` + -ModuleName 'MyModule' -ModuleDesc 'My awesome module' -Version '1.0.0' ` + -FullName 'Your Name' -License MIT -Options Git,Pester,PSScriptAnalyzer ``` -Alternatively, you can download a ZIP file of the latest version from our [Releases](https://github.com/PowerShellOrg/Plaster/releases) -page. +### Creating Your First Template -## Documentation +#### Option 1: JSON Format (Recommended for new templates) +```powershell +# Create a new JSON manifest +New-PlasterManifest -TemplateName MyTemplate -TemplateType Project -Format JSON + +# Edit the generated plasterManifest.json with VS Code for full IntelliSense +code plasterManifest.json +``` + +#### Option 2: XML Format (Traditional) +```powershell +# Create a new XML manifest +New-PlasterManifest -TemplateName MyTemplate -TemplateType Project + +# Edit the generated plasterManifest.xml +code plasterManifest.xml +``` + +### JSON Manifest Example +```json +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "name": "MyTemplate", + "title": "My PowerShell Template", + "description": "Creates a new PowerShell project", + "version": "1.0.0", + "templateType": "Project" + }, + "parameters": [ + { + "name": "ProjectName", + "type": "text", + "prompt": "Enter the project name" + }, + { + "name": "Features", + "type": "multichoice", + "prompt": "Select features to include", + "default": [0, 1], + "choices": [ + {"label": "&Tests", "value": "Tests"}, + {"label": "&Build Script", "value": "Build"} + ] + } + ], + "content": [ + { + "type": "file", + "source": "template.ps1", + "destination": "${ProjectName}.ps1" + } + ] +} +``` + +## Core Concepts + +### Templates +Templates are directories containing: +- **Manifest File**: `plasterManifest.xml` or `plasterManifest.json` (or both) +- **Source Files**: Files and directories to be copied/processed +- **Template Files**: Files with variable substitution (using `<%=` and `%>` delimiters) + +### Parameters +Define user inputs with various types: +- **text**: Free-form text input +- **choice**: Single selection from options +- **multichoice**: Multiple selections from options +- **user-fullname**: User's full name (with git integration) +- **user-email**: User's email (with git integration) + +### Content Actions +Define what the template does: +- **file**: Copy files/directories +- **templateFile**: Copy and expand template files +- **modify**: Modify existing files with search/replace +- **newModuleManifest**: Generate PowerShell module manifests +- **requireModule**: Verify required modules are installed +- **message**: Display messages to users + +## Migration Guide + +### Converting XML to JSON +```powershell +# Automatic conversion +$xmlManifest = Test-PlasterManifest -Path .\plasterManifest.xml +ConvertTo-JsonManifest -InputObject $xmlManifest -OutputPath .\plasterManifest.json + +# Or use New-PlasterManifest with conversion +New-PlasterManifest -TemplateName MyTemplate -TemplateType Project -ConvertFromXml +``` + +### Variable Syntax Updates +When converting to JSON, update variable references: -You can learn how to use Plaster and write your templates by reading our documentation: +**XML Format:** +```xml + +``` + +**JSON Format:** +```json +{ + "type": "file", + "source": "template.ps1", + "destination": "${ModuleName}.ps1" +} +``` -+ [About Plaster](docs/en-US/about_Plaster.help.md) -+ [Creating a Plaster Manifest](docs/en-US/about_Plaster_CreatingAManifest.help.md) -+ [Cmdlet Documentation](docs/en-US/Plaster.md) +## Advanced Features -Or by watching: +### Conditional Logic +```json +{ + "type": "file", + "source": "tests.ps1", + "destination": "Tests/${ProjectName}.Tests.ps1", + "condition": "${Features} -contains 'Tests'" +} +``` -+ [Working with Plaster Presentation](https://youtu.be/16CYGTKH73U) by David Christian - [@dchristian3188](https://github.com/dchristian3188) +### Pattern Validation +```json +{ + "name": "ModuleName", + "type": "text", + "pattern": "^[A-Za-z][A-Za-z0-9_]*$", + "prompt": "Enter a valid module name" +} +``` -Or by checking out some blog posts on Plaster: +### Localization +Create culture-specific manifests: +- `plasterManifest.json` (default) +- `plasterManifest_fr-FR.json` (French) +- `plasterManifest_de-DE.json` (German) -+ [Working with Plaster](http://overpoweredshell.com/Working-with-Plaster/) by David Christian - [@dchristian3188](https://github.com/dchristian3188) +### Module Integration +Embed templates in PowerShell modules: +```powershell +# Module manifest (*.psd1) +PrivateData = @{ + PSData = @{ + Extensions = @( + @{ + Module = "Plaster" + MinimumVersion = "2.0.0" + Details = @{ + TemplatePaths = @("Templates") + } + } + ) + } +} +``` + +## Examples + +The `examples` directory contains comprehensive examples: + +- **NewModuleTemplate**: Full-featured module template (XML + JSON) +- **NewModule**: Simplified module template +- **NewDscResourceScript**: DSC resource template +- **TemplateModule**: PowerShell module with embedded templates +- **Validation Examples**: Input validation patterns +- **Localization Examples**: Multi-language support + +## Documentation -## Maintainers +- **Getting Started**: `Get-Help about_Plaster` +- **Creating XML Manifests**: `Get-Help about_Plaster_CreatingAManifest` +- **Creating JSON Manifests**: `Get-Help about_Plaster_CreatingJsonManifest` +- **Cmdlet Reference**: `Get-Help Invoke-Plaster`, `Get-Help New-PlasterManifest` -+ [Jeff Hicks](https://github.com/jdhitsolutions) - [@jeffhicks](http://twitter.com/jeffhicks) -+ [James Petty](https://github.com/psjamess) - [@PSJamesP](http://twitter.com/PSJamesP) +## Editor Integration + +### Visual Studio Code +- **JSON Schema**: Automatic validation and IntelliSense for JSON manifests +- **PowerShell Extension**: Template discovery and scaffolding support +- **File Association**: Associate `.json` files with Plaster schema + +### PowerShell ISE +- **Template Discovery**: Built-in template browser +- **Parameter Input**: GUI for template parameters + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +### Development Setup +```powershell +git clone https://github.com/PowerShell/Plaster.git +cd Plaster +Import-Module .\Plaster\Plaster.psd1 +Invoke-Pester # Run tests +``` ## License -This project is [licensed under the MIT License](LICENSE). +This project is licensed under the MIT License - see [LICENSE](LICENSE) for details. + +## Related Projects + +- **PowerShell**: https://github.com/PowerShell/PowerShell +- **Pester**: https://github.com/pester/Pester +- **PSScriptAnalyzer**: https://github.com/PowerShell/PSScriptAnalyzer +- **platyPS**: https://github.com/PowerShell/platyPS + +--- + +**Plaster 2.0** - Modern template scaffolding for PowerShell with JSON support, better tooling, and enhanced developer experience. 🚀 diff --git a/build.ps1 b/build.ps1 index 19b4abe..4c27d60 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,8 +1,24 @@ -#Requires -Modules psake -[cmdletbinding(DefaultParameterSetName = 'Task')] +[CmdletBinding(DefaultParameterSetName = 'Task')] param( # Build task(s) to execute [parameter(ParameterSetName = 'task', position = 0)] + [ArgumentCompleter( { + param($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams) + $psakeFile = './psakeFile.ps1' + switch ($Parameter) { + 'Task' { + if ([string]::IsNullOrEmpty($WordToComplete)) { + Get-PSakeScriptTasks -BuildFile $psakeFile | Select-Object -ExpandProperty Name + } else { + Get-PSakeScriptTasks -BuildFile $psakeFile | + Where-Object { $_.Name -match $WordToComplete } | + Select-Object -ExpandProperty Name + } + } + default { + } + } + })] [string[]]$Task = 'default', # Bootstrap dependencies @@ -20,25 +36,30 @@ param( ) $ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# Bootstrap dependencies +if ($Bootstrap.IsPresent) { + PackageManagement\Get-PackageProvider -Name Nuget -ForceBootstrap | Out-Null + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + if ((Test-Path -Path ./requirements.psd1)) { + if (-not (Get-Module -Name PSDepend -ListAvailable)) { + Install-Module -Name PSDepend -Repository PSGallery -Scope CurrentUser -Force + } + Import-Module -Name PSDepend -Verbose:$false + Invoke-PSDepend -Path .\requirements.psd1 -Install -Import -Force -WarningAction SilentlyContinue + } else { + Write-Warning 'No [requirements.psd1] found. Skipping build dependency installation.' + } +} # Execute psake task(s) -$psakeFile = "$PSScriptRoot\psakeFile.ps1" +$psakeFile = './psakeFile.ps1' if ($PSCmdlet.ParameterSetName -eq 'Help') { - Get-PSakeScriptTasks -buildFile $psakeFile | - Format-Table -Property Name, Description, Alias, DependsOn + Get-PSakeScriptTasks -BuildFile $psakeFile | + Format-Table -Property Name, Description, Alias, DependsOn } else { Set-BuildEnvironment -Force - $parameters = @{} - if ($PSGalleryApiKey) { - $parameters['galleryApiKey'] = $PSGalleryApiKey - } - $psake_splat = @{ - buildFile = $psakeFile - taskList = $Task - nologo = $True - properties = $Properties - parameters = $Parameters - } - Invoke-PSake @psake_splat + Invoke-psake -buildFile $psakeFile -taskList $Task -nologo -properties $Properties -parameters $Parameters exit ([int](-not $psake.build_success)) -} \ No newline at end of file +} diff --git a/build.psake.ps1 b/build.psake.ps1 deleted file mode 100644 index 513ded1..0000000 --- a/build.psake.ps1 +++ /dev/null @@ -1,693 +0,0 @@ -#Requires -Modules psake - -############################################################################## -# DO NOT MODIFY THIS FILE! Modify build.settings.ps1 instead. -############################################################################## - -############################################################################## -# This is the PowerShell Module psake build script. It defines the following tasks: -# -# Clean, Build, Sign, BuildHelp, Install, Test and Publish. -# -# The default task is Build. This task copies the appropriate files from the -# $SrcRootDir under the $OutDir. Later, other tasks such as Sign and BuildHelp -# will further modify the contents of $OutDir and add new files. -# -# The Sign task will only sign scripts if the $SignScripts variable is set to -# $true. A code-signing certificate is required for this task to complete. -# -# The BuildHelp task invokes platyPS to generate markdown files from -# comment-based help for your exported commands. platyPS then generates -# a help file for your module from the markdown files. -# -# The Install task simplies copies the module folder under $OutDir to your -# profile's Modules folder. -# -# The Test task invokes Pester on the $TestRootDir. -# -# The Publish task uses the Publish-Module command to publish -# to either the PowerShell Gallery (the default) or you can change -# the $PublishRepository property to the name of an alternate repository. -# Note: the Publish task requires that the Test task execute without failures. -# -# You can exeute a specific task, such as the Test task by running the -# following command: -# -# PS C:\> invoke-psake build.psake.ps1 -taskList Test -# -# You can execute the Publish task with the following command. -# The first time you execute the Publish task, you will be prompted to enter -# your PowerShell Gallery NuGetApiKey. After entering the key, it is encrypted -# and stored so you will not have to enter it again. -# -# PS C:\> invoke-psake build.psake.ps1 -taskList Publish -# -# You can verify the stored and encrypted NuGetApiKey by running the following -# command which will display a portion of your NuGetApiKey in plain text. -# -# PS C:\> invoke-psake build.psake.ps1 -taskList ShowApiKey -# -# You can store a new NuGetApiKey with this command. You can leave off -# the -properties parameter and you'll be prompted for the key. -# -# PS C:\> invoke-psake build.psake.ps1 -taskList StoreApiKey -properties @{NuGetApiKey='test123'} -# - -############################################################################### -# Dot source the user's customized properties and extension tasks. -############################################################################### -. $PSScriptRoot\build.settings.ps1 - -############################################################################### -# Private properties. -############################################################################### -Properties { - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] - $ModuleOutDir = "$OutDir\$ModuleName" - - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] - $UpdatableHelpOutDir = "$OutDir\UpdatableHelp" - - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] - $SharedProperties = @{} - - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] - $LineSep = "-" * 78 -} - -############################################################################### -# Core task implementations. Avoid modifying these tasks. -############################################################################### -Task default -depends Build - -Task Init -requiredVariables OutDir { - if (!(Test-Path -LiteralPath $OutDir)) { - New-Item $OutDir -ItemType Directory -Verbose:$VerbosePreference > $null - } - else { - Write-Verbose "$($psake.context.currentTaskName) - directory already exists '$OutDir'." - } -} - -Task Clean -depends Init -requiredVariables OutDir { - # Maybe a bit paranoid but this task nuked \ on my laptop. Good thing I was not running as admin. - if ($OutDir.Length -gt 3) { - Get-ChildItem $OutDir | Remove-Item -Recurse -Force -Verbose:$VerbosePreference - } - else { - Write-Verbose "$($psake.context.currentTaskName) - `$OutDir '$OutDir' must be longer than 3 characters." - } -} - -Task StageFiles -depends Init, Clean, BeforeStageFiles, CoreStageFiles, AfterStageFiles { -} - -Task CoreStageFiles -requiredVariables ModuleOutDir, SrcRootDir { - if (!(Test-Path -LiteralPath $ModuleOutDir)) { - New-Item $ModuleOutDir -ItemType Directory -Verbose:$VerbosePreference > $null - } - else { - Write-Verbose "$($psake.context.currentTaskName) - directory already exists '$ModuleOutDir'." - } - - Copy-Item -Path $SrcRootDir\* -Destination $ModuleOutDir -Recurse -Exclude $Exclude -Verbose:$VerbosePreference -} - -Task Build -depends Init, Clean, BeforeBuild, StageFiles, Analyze, Sign, AfterBuild { -} - -Task Analyze -depends StageFiles ` - -requiredVariables ModuleOutDir, ScriptAnalysisEnabled, ScriptAnalysisFailBuildOnSeverityLevel, ScriptAnalyzerSettingsPath { - if (!$ScriptAnalysisEnabled) { - "Script analysis is not enabled. Skipping $($psake.context.currentTaskName) task." - return - } - - if (!(Get-Module PSScriptAnalyzer -ListAvailable)) { - "PSScriptAnalyzer module is not installed. Skipping $($psake.context.currentTaskName) task." - return - } - - "ScriptAnalysisFailBuildOnSeverityLevel set to: $ScriptAnalysisFailBuildOnSeverityLevel" - - $analysisResult = Invoke-ScriptAnalyzer -Path $ModuleOutDir -Settings $ScriptAnalyzerSettingsPath -Recurse -Verbose:$VerbosePreference - $analysisResult | Format-Table - switch ($ScriptAnalysisFailBuildOnSeverityLevel) { - 'None' { - return - } - 'Error' { - Assert -conditionToCheck ( - ($analysisResult | Where-Object Severity -eq 'Error').Count -eq 0 - ) -failureMessage 'One or more ScriptAnalyzer errors were found. Build cannot continue!' - } - 'Warning' { - Assert -conditionToCheck ( - ($analysisResult | Where-Object { - $_.Severity -eq 'Warning' -or $_.Severity -eq 'Error' - }).Count -eq 0) -failureMessage 'One or more ScriptAnalyzer warnings were found. Build cannot continue!' - } - default { - Assert -conditionToCheck ( - $analysisResult.Count -eq 0 - ) -failureMessage 'One or more ScriptAnalyzer issues were found. Build cannot continue!' - } - } -} - -Task Sign -depends StageFiles -requiredVariables CertPath, SettingsPath, ScriptSigningEnabled { - if (!$ScriptSigningEnabled) { - "Script signing is not enabled. Skipping $($psake.context.currentTaskName) task." - return - } - - $validCodeSigningCerts = Get-ChildItem -Path $CertPath -CodeSigningCert -Recurse | Where-Object NotAfter -ge (Get-Date) - if (!$validCodeSigningCerts) { - throw "There are no non-expired code-signing certificates in $CertPath. You can either install " + - "a code-signing certificate into the certificate store or disable script analysis in build.settings.ps1." - } - - $certSubjectNameKey = "CertSubjectName" - $storeCertSubjectName = $true - - # Get the subject name of the code-signing certificate to be used for script signing. - if (!$CertSubjectName -and ($CertSubjectName = GetSetting -Key $certSubjectNameKey -Path $SettingsPath)) { - $storeCertSubjectName = $false - } - elseif (!$CertSubjectName) { - "A code-signing certificate has not been specified." - "The following non-expired, code-signing certificates are available in your certificate store:" - $validCodeSigningCerts | Format-List Subject,Issuer,Thumbprint,NotBefore,NotAfter - - $CertSubjectName = Read-Host -Prompt 'Enter the subject name (case-sensitive) of the certificate to use for script signing' - } - - # Find a code-signing certificate that matches the specified subject name. - $certificate = $validCodeSigningCerts | - Where-Object { $_.SubjectName.Name -cmatch [regex]::Escape($CertSubjectName) } | - Sort-Object NotAfter -Descending | Select-Object -First 1 - - if ($certificate) { - $SharedProperties.CodeSigningCertificate = $certificate - - if ($storeCertSubjectName) { - SetSetting -Key $certSubjectNameKey -Value $certificate.SubjectName.Name -Path $SettingsPath - "The new certificate subject name has been stored in ${SettingsPath}." - } - else { - "Using stored certificate subject name $CertSubjectName from ${SettingsPath}." - } - - $LineSep - "Using code-signing certificate: $certificate" - $LineSep - - $files = @(Get-ChildItem -Path $ModuleOutDir\* -Recurse -Include *.ps1,*.psm1) - foreach ($file in $files) { - $setAuthSigParams = @{ - FilePath = $file.FullName - Certificate = $certificate - Verbose = $VerbosePreference - } - - $result = Microsoft.PowerShell.Security\Set-AuthenticodeSignature @setAuthSigParams - if ($result.Status -ne 'Valid') { - throw "Failed to sign script: $($file.FullName)." - } - - "Successfully signed script: $($file.Name)" - } - } - else { - $expiredCert = Get-ChildItem -Path $CertPath -CodeSigningCert -Recurse | - Where-Object { ($_.SubjectName.Name -cmatch [regex]::Escape($CertSubjectName)) -and - ($_.NotAfter -lt (Get-Date)) } - Sort-Object NotAfter -Descending | Select-Object -First 1 - - if ($expiredCert) { - throw "The code-signing certificate `"$($expiredCert.SubjectName.Name)`" EXPIRED on $($expiredCert.NotAfter)." - } - - throw 'No valid certificate subject name supplied or stored.' - } -} - -Task BuildHelp -depends Build, BeforeBuildHelp, GenerateMarkdown, GenerateHelpFiles, AfterBuildHelp { -} - -Task GenerateMarkdown -requiredVariables DefaultLocale, DocsRootDir, ModuleName, ModuleOutDir { - if (!(Get-Module platyPS -ListAvailable)) { - "platyPS module is not installed. Skipping $($psake.context.currentTaskName) task." - return - } - - $moduleInfo = Import-Module $ModuleOutDir\$ModuleName.psd1 -Global -Force -PassThru - - try { - if ($moduleInfo.ExportedCommands.Count -eq 0) { - "No commands have been exported. Skipping $($psake.context.currentTaskName) task." - return - } - - if (!(Test-Path -LiteralPath $DocsRootDir)) { - New-Item $DocsRootDir -ItemType Directory > $null - } - - if (Get-ChildItem -LiteralPath $DocsRootDir -Filter *.md -Recurse) { - Get-ChildItem -LiteralPath $DocsRootDir -Directory | ForEach-Object { - Update-MarkdownHelp -Path $_.FullName -Verbose:$VerbosePreference > $null - } - } - - # ErrorAction set to SilentlyContinue so this command will not overwrite an existing MD file. - New-MarkdownHelp -Module $ModuleName -Locale $DefaultLocale -OutputFolder $DocsRootDir\$DefaultLocale ` - -WithModulePage -ErrorAction SilentlyContinue -Verbose:$VerbosePreference > $null - } - finally { - Remove-Module $ModuleName - } -} - -Task GenerateHelpFiles -requiredVariables DocsRootDir, ModuleName, ModuleOutDir, OutDir { - if (!(Get-Module platyPS -ListAvailable)) { - "platyPS module is not installed. Skipping $($psake.context.currentTaskName) task." - return - } - - if (!(Get-ChildItem -LiteralPath $DocsRootDir -Filter *.md -Recurse -ErrorAction SilentlyContinue)) { - "No markdown help files to process. Skipping $($psake.context.currentTaskName) task." - return - } - - $helpLocales = (Get-ChildItem -Path $DocsRootDir -Directory).Name - - # Generate the module's primary MAML help file. - foreach ($locale in $helpLocales) { - New-ExternalHelp -Path $DocsRootDir\$locale -OutputPath $ModuleOutDir\$locale -Force ` - -ErrorAction SilentlyContinue -Verbose:$VerbosePreference > $null - } -} - -Task BuildUpdatableHelp -depends BuildHelp, BeforeBuildUpdatableHelp, CoreBuildUpdatableHelp, AfterBuildUpdatableHelp { -} - -Task CoreBuildUpdatableHelp -requiredVariables DocsRootDir, ModuleName, UpdatableHelpOutDir { - if (!(Get-Module platyPS -ListAvailable)) { - "platyPS module is not installed. Skipping $($psake.context.currentTaskName) task." - return - } - - $helpLocales = (Get-ChildItem -Path $DocsRootDir -Directory).Name - - # Create updatable help output directory. - if (!(Test-Path -LiteralPath $UpdatableHelpOutDir)) { - New-Item $UpdatableHelpOutDir -ItemType Directory -Verbose:$VerbosePreference > $null - } - else { - Write-Verbose "$($psake.context.currentTaskName) - directory already exists '$UpdatableHelpOutDir'." - Get-ChildItem $UpdatableHelpOutDir | Remove-Item -Recurse -Force -Verbose:$VerbosePreference - } - - # Generate updatable help files. Note: this will currently update the version number in the module's MD - # file in the metadata. - foreach ($locale in $helpLocales) { - New-ExternalHelpCab -CabFilesFolder $ModuleOutDir\$locale -LandingPagePath $DocsRootDir\$locale\$ModuleName.md ` - -OutputFolder $UpdatableHelpOutDir -Verbose:$VerbosePreference > $null - } -} - -Task GenerateFileCatalog -depends Build, BuildHelp, BeforeGenerateFileCatalog, CoreGenerateFileCatalog, AfterGenerateFileCatalog { -} - -Task CoreGenerateFileCatalog -requiredVariables CatalogGenerationEnabled, CatalogVersion, ModuleName, ModuleOutDir, OutDir { - if (!$CatalogGenerationEnabled) { - "FileCatalog generation is not enabled. Skipping $($psake.context.currentTaskName) task." - return - } - - if (!(Get-Command Microsoft.PowerShell.Security\New-FileCatalog -ErrorAction SilentlyContinue)) { - "FileCatalog commands not available on this version of PowerShell. Skipping $($psake.context.currentTaskName) task." - return - } - - $catalogFilePath = "$OutDir\$ModuleName.cat" - - $newFileCatalogParams = @{ - Path = $ModuleOutDir - CatalogFilePath = $catalogFilePath - CatalogVersion = $CatalogVersion - Verbose = $VerbosePreference - } - - Microsoft.PowerShell.Security\New-FileCatalog @newFileCatalogParams > $null - - if ($ScriptSigningEnabled) { - if ($SharedProperties.CodeSigningCertificate) { - $setAuthSigParams = @{ - FilePath = $catalogFilePath - Certificate = $SharedProperties.CodeSigningCertificate - Verbose = $VerbosePreference - } - - $result = Microsoft.PowerShell.Security\Set-AuthenticodeSignature @setAuthSigParams - if ($result.Status -ne 'Valid') { - throw "Failed to sign file catalog: $($catalogFilePath)." - } - - "Successfully signed file catalog: $($catalogFilePath)" - } - else { - "No code-signing certificate was found to sign the file catalog." - } - } - else { - "Script signing is not enabled. Skipping signing of file catalog." - } - - Move-Item -LiteralPath $newFileCatalogParams.CatalogFilePath -Destination $ModuleOutDir -} - -Task Install -depends Build, BuildHelp, GenerateFileCatalog, BeforeInstall, CoreInstall, AfterInstall { -} - -Task CoreInstall -requiredVariables ModuleOutDir { - if (!(Test-Path -LiteralPath $InstallPath)) { - Write-Verbose 'Creating install directory' - New-Item -Path $InstallPath -ItemType Directory -Verbose:$VerbosePreference > $null - } - - Copy-Item -Path $ModuleOutDir\* -Destination $InstallPath -Verbose:$VerbosePreference -Recurse -Force - "Module installed into $InstallPath" -} - -Task Test -depends Build -requiredVariables TestRootDir, ModuleName, CodeCoverageEnabled, CodeCoverageFiles { - if (!(Get-Module Pester -ListAvailable)) { - "Pester module is not installed. Skipping $($psake.context.currentTaskName) task." - return - } - - Import-Module Pester - - try { - Microsoft.PowerShell.Management\Push-Location -LiteralPath $TestRootDir - - if ($TestOutputFile) { - $testing = @{ - OutputFile = $TestOutputFile - OutputFormat = $TestOutputFormat - PassThru = $true - Verbose = $VerbosePreference - } - } - else { - $testing = @{ - PassThru = $true - Verbose = $VerbosePreference - } - } - - # To control the Pester code coverage, a boolean $CodeCoverageEnabled is used. - if ($CodeCoverageEnabled) { - $testing.CodeCoverage = $CodeCoverageFiles - } - - $testResult = Invoke-Pester @testing - - Assert -conditionToCheck ( - $testResult.FailedCount -eq 0 - ) -failureMessage "One or more Pester tests failed, build cannot continue." - - if ($CodeCoverageEnabled) { - $testCoverage = [int]($testResult.CodeCoverage.NumberOfCommandsExecuted / - $testResult.CodeCoverage.NumberOfCommandsAnalyzed * 100) - "Pester code coverage on specified files: ${testCoverage}%" - } - } - finally { - Microsoft.PowerShell.Management\Pop-Location - Remove-Module $ModuleName -ErrorAction SilentlyContinue - } -} - -Task Publish -depends Build, Test, BuildHelp, GenerateFileCatalog, BeforePublish, CorePublish, AfterPublish { -} - -Task CorePublish -requiredVariables SettingsPath, ModuleOutDir { - $publishParams = @{ - Path = $ModuleOutDir - NuGetApiKey = $NuGetApiKey - } - - # Publishing to the PSGallery requires an API key, so get it. - if ($NuGetApiKey) { - "Using script embedded NuGetApiKey" - } - elseif ($NuGetApiKey = GetSetting -Path $SettingsPath -Key NuGetApiKey) { - "Using stored NuGetApiKey" - } - else { - $promptForKeyCredParams = @{ - DestinationPath = $SettingsPath - Message = 'Enter your NuGet API key in the password field' - Key = 'NuGetApiKey' - } - - $cred = PromptUserForCredentialAndStorePassword @promptForKeyCredParams - $NuGetApiKey = $cred.GetNetworkCredential().Password - "The NuGetApiKey has been stored in $SettingsPath" - } - - $publishParams = @{ - Path = $ModuleOutDir - NuGetApiKey = $NuGetApiKey - } - - # If an alternate repository is specified, set the appropriate parameter. - if ($PublishRepository) { - $publishParams['Repository'] = $PublishRepository - } - - # Consider not using -ReleaseNotes parameter when Update-ModuleManifest has been fixed. - if ($ReleaseNotesPath) { - $publishParams['ReleaseNotes'] = @(Get-Content $ReleaseNotesPath) - } - - "Calling Publish-Module..." - Publish-Module @publishParams -} - -############################################################################### -# Secondary/utility tasks - typically used to manage stored build settings. -############################################################################### - -Task ? -description 'Lists the available tasks' { - "Available tasks:" - $psake.context.Peek().Tasks.Keys | Sort-Object -} - -Task RemoveApiKey -requiredVariables SettingsPath { - if (GetSetting -Path $SettingsPath -Key NuGetApiKey) { - RemoveSetting -Path $SettingsPath -Key NuGetApiKey - } -} - -Task StoreApiKey -requiredVariables SettingsPath { - $promptForKeyCredParams = @{ - DestinationPath = $SettingsPath - Message = 'Enter your NuGet API key in the password field' - Key = 'NuGetApiKey' - } - - PromptUserForCredentialAndStorePassword @promptForKeyCredParams - "The NuGetApiKey has been stored in $SettingsPath" -} - -Task ShowApiKey -requiredVariables SettingsPath { - $OFS = "" - if ($NuGetApiKey) { - "The embedded (partial) NuGetApiKey is: $($NuGetApiKey[0..7])" - } - elseif ($NuGetApiKey = GetSetting -Path $SettingsPath -Key NuGetApiKey) { - "The stored (partial) NuGetApiKey is: $($NuGetApiKey[0..7])" - } - else { - "The NuGetApiKey has not been provided or stored." - return - } - - "To see the full key, use the task 'ShowFullApiKey'" -} - -Task ShowFullApiKey -requiredVariables SettingsPath { - if ($NuGetApiKey) { - "The embedded NuGetApiKey is: $NuGetApiKey" - } - elseif ($NuGetApiKey = GetSetting -Path $SettingsPath -Key NuGetApiKey) { - "The stored NuGetApiKey is: $NuGetApiKey" - } - else { - "The NuGetApiKey has not been provided or stored." - } -} - -Task RemoveCertSubjectName -requiredVariables SettingsPath { - if (GetSetting -Path $SettingsPath -Key CertSubjectName) { - RemoveSetting -Path $SettingsPath -Key CertSubjectName - } -} - -Task StoreCertSubjectName -requiredVariables SettingsPath { - $certSubjectName = 'CN=' - $certSubjectName += Read-Host -Prompt 'Enter the certificate subject name for script signing. Use exact casing, CN= prefix will be added' - SetSetting -Key CertSubjectName -Value $certSubjectName -Path $SettingsPath - "The new certificate subject name '$certSubjectName' has been stored in ${SettingsPath}." -} - -Task ShowCertSubjectName -requiredVariables SettingsPath { - $CertSubjectName = GetSetting -Path $SettingsPath -Key CertSubjectName - "The stored certificate is: $CertSubjectName" - - $cert = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert | - Where-Object { $_.Subject -eq $CertSubjectName -and $_.NotAfter -gt (Get-Date) } | - Sort-Object -Property NotAfter -Descending | Select-Object -First 1 - - if ($cert) { - "A valid certificate for the subject $CertSubjectName has been found" - } - else { - 'A valid certificate has not been found' - } -} - -############################################################################### -# Helper functions -############################################################################### - -function PromptUserForCredentialAndStorePassword { - [Diagnostics.CodeAnalysis.SuppressMessage("PSProvideDefaultParameterValue", '')] - param( - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] - $DestinationPath, - - [Parameter(Mandatory)] - [string] - $Message, - - [Parameter(Mandatory, ParameterSetName = 'SaveSetting')] - [string] - $Key - ) - - $cred = Get-Credential -Message $Message -UserName "ignored" - if ($DestinationPath) { - SetSetting -Key $Key -Value $cred.Password -Path $DestinationPath - } - - $cred -} - -function AddSetting { - [System.Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '', Scope='Function')] - param( - [Parameter(Mandatory)] - [string]$Key, - - [Parameter(Mandatory)] - [string]$Path, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object]$Value - ) - - switch ($type = $Value.GetType().Name) { - 'securestring' { $setting = $Value | ConvertFrom-SecureString } - default { $setting = $Value } - } - - if (Test-Path -LiteralPath $Path) { - $storedSettings = Import-Clixml -Path $Path - $storedSettings.Add($Key, @($type, $setting)) - $storedSettings | Export-Clixml -Path $Path - } - else { - $parentDir = Split-Path -Path $Path -Parent - if (!(Test-Path -LiteralPath $parentDir)) { - New-Item $parentDir -ItemType Directory > $null - } - - @{$Key = @($type, $setting)} | Export-Clixml -Path $Path - } -} - -function GetSetting { - param( - [Parameter(Mandatory)] - [string]$Key, - - [Parameter(Mandatory)] - [string]$Path - ) - - if (Test-Path -LiteralPath $Path) { - $securedSettings = Import-Clixml -Path $Path - if ($securedSettings.$Key) { - switch ($securedSettings.$Key[0]) { - 'securestring' { - $value = $securedSettings.$Key[1] | ConvertTo-SecureString - $cred = New-Object -TypeName PSCredential -ArgumentList 'jpgr', $value - $cred.GetNetworkCredential().Password - } - default { - $securedSettings.$Key[1] - } - } - } - } -} - -function SetSetting { - param( - [Parameter(Mandatory)] - [string]$Key, - - [Parameter(Mandatory)] - [string]$Path, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object]$Value - ) - - if (GetSetting -Key $Key -Path $Path) { - RemoveSetting -Key $Key -Path $Path - } - - AddSetting -Key $Key -Value $Value -Path $Path -} - -function RemoveSetting { - param( - [Parameter(Mandatory)] - [string]$Key, - - [Parameter(Mandatory)] - [string]$Path - ) - - if (Test-Path -LiteralPath $Path) { - $storedSettings = Import-Clixml -Path $Path - $storedSettings.Remove($Key) - if ($storedSettings.Count -eq 0) { - Remove-Item -Path $Path - } - else { - $storedSettings | Export-Clixml -Path $Path - } - } - else { - Write-Warning "The build setting file '$Path' has not been created yet." - } -} diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..24b9c81 --- /dev/null +++ b/cspell.json @@ -0,0 +1,24 @@ +{ + "version": "0.2", + "ignorePaths": [], + "dictionaryDefinitions": [], + "dictionaries": [ + "powershell" + ], + "words": [ + "psake", + "psakefile", + "psakefileps1", + "psbpreference", + "compilemodule", + "defaultlocale", + "testoutputfile", + "frommodule", + "minimumversion", + "plaster", + "multichoice", + "BHPS" + ], + "ignoreWords": [], + "import": [] +} diff --git a/debugHarness.ps1 b/debugHarness.ps1 index b893bd1..c528ea4 100644 --- a/debugHarness.ps1 +++ b/debugHarness.ps1 @@ -1,5 +1,21 @@ # Use this file to debug the module. -Import-Module $PSScriptRoot\src\Plaster.psd1 +if ($null -eq $env:BHProjectPath) { + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\build.ps1' + . $path -Task Build +} +$manifest = Import-PowerShellDataFile -Path $env:BHPSModuleManifest +$outputDir = Join-Path -Path $env:BHProjectPath -ChildPath 'Output' +$outputModDir = Join-Path -Path $outputDir -ChildPath $env:BHProjectName +$outputModVerDir = Join-Path -Path $outputModDir -ChildPath $manifest.ModuleVersion +$outputModVerManifest = Join-Path -Path $outputModVerDir -ChildPath "$($env:BHProjectName).psd1" +Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore +Import-Module -Name $outputModVerManifest -Verbose:$false -ErrorAction Stop + +#region Setup Output Directory +$OutDir = Join-Path $outputDir "\HarnessOutput" +Remove-Item $OutDir -Recurse -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $OutDir | Out-Null +#endregion Setup Output Directory # Various debug scenarios other than running Invoke-Plaster. # Invoke-Pester $PSScriptRoot\test @@ -7,9 +23,6 @@ Import-Module $PSScriptRoot\src\Plaster.psd1 # Invoke-psake $PSScriptRoot\build.psake.ps1 -taskList BuildHelp # return -$OutDir = "$PSScriptRoot\examples\Out" -Remove-Item $OutDir -Recurse -ErrorAction SilentlyContinue - # $PlasterParams = @{ # TemplatePath = "$PSScriptRoot\src\Templates\AddPSScriptAnalyzerSettings" # DestinationPath = $OutDir @@ -19,7 +32,7 @@ Remove-Item $OutDir -Recurse -ErrorAction SilentlyContinue # } $PlasterParams = @{ - TemplatePath = "$PSScriptRoot\src\Templates\NewPowerShellScriptModule" + TemplatePath = "$PSScriptRoot\Plaster\Templates\NewPowerShellScriptModule" DestinationPath = $OutDir ModuleName = 'FooUtils' Version = '1.2.0' @@ -48,7 +61,7 @@ $PlasterParams = @{ # PassThru = $true # } -$obj = Invoke-Plaster @PlasterParams -Force +$obj = Invoke-Plaster @PlasterParams -WhatIf "PassThru object is:" $obj diff --git a/docs/en-US/Get-PlasterTemplate.md b/docs/en-US/Get-PlasterTemplate.md index 97e0be1..ded7b9a 100644 --- a/docs/en-US/Get-PlasterTemplate.md +++ b/docs/en-US/Get-PlasterTemplate.md @@ -15,13 +15,14 @@ cmdlet. ### Path ``` -Get-PlasterTemplate [[-Path] ] [[-Name] ] [-Tag ] [-Recurse] [] +Get-PlasterTemplate [[-Path] ] [[-Name] ] [-Tag ] [-Recurse] + [-ProgressAction ] [] ``` ### IncludedTemplates ``` Get-PlasterTemplate [[-Name] ] [-Tag ] [-IncludeInstalledModules] [-ListAvailable] - [] + [-ProgressAction ] [] ``` ## DESCRIPTION @@ -198,6 +199,21 @@ 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). diff --git a/docs/en-US/Invoke-Plaster.md b/docs/en-US/Invoke-Plaster.md index 94f3ffa..463ab88 100644 --- a/docs/en-US/Invoke-Plaster.md +++ b/docs/en-US/Invoke-Plaster.md @@ -12,9 +12,16 @@ Invokes the specified Plaster template which will scaffold out a file or a set o ## SYNTAX +### TemplatePath (Default) ``` -Invoke-Plaster [-TemplatePath] [-DestinationPath] [-Force] [-NoLogo] [-PassThru] [-WhatIf] - [-Confirm] [] +Invoke-Plaster [-TemplatePath] [-DestinationPath] [-Force] [-NoLogo] [-PassThru] + [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +### TemplateDefinition +``` +Invoke-Plaster [-TemplateDefinition] [-DestinationPath] [-Force] [-NoLogo] [-PassThru] + [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -131,12 +138,27 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -TemplateDefinition +{{ Fill TemplateDefinition Description }} + +```yaml +Type: String +Parameter Sets: TemplateDefinition +Aliases: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -TemplatePath Specifies the path to the template directory. ```yaml Type: String -Parameter Sets: (All) +Parameter Sets: TemplatePath Aliases: Required: True @@ -162,6 +184,21 @@ 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). diff --git a/docs/en-US/New-PlasterManifest.md b/docs/en-US/New-PlasterManifest.md index a07a1b8..227a7b0 100644 --- a/docs/en-US/New-PlasterManifest.md +++ b/docs/en-US/New-PlasterManifest.md @@ -15,7 +15,8 @@ Creates a new Plaster template manifest file. ``` New-PlasterManifest [[-Path] ] [-TemplateName] [-TemplateType] [[-Id] ] [[-TemplateVersion] ] [[-Title] ] [[-Description] ] [[-Tags] ] - [[-Author] ] [-AddContent] [-WhatIf] [-Confirm] [] + [[-Author] ] [-AddContent] [[-Format] ] [-ConvertFromXml] [-ProgressAction ] + [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -117,6 +118,21 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ConvertFromXml +{{ Fill ConvertFromXml Description }} + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Description Description of the Plaster template. This describes what the template is for. @@ -137,6 +153,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Format +{{ Fill Format Description }} + +```yaml +Type: String +Parameter Sets: (All) +Aliases: +Accepted values: XML, JSON + +Required: False +Position: 9 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Id Unique identifier for all versions of this template. The id is a GUID. @@ -283,6 +315,21 @@ 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). diff --git a/docs/en-US/Test-PlasterManifest.md b/docs/en-US/Test-PlasterManifest.md index 1be7aff..ad487cd 100644 --- a/docs/en-US/Test-PlasterManifest.md +++ b/docs/en-US/Test-PlasterManifest.md @@ -13,7 +13,7 @@ Verifies that a plaster manifest file is valid. ## SYNTAX ``` -Test-PlasterManifest [[-Path] ] [] +Test-PlasterManifest [[-Path] ] [-ProgressAction ] [] ``` ## DESCRIPTION @@ -57,6 +57,21 @@ Accept pipeline input: True (ByPropertyName, ByValue) 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). diff --git a/examples/NewModule/editor/VSCode/tasks_pester.json b/examples/NewModule/editor/VSCode/tasks_pester.json index 6630bef..8e0fde2 100644 --- a/examples/NewModule/editor/VSCode/tasks_pester.json +++ b/examples/NewModule/editor/VSCode/tasks_pester.json @@ -49,5 +49,5 @@ }, "problemMatcher": [ "$pester" ] } - ] + ] } diff --git a/examples/NewModule/editor/VSCode/tasks_psake.json b/examples/NewModule/editor/VSCode/tasks_psake.json index 3d081b2..d58fce1 100644 --- a/examples/NewModule/editor/VSCode/tasks_psake.json +++ b/examples/NewModule/editor/VSCode/tasks_psake.json @@ -79,5 +79,5 @@ "command": "Invoke-psake build.psake.ps1 -taskList Publish", "problemMatcher": [] } - ] + ] } diff --git a/examples/NewModule/editor/VSCode/tasks_psake_pester.json b/examples/NewModule/editor/VSCode/tasks_psake_pester.json index 7ba6b0b..f413e48 100644 --- a/examples/NewModule/editor/VSCode/tasks_psake_pester.json +++ b/examples/NewModule/editor/VSCode/tasks_psake_pester.json @@ -89,5 +89,5 @@ }, "problemMatcher": [ "$pester" ] } - ] + ] } diff --git a/examples/NewModule/plasterManifest.json b/examples/NewModule/plasterManifest.json new file mode 100644 index 0000000..849e082 --- /dev/null +++ b/examples/NewModule/plasterManifest.json @@ -0,0 +1,277 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "id": "dcd95744-8abc-4ecb-a439-bf2cd37821bb", + "name": "NewModule", + "title": "New Module", + "description": "Scaffolds the files required for a PowerShell module.", + "version": "1.0.0", + "templateType": "Project", + "author": "Plaster Project", + "tags": [ + "Module", + "ModuleManifest", + "Build" + ] + }, + "parameters": [ + { + "name": "ModuleName", + "type": "text", + "prompt": "Enter the name of the module" + }, + { + "name": "ModuleDesc", + "type": "text", + "prompt": "Enter a description of the module (required for publishing to the PowerShell Gallery)" + }, + { + "name": "Version", + "type": "text", + "prompt": "Enter the version number of the module", + "default": "0.1.0" + }, + { + "name": "FullName", + "type": "user-fullname", + "prompt": "Enter your full name", + "store": "text" + }, + { + "name": "License", + "type": "choice", + "prompt": "Select a license (see http://choosealicense.com for help choosing):", + "default": 0, + "store": "text", + "choices": [ + { + "label": "&None", + "help": "No license.", + "value": "None" + }, + { + "label": "&Apache", + "help": "Adds an Apache license file.", + "value": "Apache" + }, + { + "label": "&MIT", + "help": "Adds an MIT license file.", + "value": "MIT" + } + ] + }, + { + "name": "Options", + "type": "multichoice", + "prompt": "Select one or more of the following tooling options:", + "default": [ + 1, + 2, + 3, + 4 + ], + "store": "text", + "choices": [ + { + "label": "&None", + "help": "No tooling options specified.", + "value": "None" + }, + { + "label": "Add &Git .gitignore file", + "help": "Adds a .gitignore file.", + "value": "Git" + }, + { + "label": "Add p&sake build script", + "help": "Adds psake build script that generates the module directory for publishing to the PowerShell Gallery.", + "value": "psake" + }, + { + "label": "Add &Pester test support", + "help": "Adds test directory and Pester test for the module manifest file.", + "value": "Pester" + }, + { + "label": "Add PSScript&Analyzer support", + "help": "Adds script analysis support using PSScriptAnalyzer.", + "value": "PSScriptAnalyzer" + }, + { + "label": "Add plat&yPS help generation support", + "help": "Adds help build support using platyPS.", + "value": "platyPS" + } + ] + }, + { + "name": "Editor", + "type": "choice", + "prompt": "Select one of the supported script editors for better editor integration (or None):", + "default": 0, + "store": "text", + "choices": [ + { + "label": "&None", + "help": "No editor specified.", + "value": "None" + }, + { + "label": "Visual Studio &Code", + "help": "Your editor is Visual Studio Code.", + "value": "VSCode" + } + ] + } + ], + "content": [ + { + "type": "message", + "text": "\n\n\nScaffolding your PowerShell Module...\n\n\n" + }, + { + "type": "newModuleManifest", + "destination": "src\\${ModuleName}.psd1", + "moduleVersion": "${Version}", + "rootModule": "${ModuleName}.psm1", + "author": "${FullName}", + "description": "${ModuleDesc}", + "encoding": "UTF8-NoBOM" + }, + { + "type": "file", + "condition": "${Options} -contains 'Git'", + "source": "_gitignore", + "destination": ".gitignore" + }, + { + "type": "file", + "condition": "${Options} -contains 'psake'", + "source": "build*.ps1", + "destination": "" + }, + { + "type": "file", + "source": "ReleaseNotes.md", + "destination": "" + }, + { + "type": "file", + "source": "src\\Module.psm1", + "destination": "src\\${ModuleName}.psm1" + }, + { + "type": "file", + "source": "ScriptAnalyzerSettings.psd1", + "destination": "" + }, + { + "type": "templateFile", + "condition": "${Options} -contains 'platyPS'", + "source": "docs\\en-US\\about_Module.help.md", + "destination": "docs\\en-US\\about_${ModuleName}.help.md" + }, + { + "type": "templateFile", + "condition": "${Options} -notcontains 'platyPS'", + "source": "docs\\en-US\\about_Module.help.txt", + "destination": "src\\en-US\\about_${ModuleName}.help.txt" + }, + { + "type": "templateFile", + "condition": "${Options} -contains 'Pester'", + "source": "test\\Module.T.ps1", + "destination": "test\\${ModuleName}.Tests.ps1" + }, + { + "type": "templateFile", + "condition": "${Options} -contains 'Pester'", + "source": "test\\Shared.ps1", + "destination": "test\\Shared.ps1" + }, + { + "type": "templateFile", + "condition": "${License} -eq 'Apache'", + "source": "license\\Apache.txt", + "destination": "LICENSE.txt", + "encoding": "UTF8-NoBOM" + }, + { + "type": "templateFile", + "condition": "${License} -eq 'MIT'", + "source": "license\\MIT.txt", + "destination": "LICENSE.txt" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -notcontains 'psake') -and (${Options} -contains 'Pester')", + "source": "editor\\VSCode\\tasks_pester.json", + "destination": ".vscode\\tasks.json" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -contains 'psake') -and (${Options} -notcontains 'Pester')", + "source": "editor\\VSCode\\tasks_psake.json", + "destination": ".vscode\\tasks.json" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -contains 'psake') -and (${Options} -contains 'Pester')", + "source": "editor\\VSCode\\tasks_psake_pester.json", + "destination": ".vscode\\tasks.json" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -contains 'PSScriptAnalyzer')", + "source": "editor\\VSCode\\settings.json", + "destination": ".vscode\\settings.json" + }, + { + "type": "requireModule", + "name": "Pester", + "condition": "${Options} -contains 'Pester'", + "minimumVersion": "3.4.0", + "message": "Without Pester, you will not be able to run the provided Pester test to validate your module manifest file.\nWithout version 3.4.0, VS Code will not display Pester warnings and errors in the Problems panel." + }, + { + "type": "requireModule", + "name": "psake", + "condition": "${Options} -contains 'psake'", + "message": "Without psake, you will not be able to run the provided build script to build and/or publish your module." + }, + { + "type": "requireModule", + "name": "PSScriptAnalyzer", + "condition": "${Options} -contains 'PSScriptAnalyzer'", + "message": "Without PSScriptAnalyzer, you will not be able to perform script analysis, or use the provided options to control script analysis." + }, + { + "type": "requireModule", + "name": "platyPS", + "condition": "${Options} -contains 'platyPS'", + "minimumVersion": "0.7.2", + "message": "Without platyPS, you will not be able to generate PowerShell external help for your module using markdown." + }, + { + "type": "message", + "text": "\nYour new PowerShell module project '${ModuleName}' has been created.\n\n" + }, + { + "type": "message", + "condition": "${Options} -contains 'Pester'", + "text": "A Pester test has been created to validate the module's manifest file. Add additional tests to the test directory.\nYou can run the Pester tests in your project by executing the 'test' task. Press Ctrl+P, then type 'task test'.\n\n" + }, + { + "type": "message", + "condition": "${Options} -contains 'psake'", + "text": "You can build your project by executing the 'build' task. Press Ctrl+P, then type 'task build'.\nYou can publish your project to the PSGallery by pressing Ctrl+P, then type 'task publish'.\n\n" + }, + { + "type": "message", + "condition": "(${Options} -contains 'psake') -and (${Options} -contains 'platyPS')", + "text": "You can generate help and additional documentation using platyPS by running the 'build help' task. Press Ctrl+P,\nthen type 'task build help'. Add additional documentation written in platyPS markdown to the docs directory. You can\nupdate the help by running the 'build help' task again.\n\n" + } + ] +} diff --git a/examples/NewPowerShellScript/plasterManifest.json b/examples/NewPowerShellScript/plasterManifest.json new file mode 100644 index 0000000..86f3bc0 --- /dev/null +++ b/examples/NewPowerShellScript/plasterManifest.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "id": "635046f0-a59b-46a0-b6b3-892084707467", + "name": "NewPowerShellScript", + "title": "New PowerShell Script", + "description": "Creates a new PowerShell script file with basic structure.", + "version": "1.0.0", + "templateType": "Item", + "author": "Plaster Project", + "tags": [ + "Script", + "PowerShell" + ] + }, + "parameters": [ + { + "name": "ScriptName", + "type": "text", + "prompt": "Enter the name of the script (without .ps1 extension)" + }, + { + "name": "IncludeHelp", + "type": "choice", + "prompt": "Include comment-based help?", + "default": 0, + "choices": [ + { + "label": "&Yes", + "help": "Includes a comment-based help template", + "value": "Yes" + }, + { + "label": "&No", + "help": "Creates a basic script without help", + "value": "No" + } + ] + } + ], + "content": [ + { + "type": "message", + "text": "Creating PowerShell script: ${ScriptName}.ps1" + }, + { + "type": "templateFile", + "source": "script-template.ps1", + "destination": "${ScriptName}.ps1", + "encoding": "UTF8-NoBOM" + }, + { + "type": "message", + "text": "Script created successfully!" + } + ] +} diff --git a/examples/TemplateModule/README.md b/examples/TemplateModule/README.md index 4a7835e..c6b11a3 100644 --- a/examples/TemplateModule/README.md +++ b/examples/TemplateModule/README.md @@ -1,34 +1,147 @@ -# Plaster Template Module Example +# Plaster Examples -This file contains a simple PowerShell module which includes a Plaster template. -The important part to note is in the `PrivateData` section of the `TemplateModule.psd1` -file: +## NewModuleTemplate + +This example demonstrates a template used for scaffolding a new module project. It has support for Git, PSake build script, and Pester tests. + +### Using XML Format (Traditional) + +The template includes both XML and JSON manifest formats: +- `plasterManifest.xml` - Traditional XML format (backwards compatible) +- `plasterManifest.json` - Modern JSON format (Plaster 2.0+) + +To run this example using XML format: +```powershell +Import-Module ..\..\Plaster\Plaster.psd1 +Invoke-Plaster -TemplatePath . -DestinationPath ..\Out +``` + +### Using JSON Format (Plaster 2.0+) + +To run this example using the new JSON format: +```powershell +Import-Module ..\..\Plaster\Plaster.psd1 +Invoke-Plaster -TemplatePath .\plasterManifest.json -DestinationPath ..\Out +``` + +### Interactive vs Non-Interactive Usage + +Both formats support the same interactive prompting. If you run either command a second time, you'll see Plaster's file conflict handling. You can use the `-Force` parameter to automatically overwrite existing files. + +You can bypass interactive prompting by providing parameters directly to `Invoke-Plaster`. Template parameters are added as dynamic parameters with autocompletion support: + +```powershell +$PlasterParams = @{ + TemplatePath = $PWD # Works with either XML or JSON manifest + DestinationPath = '..\Out' + ModuleName = 'FooUtils' + ModuleDesc = 'Commands for Foo' + FullName = 'John Q. Doe' + Email = 'john.q.doe@example.org' + Version = '1.2.0' + Options = 'Git','PSake','Pester' + Editor = 'VSCode' + License = 'MIT' +} + +Invoke-Plaster @PlasterParams -Force +``` + +## Format Comparison + +### XML Format Features +- Backwards compatible with Plaster 1.x +- Well-established syntax +- XML schema validation +- Requires XML escaping for special characters + +### JSON Format Features (New in 2.0) +- Modern, readable syntax +- Better tooling support (VS Code IntelliSense) +- JSON schema validation +- Simplified variable syntax: `${ParameterName}` instead of `${PLASTER_PARAM_ParameterName}` +- Easier to work with arrays and objects +- No XML escaping required + +## Variable Syntax Differences + +**XML Format:** +```xml + + +``` + +**JSON Format:** +```json +{ + "type": "file", + "source": "Module.psm1", + "destination": "src\\${ModuleName}.psm1" +} +``` + +Notice how JSON format uses simplified variable names (`${ModuleName}` vs `${PLASTER_PARAM_ModuleName}`) and doesn't require XML entity escaping. + +## Available Examples + +### Main Examples +- **NewModuleTemplate** (`examples/`) - Full-featured module template with both XML and JSON formats +- **NewModule** (`examples/NewModule/`) - Simplified module template +- **NewDscResourceScript** (`examples/NewDscResourceScript/`) - DSC resource template + +### Template Validation Examples +- **plasterManifest-validatePattern.xml/json** - Shows input validation patterns + +### Localization Examples +- **plasterManifest_fr-FR.xml/json** - French localized version + +### Module Extension Examples +- **TemplateModule** (`examples/TemplateModule/`) - Shows how to embed templates in PowerShell modules + +## Migration from XML to JSON + +Plaster 2.0 includes automatic conversion capabilities: + +```powershell +# Convert existing XML manifest to JSON +$xmlPath = ".\plasterManifest.xml" +$jsonPath = ".\plasterManifest.json" +$manifest = Test-PlasterManifest -Path $xmlPath +ConvertTo-JsonManifest -InputObject $manifest -OutputPath $jsonPath +``` + +## Template Discovery + +Both formats are discovered automatically by `Get-PlasterTemplate`: ```powershell -PrivateData = @{ +# Shows both XML and JSON templates +Get-PlasterTemplate -Path . -Recurse +Get-PlasterTemplate -IncludeInstalledModules +``` - PSData = @{ +## Creating New Manifests - Extensions = @( - @{ - Module = "Plaster" - MinimumVersion = "0.3.0" - Details = @{ - TemplatePaths = @("TemplateOne", "TemplateTwo") - } - } - ) - } # End of PSData hashtable +```powershell +# Create XML manifest (traditional) +New-PlasterManifest -TemplateName "MyTemplate" -TemplateType Project -} # End of PrivateData hashtable +# Create JSON manifest (Plaster 2.0+) +New-PlasterManifest -TemplateName "MyTemplate" -TemplateType Project -Format JSON + +# Convert existing XML to JSON +New-PlasterManifest -TemplateName "MyTemplate" -TemplateType Project -ConvertFromXml ``` -A PowerShell module which includes Plaster templates should add an `Extensions` section -in their `PrivateData.PSData` object using the format shown above. You can target a specific -version range of Plaster using the `MinimumVersion` (shown) and `MaximumVersion` properties -(not shown). For now the only property in the `Details` object is `TemplatePaths` which -is a simple list of folder paths under the module's installation path which contain `plasterManifest.xml` -files. +## Best Practices + +1. **New Templates**: Use JSON format for better tooling and readability +2. **Existing Templates**: XML format continues to work; migrate when convenient +3. **Mixed Environments**: Templates can include both formats for maximum compatibility +4. **Schema Validation**: Both formats support schema validation for better authoring experience +5. **Localization**: Both formats support culture-specific manifests (e.g., `plasterManifest_fr-FR.json`) -The `TemplateOne` and `TemplateTwo` subfolders both contain a simple, standard `plasterManifest.xml` -which needs no extra configuration to be shipped as part of a PowerShell module. +For more information about creating manifests, see the help topics: +- `Get-Help about_Plaster_CreatingAManifest` +- `Get-Help about_Plaster_CreatingJsonManifest` (New in 2.0) +- `Get-Help New-PlasterManifest` diff --git a/examples/TemplateModule/TemplateModule.psd1 b/examples/TemplateModule/TemplateModule.psd1 index 12ad1d4..673f023 100644 --- a/examples/TemplateModule/TemplateModule.psd1 +++ b/examples/TemplateModule/TemplateModule.psd1 @@ -1,4 +1,4 @@ -# +# # Module manifest for module 'TemplateModule' # diff --git a/examples/TemplateModule/TemplateOne/plasterManifest.json b/examples/TemplateModule/TemplateOne/plasterManifest.json new file mode 100644 index 0000000..e304dc9 --- /dev/null +++ b/examples/TemplateModule/TemplateOne/plasterManifest.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "id": "635046f0-a59b-46a0-b6b3-892084707467", + "name": "TemplateOneTemplate", + "title": "TemplateOne Template", + "description": "An example Plaster template from a PowerShell module.", + "version": "0.1.0", + "templateType": "Project", + "author": "Plaster project", + "tags": [ + "Module", + "ModuleManifest" + ] + }, + "parameters": [ + { + "name": "ModuleName", + "type": "text", + "prompt": "Enter the name of the module" + } + ], + "content": [ + { + "type": "newModuleManifest", + "destination": "${ModuleName}.psd1", + "moduleVersion": "${Version}", + "rootModule": "${ModuleName}.psm1", + "encoding": "UTF8-NoBOM" + } + ] +} diff --git a/examples/TemplateModule/TemplateTwo/plasterManifest.json b/examples/TemplateModule/TemplateTwo/plasterManifest.json new file mode 100644 index 0000000..fca3af2 --- /dev/null +++ b/examples/TemplateModule/TemplateTwo/plasterManifest.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "id": "76a18e25-3c2f-4266-9a6f-64cdef44de94", + "name": "TemplateTwoTemplate", + "title": "TemplateTwo Template", + "description": "An example Plaster template from a PowerShell module.", + "version": "0.1.0", + "templateType": "Project", + "author": "Plaster project", + "tags": [ + "Module", + "ModuleManifest" + ] + }, + "parameters": [ + { + "name": "ModuleName", + "type": "text", + "prompt": "Enter the name of the module" + } + ], + "content": [ + { + "type": "newModuleManifest", + "destination": "${ModuleName}.psd1", + "moduleVersion": "${Version}", + "rootModule": "${ModuleName}.psm1", + "encoding": "UTF8-NoBOM" + } + ] +} diff --git a/examples/plasterManifest-validatePattern.json b/examples/plasterManifest-validatePattern.json new file mode 100644 index 0000000..849e082 --- /dev/null +++ b/examples/plasterManifest-validatePattern.json @@ -0,0 +1,277 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "id": "dcd95744-8abc-4ecb-a439-bf2cd37821bb", + "name": "NewModule", + "title": "New Module", + "description": "Scaffolds the files required for a PowerShell module.", + "version": "1.0.0", + "templateType": "Project", + "author": "Plaster Project", + "tags": [ + "Module", + "ModuleManifest", + "Build" + ] + }, + "parameters": [ + { + "name": "ModuleName", + "type": "text", + "prompt": "Enter the name of the module" + }, + { + "name": "ModuleDesc", + "type": "text", + "prompt": "Enter a description of the module (required for publishing to the PowerShell Gallery)" + }, + { + "name": "Version", + "type": "text", + "prompt": "Enter the version number of the module", + "default": "0.1.0" + }, + { + "name": "FullName", + "type": "user-fullname", + "prompt": "Enter your full name", + "store": "text" + }, + { + "name": "License", + "type": "choice", + "prompt": "Select a license (see http://choosealicense.com for help choosing):", + "default": 0, + "store": "text", + "choices": [ + { + "label": "&None", + "help": "No license.", + "value": "None" + }, + { + "label": "&Apache", + "help": "Adds an Apache license file.", + "value": "Apache" + }, + { + "label": "&MIT", + "help": "Adds an MIT license file.", + "value": "MIT" + } + ] + }, + { + "name": "Options", + "type": "multichoice", + "prompt": "Select one or more of the following tooling options:", + "default": [ + 1, + 2, + 3, + 4 + ], + "store": "text", + "choices": [ + { + "label": "&None", + "help": "No tooling options specified.", + "value": "None" + }, + { + "label": "Add &Git .gitignore file", + "help": "Adds a .gitignore file.", + "value": "Git" + }, + { + "label": "Add p&sake build script", + "help": "Adds psake build script that generates the module directory for publishing to the PowerShell Gallery.", + "value": "psake" + }, + { + "label": "Add &Pester test support", + "help": "Adds test directory and Pester test for the module manifest file.", + "value": "Pester" + }, + { + "label": "Add PSScript&Analyzer support", + "help": "Adds script analysis support using PSScriptAnalyzer.", + "value": "PSScriptAnalyzer" + }, + { + "label": "Add plat&yPS help generation support", + "help": "Adds help build support using platyPS.", + "value": "platyPS" + } + ] + }, + { + "name": "Editor", + "type": "choice", + "prompt": "Select one of the supported script editors for better editor integration (or None):", + "default": 0, + "store": "text", + "choices": [ + { + "label": "&None", + "help": "No editor specified.", + "value": "None" + }, + { + "label": "Visual Studio &Code", + "help": "Your editor is Visual Studio Code.", + "value": "VSCode" + } + ] + } + ], + "content": [ + { + "type": "message", + "text": "\n\n\nScaffolding your PowerShell Module...\n\n\n" + }, + { + "type": "newModuleManifest", + "destination": "src\\${ModuleName}.psd1", + "moduleVersion": "${Version}", + "rootModule": "${ModuleName}.psm1", + "author": "${FullName}", + "description": "${ModuleDesc}", + "encoding": "UTF8-NoBOM" + }, + { + "type": "file", + "condition": "${Options} -contains 'Git'", + "source": "_gitignore", + "destination": ".gitignore" + }, + { + "type": "file", + "condition": "${Options} -contains 'psake'", + "source": "build*.ps1", + "destination": "" + }, + { + "type": "file", + "source": "ReleaseNotes.md", + "destination": "" + }, + { + "type": "file", + "source": "src\\Module.psm1", + "destination": "src\\${ModuleName}.psm1" + }, + { + "type": "file", + "source": "ScriptAnalyzerSettings.psd1", + "destination": "" + }, + { + "type": "templateFile", + "condition": "${Options} -contains 'platyPS'", + "source": "docs\\en-US\\about_Module.help.md", + "destination": "docs\\en-US\\about_${ModuleName}.help.md" + }, + { + "type": "templateFile", + "condition": "${Options} -notcontains 'platyPS'", + "source": "docs\\en-US\\about_Module.help.txt", + "destination": "src\\en-US\\about_${ModuleName}.help.txt" + }, + { + "type": "templateFile", + "condition": "${Options} -contains 'Pester'", + "source": "test\\Module.T.ps1", + "destination": "test\\${ModuleName}.Tests.ps1" + }, + { + "type": "templateFile", + "condition": "${Options} -contains 'Pester'", + "source": "test\\Shared.ps1", + "destination": "test\\Shared.ps1" + }, + { + "type": "templateFile", + "condition": "${License} -eq 'Apache'", + "source": "license\\Apache.txt", + "destination": "LICENSE.txt", + "encoding": "UTF8-NoBOM" + }, + { + "type": "templateFile", + "condition": "${License} -eq 'MIT'", + "source": "license\\MIT.txt", + "destination": "LICENSE.txt" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -notcontains 'psake') -and (${Options} -contains 'Pester')", + "source": "editor\\VSCode\\tasks_pester.json", + "destination": ".vscode\\tasks.json" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -contains 'psake') -and (${Options} -notcontains 'Pester')", + "source": "editor\\VSCode\\tasks_psake.json", + "destination": ".vscode\\tasks.json" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -contains 'psake') -and (${Options} -contains 'Pester')", + "source": "editor\\VSCode\\tasks_psake_pester.json", + "destination": ".vscode\\tasks.json" + }, + { + "type": "file", + "condition": "(${Editor} -eq 'VSCode') -and (${Options} -contains 'PSScriptAnalyzer')", + "source": "editor\\VSCode\\settings.json", + "destination": ".vscode\\settings.json" + }, + { + "type": "requireModule", + "name": "Pester", + "condition": "${Options} -contains 'Pester'", + "minimumVersion": "3.4.0", + "message": "Without Pester, you will not be able to run the provided Pester test to validate your module manifest file.\nWithout version 3.4.0, VS Code will not display Pester warnings and errors in the Problems panel." + }, + { + "type": "requireModule", + "name": "psake", + "condition": "${Options} -contains 'psake'", + "message": "Without psake, you will not be able to run the provided build script to build and/or publish your module." + }, + { + "type": "requireModule", + "name": "PSScriptAnalyzer", + "condition": "${Options} -contains 'PSScriptAnalyzer'", + "message": "Without PSScriptAnalyzer, you will not be able to perform script analysis, or use the provided options to control script analysis." + }, + { + "type": "requireModule", + "name": "platyPS", + "condition": "${Options} -contains 'platyPS'", + "minimumVersion": "0.7.2", + "message": "Without platyPS, you will not be able to generate PowerShell external help for your module using markdown." + }, + { + "type": "message", + "text": "\nYour new PowerShell module project '${ModuleName}' has been created.\n\n" + }, + { + "type": "message", + "condition": "${Options} -contains 'Pester'", + "text": "A Pester test has been created to validate the module's manifest file. Add additional tests to the test directory.\nYou can run the Pester tests in your project by executing the 'test' task. Press Ctrl+P, then type 'task test'.\n\n" + }, + { + "type": "message", + "condition": "${Options} -contains 'psake'", + "text": "You can build your project by executing the 'build' task. Press Ctrl+P, then type 'task build'.\nYou can publish your project to the PSGallery by pressing Ctrl+P, then type 'task publish'.\n\n" + }, + { + "type": "message", + "condition": "(${Options} -contains 'psake') -and (${Options} -contains 'platyPS')", + "text": "You can generate help and additional documentation using platyPS by running the 'build help' task. Press Ctrl+P,\nthen type 'task build help'. Add additional documentation written in platyPS markdown to the docs directory. You can\nupdate the help by running the 'build help' task again.\n\n" + } + ] +} diff --git a/examples/plasterManifest.json b/examples/plasterManifest.json new file mode 100644 index 0000000..2f00487 --- /dev/null +++ b/examples/plasterManifest.json @@ -0,0 +1,289 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "id": "38bd80d9-3a47-4916-9220-bed383d90876", + "name": "NewPowerShellModule", + "title": "New PowerShell Module", + "description": "Plaster template for creating the files for a PowerShell module.", + "version": "0.2.0", + "templateType": "Project", + "author": "Plaster Project", + "tags": [ + "Module", + "ModuleManifest", + "Build" + ] + }, + "parameters": [ + { + "name": "ModuleName", + "type": "text", + "prompt": "Enter the name of the module" + }, + { + "name": "ModuleDesc", + "type": "text", + "prompt": "Enter a description of the module (required for publishing to the PowerShell Gallery)" + }, + { + "name": "Version", + "type": "text", + "default": "1.0.0", + "prompt": "Enter the version number for the module" + }, + { + "name": "FullName", + "type": "user-fullname", + "store": "text", + "prompt": "Enter your full name" + }, + { + "name": "Email", + "type": "user-email", + "store": "text", + "prompt": "Enter your email address" + }, + { + "name": "Options", + "type": "multichoice", + "default": [ + 0, + 1, + 3 + ], + "store": "text", + "prompt": "Select desired options", + "choices": [ + { + "label": "&Pester test support", + "help": "Adds Tests directory and a starter Pester Tests file.", + "value": "Pester" + }, + { + "label": "P&Sake build script", + "help": "Adds a PSake build script that generates the module directory for publishing to the PSGallery.", + "value": "PSake" + }, + { + "label": "platyPS &documentation support", + "help": "Adds documentation support using platyPS.", + "value": "platyPS" + }, + { + "label": "&Git", + "help": "Adds a .gitignore file.", + "value": "Git" + }, + { + "label": "&None", + "help": "No options specified.", + "value": "None" + } + ] + }, + { + "name": "Editor", + "type": "choice", + "default": 2, + "store": "text", + "prompt": "Which editor do you use", + "choices": [ + { + "label": "&ISE", + "help": "Your editor is PowerShell ISE.", + "value": "ISE" + }, + { + "label": "Visual Studio &Code", + "help": "Your editor is Visual Studio Code.", + "value": "VSCode" + }, + { + "label": "&None", + "help": "No editor specified.", + "value": "None" + } + ] + }, + { + "name": "License", + "type": "choice", + "default": 2, + "store": "text", + "prompt": "Select a license for your module", + "choices": [ + { + "label": "&Apache", + "help": "Adds an Apache license file.", + "value": "Apache" + }, + { + "label": "&MIT", + "help": "Adds an MIT license file.", + "value": "MIT" + }, + { + "label": "&None", + "help": "No license specified.", + "value": "None" + } + ] + } + ], + "content": [ + { + "type": "message", + "text": "\nScaffold a PowerShell Module with the files required to run Pester tests, build with PSake and publish to the PSGallery.\n\n" + }, + { + "type": "newModuleManifest", + "destination": "src\\${ModuleName}.psd1", + "moduleVersion": "${Version}", + "rootModule": "${ModuleName}.psm1", + "author": "${FullName}", + "description": "${ModuleDesc}", + "encoding": "UTF8-NoBOM" + }, + { + "type": "file", + "source": "Module.psm1", + "destination": "src\\${ModuleName}.psm1" + }, + { + "type": "file", + "source": "_gitignore", + "destination": ".gitignore", + "condition": "${Options} -contains 'Git'" + }, + { + "type": "file", + "source": "build*.ps1", + "destination": "", + "condition": "${Options} -contains 'PSake'" + }, + { + "type": "file", + "source": "", + "destination": "docs\\", + "condition": "${Options} -contains 'platyPS'" + }, + { + "type": "file", + "source": "RecurseFile\\**", + "destination": "Recurse" + }, + { + "type": "templateFile", + "source": "ApacheLicense.txt", + "destination": "LICENSE.txt", + "condition": "${License} -eq 'Apache'", + "encoding": "UTF8-NoBOM" + }, + { + "type": "templateFile", + "source": "MITLicense.txt", + "destination": "LICENSE.txt", + "condition": "${License} -eq 'MIT'" + }, + { + "type": "templateFile", + "source": "en-US\\about_Module.help.txt", + "destination": "en-US\\about_${ModuleName}.help.txt" + }, + { + "type": "templateFile", + "source": "Tests\\Module.T.ps1", + "destination": "test\\${ModuleName}.Tests.ps1", + "condition": "${Options} -contains 'Pester'" + }, + { + "type": "templateFile", + "source": "RecurseTemplateFile\\**", + "destination": "Recurse", + "encoding": "Ascii" + }, + { + "type": "file", + "source": "tasks.json", + "destination": ".vscode\\tasks.json", + "condition": "${Editor} -eq 'VSCode'" + }, + { + "type": "modify", + "path": ".vscode\\tasks.json", + "encoding": "UTF8", + "condition": "${Editor} -eq 'VSCode'", + "modifications": [ + { + "type": "replace", + "condition": "${PLASTER_FileContent} -notmatch '// Author:'", + "search": "(?s)^(.*)", + "replace": "// Author: ${FullName}\r\n$1", + "expand": true + }, + { + "type": "replace", + "condition": "${Options} -contains 'Pester' -and ${PLASTER_FileContent} -notmatch 'taskName\":s*\"Test\"'", + "search": "(?si)(?<=\"tasks\":\\s*\\[)(\\s*)(?=\\{)", + "replace": "$1{\n \"taskName\": \"Test\",\n \"suppressTaskName\": true,\n \"isTestCommand\": true,\n \"showOutput\": \"always\",\n \"args\": [\n \"Write-Host 'Invoking Pester...'; Invoke-Pester -PesterOption @{IncludeVSCodeMarker=$true};\",\n \"Invoke-Command { Write-Host 'Completed Test task in task runner.' }\"\n ],\n \"problemMatcher\": [\n {\n \"owner\": \"powershell\",\n \"fileLocation\": [\"absolute\"],\n \"severity\": \"error\",\n \"pattern\": [\n {\n \"regexp\": \"^\\\\s*(\\\\[-\\\\]\\\\s*.*?)(\\\\d+)ms\\\\s*$\",\n \"message\": 1\n },\n {\n \"regexp\": \"^\\\\s+at\\\\s+[^,]+,\\\\s*(.*?):\\\\s+line\\\\s+(\\\\d+)$\",\n \"file\": 1,\n \"line\": 2\n }\n ]\n }\n ]\n },$1" + }, + { + "type": "replace", + "condition": "${Options} -contains 'PSake' -and ${PLASTER_FileContent} -notmatch 'taskName\":s*\"Build\"'", + "search": "(?si)(?<=\"tasks\":\\s*\\[)(\\s*)(?=\\{)", + "replace": "$1{\n \"taskName\": \"Build\",\n \"suppressTaskName\": true,\n \"isBuildCommand\": true,\n \"showOutput\": \"always\",\n \"args\": [\n \"Write-Host 'Invoking PSake...'; Invoke-PSake build.ps1 -taskList Build;\",\n \"Invoke-Command { Write-Host 'Completed Build task in task runner.' }\"\n ]\n },$1" + } + ] + }, + { + "type": "requireModule", + "name": "Pester", + "condition": "${Options} -contains 'Pester'", + "minimumVersion": "3.4.0", + "message": "Without Pester, you will not be able to run the provided Pester test to validate your module manifest file.\nWithout version 3.4.0, VS Code will not display Pester warnings and errors in the Problems panel." + }, + { + "type": "requireModule", + "name": "psake", + "condition": "${Options} -contains 'PSake'", + "message": "Without psake, you will not be able to run the provided build script to build and/or publish your module." + }, + { + "type": "requireModule", + "name": "platyPS", + "condition": "${Options} -contains 'platyPS'", + "message": "Without platyPS, you will not be able to generate PowerShell external help for your module using markdown." + }, + { + "type": "message", + "text": "\n\nYour new PowerShell module project ${ModuleName}", + "noNewLine": true + }, + { + "type": "message", + "text": "with Git version control ", + "condition": "${Options} -contains 'Git'", + "noNewLine": true + }, + { + "type": "message", + "text": "has been created." + }, + { + "type": "message", + "condition": "${Options} -contains 'PSake'", + "text": "You can build your project by executing the 'build' task by pressing Ctrl+P, then type 'task build'.\nYou can publish your project to the PSGallery by pressing Ctrl+P, then type 'task publish'.\n\n" + }, + { + "type": "message", + "condition": "${Options} -contains 'Pester'", + "text": "A Pester test has been created to validate the module's manifest file. Add additional test to the Tests directory.\nYou can run the Pester tests in your project by executing the 'test' task by pressing Ctrl+P, then type 'task test'.\n\n" + }, + { + "type": "message", + "condition": "${Options} -contains 'platyPS'", + "text": "You can generate help and additional documentation using platyPS by running the 'docs' task by pressing Ctrl+P,\nthen type 'task docs'. Add additional documentation written in platyPS markdown to the docs directory. You can\nupdate the docs by running the 'docs' task again.\n\n" + } + ] +} diff --git a/examples/plasterManifest_fr-FR.json b/examples/plasterManifest_fr-FR.json new file mode 100644 index 0000000..bb89b65 --- /dev/null +++ b/examples/plasterManifest_fr-FR.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/Plaster/v2/schema/plaster-manifest-v2.json", + "schemaVersion": "2.0", + "metadata": { + "id": "38bd80d9-3a47-4916-9220-bed383d90876", + "name": "NewPowerShellModule", + "title": "Nouveau PowerShell Module", + "description": "Modèle de plâtre pour créer les fichiers d'un module de PowerShell.", + "version": "0.2.0", + "templateType": "Project", + "author": "Projet Plaster", + "tags": [ + "Module", + "ModuleManifest", + "Build" + ] + }, + "parameters": [ + { + "name": "ModuleName", + "type": "text", + "prompt": "Entrez le nom du module" + }, + { + "name": "ModuleDesc", + "type": "text", + "prompt": "Entrez une description du module (requis pour la publication de la Galerie de PowerShell)" + }, + { + "name": "Version", + "type": "text", + "default": "1.0.0", + "prompt": "Entrez le numéro de version du module" + }, + { + "name": "FullName", + "type": "user-fullname", + "store": "text", + "prompt": "Entrez votre nom et prénom" + }, + { + "name": "Email", + "type": "user-email", + "store": "text", + "prompt": "Entrez votre adresse e-mail" + }, + { + "name": "Options", + "type": "multichoice", + "default": [ + 0, + 1, + 2 + ], + "store": "text", + "prompt": "Sélectionnez les options souhaitées", + "choices": [ + { + "label": "&Pester en charge les tests", + "help": "Ajoute des Tests répertoire et un fichier de Tests Pester de démarreur.", + "value": "Pester" + }, + { + "label": "P&Sake créer script", + "help": "Ajoute un script de compilation PSake qui génère le répertoire du module pour la publication de la PSGallery.", + "value": "PSake" + }, + { + "label": "&Git", + "help": "Ajoute un fichier .gitignore.", + "value": "Git" + }, + { + "label": "Aucu&n", + "help": "Pas d'options précisées.", + "value": "None" + } + ] + }, + { + "name": "Editor", + "type": "choice", + "default": 2, + "store": "text", + "prompt": "Quel éditeur Utilisez-vous", + "choices": [ + { + "label": "&ISE", + "help": "Votre éditeur est PowerShell ISE.", + "value": "ISE" + }, + { + "label": "Visual Studio &Code", + "help": "Est de votre éditeur de Code de Visual Studio.", + "value": "VSCode" + }, + { + "label": "Aucu&n", + "help": "Aucun éditeur spécifié.", + "value": "None" + } + ] + }, + { + "name": "License", + "type": "choice", + "default": 2, + "store": "text", + "prompt": "Sélectionner une licence pour votre module", + "choices": [ + { + "label": "&Apache", + "help": "Ajoute un fichier de licence Apache.", + "value": "Apache" + }, + { + "label": "&MIT", + "help": "Ajoute un fichier de licence MIT.", + "value": "MIT" + }, + { + "label": "Aucu&n", + "help": "Aucune licence spécifiée.", + "value": "None" + } + ] + } + ], + "content": [ + { + "type": "message", + "text": "\nÉchafaudage un PowerShell Module avec les fichiers requis pour exécuter des tests de Pester, de construire avec PSake et de publier sur le PSGallery.\n\n" + }, + { + "type": "newModuleManifest", + "destination": "${ModuleName}.psd1", + "moduleVersion": "${Version}", + "rootModule": "${ModuleName}.psm1", + "author": "${FullName}", + "description": "${ModuleDesc}", + "encoding": "UTF8-NoBOM" + }, + { + "type": "file", + "source": "Module.psm1", + "destination": "${ModuleName}.psm1" + }, + { + "type": "file", + "source": "_gitignore", + "destination": ".gitignore", + "condition": "${Options} -contains 'Git'" + }, + { + "type": "file", + "source": "Build.ps1", + "destination": "Build.ps1", + "condition": "${Options} -contains 'PSake'" + }, + { + "type": "file", + "source": "RecurseFile\\**", + "destination": "Recurse" + }, + { + "type": "templateFile", + "source": "ApacheLicense.txt", + "destination": "LICENSE.txt", + "condition": "${License} -eq 'Apache'", + "encoding": "UTF8-NoBOM" + }, + { + "type": "templateFile", + "source": "MITLicense.txt", + "destination": "LICENSE.txt", + "condition": "${License} -eq 'MIT'" + }, + { + "type": "templateFile", + "source": "en-US\\about_Module.help.txt", + "destination": "en-US\\about_${ModuleName}.help.txt" + }, + { + "type": "templateFile", + "source": "Tests\\Module.T.ps1", + "destination": "Tests\\${ModuleName}.Tests.ps1", + "condition": "${Options} -contains 'Pester'" + }, + { + "type": "templateFile", + "source": "RecurseTemplateFile\\**", + "destination": "Recurse", + "encoding": "Ascii" + }, + { + "type": "file", + "source": "tasks.json", + "destination": ".vscode\\tasks.json", + "condition": "${Editor} -eq 'VSCode'" + }, + { + "type": "modify", + "path": ".vscode\\tasks.json", + "encoding": "UTF8", + "condition": "${Editor} -eq 'VSCode'", + "modifications": [ + { + "type": "replace", + "condition": "${PLASTER_FileContent} -notmatch '// Author:'", + "search": "(?s)^(.*)", + "replace": "$1\r\n// Author: ${FullName}", + "expand": true + } + ] + }, + { + "type": "message", + "text": "\n\nVotre nouveau projet de module de PowerShell ${ModuleName}", + "noNewLine": true + }, + { + "type": "message", + "text": "avec le contrôle de version Git ", + "condition": "${Options} -contains 'Git'", + "noNewLine": true + }, + { + "type": "message", + "text": "a été créé." + }, + { + "type": "message", + "condition": "${Options} -contains 'PSake'", + "text": "Vous pouvez générer votre projet en exécutant la tâche de « chantier » en appuyant sur Ctrl + P, puis tapez 'task build'.\nVous pouvez publier votre projet à le PSGallery en appuyant sur Ctrl + P, puis tapez 'task publish'.\n\n" + }, + { + "type": "message", + "condition": "${Options} -contains 'Pester'", + "text": "Un test de Pester a été créé pour valider le fichier de manifeste du module. Essai supplémentaire s'ajoute le répertoire de Tests.\nVous pouvez exécuter les tests de Pester dans votre projet en exécutant la tâche « test » en appuyant sur Ctrl + P, puis tapez 'task test'.\n\n" + } + ] +} diff --git a/examples/plasterManifest_fr-FR.xml b/examples/plasterManifest_fr-FR.xml index 2c71ac6..99eb34a 100644 --- a/examples/plasterManifest_fr-FR.xml +++ b/examples/plasterManifest_fr-FR.xml @@ -1,4 +1,4 @@ - +