diff --git a/.github/skills/skills/SKILL.md b/.github/skills/skills/SKILL.md new file mode 100644 index 0000000..be4df8b --- /dev/null +++ b/.github/skills/skills/SKILL.md @@ -0,0 +1,20 @@ +--- +name: skills +description: Index folder for vendored shared skills used by NovaModuleTools scaffolding and local guidance. +--- + +# Skill: skills + +## When to use + +Use this folder only as a container for nested shared skills that are maintained separately from the repository-local skill entry points. + +## Relevant files + +- `.github/skills/skills/*` + +## Expected practices + +- Do not reference this container skill directly from prompts or agents. +- Keep nested shared skills organized beneath this folder when they are intentionally vendored for local reuse. +- Prefer the repository-local top-level skills under `.github/skills/*/SKILL.md` for normal NovaModuleTools task routing. diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d5545..9cdc9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `project.json` now supports `Manifest.PrivateData` for arbitrary consumer-defined manifest metadata. + - Nova still uses `New-ModuleManifest` for the standard manifest flow, but now serializes structured `PrivateData` itself so nested objects, arrays, booleans, numbers, strings, and `null` values survive in the generated `.psd1`. + - Generated manifests now preserve both PowerShell/Nova-managed `PrivateData.PSData` metadata and custom `Manifest.PrivateData` entries, and reject reserved-key collisions instead of overwriting `PSData` silently. + ### Changed ### Deprecated @@ -15,6 +19,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- `Update-NovaModuleTool` and `% nova update` now split self-update failure details and recovery guidance across separate terminal lines, so long dependency errors stay readable instead of wrapping mid-sentence. +- `Invoke-NovaTest`, `Test-NovaBuild`, and `% nova test` now resolve and import a supported installed `Pester` version from `5.7.1` through `5.10.0` instead of using an unsupported `Pester 6.x` installation automatically. + - Nova test workflows now fail early with a clear dependency error when only unsupported `Pester 6.x` versions are available. +- Nova test workflows now reuse an already loaded supported `Pester 5.x` version in the current PowerShell session before selecting a different installed version. + - Repository CI no longer trips the `Pester` assembly loader by importing a gallery-installed `NovaModuleTools` module, building the local module, and then switching to another supported `Pester` version in the same session. + ### Security ## [3.3.1] - 2026-07-15 @@ -535,4 +545,3 @@ This release was yanked because it removed the implicit `Pester` dependency, bef [0.0.6]: https://github.com/stiwicourage/NovaModuleTools/compare/Version_0.0.5...Version_0.0.6 [0.0.5]: https://github.com/stiwicourage/NovaModuleTools/compare/Version_0.0.4...Version_0.0.5 [0.0.4]: https://github.com/stiwicourage/NovaModuleTools/compare/Version_0.0.3...Version_0.0.4 - diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index d6360c6..075365b 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -6,6 +6,10 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Added +- `project.json` now supports `Manifest.PrivateData` for arbitrary consumer-defined module manifest metadata. + - Nova still uses `New-ModuleManifest` for the standard manifest flow, but now serializes structured `PrivateData` itself so nested values remain valid in the generated `.psd1`. + - Generated manifests preserve both `PrivateData.PSData` and custom `Manifest.PrivateData` entries, and reject reserved-key collisions instead of overwriting manifest metadata silently. + ### Changed ### Deprecated @@ -14,6 +18,9 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Fixed +- `Update-NovaModuleTool` and `% nova update` now keep self-update failure details and recovery guidance on separate terminal lines so the error is easier to scan. +- `Invoke-NovaTest`, `Test-NovaBuild`, and `% nova test` now stay on Nova's supported `Pester 5.x` range and stop with a clear error instead of trying to run with an installed `Pester 6.x` version. + ### Security ## [3.3.1] - 2026-07-15 @@ -276,4 +283,3 @@ This release was yanked because it removed the implicit `Pester` dependency befo ## [0.0.4] - 2024-06-25 ### Added - First PowerShell Gallery release of NovaModuleTools with the initial module workflow support. - diff --git a/docs/project-json-reference.html b/docs/project-json-reference.html index 3216f2e..ece2571 100644 --- a/docs/project-json-reference.html +++ b/docs/project-json-reference.html @@ -113,7 +113,7 @@

How to read this file

Schema coverage.

The unified JSON schema covers all documented fields including Manifest.ReleaseNotes, Manifest.LicenseUri, - Manifest.IconUri, and Pester.TestResult.OutputFormat. VS Code picks + Manifest.IconUri, Manifest.PrivateData, and Pester.TestResult.OutputFormat. VS Code picks up the schema automatically when the $schema field is present in project.json.

@@ -142,7 +142,13 @@

Complete example

"Tags": ["Example", "NovaModuleTools", "PowerShell"], "ProjectUri": "https://www.novamoduletools.com/", "ReleaseNotes": "https://www.novamoduletools.com/release-notes.html", - "LicenseUri": "https://www.novamoduletools.com/license.html" + "LicenseUri": "https://www.novamoduletools.com/license.html", + "PrivateData": { + "ExampleProduct": { + "Enabled": true, + "ApiVersion": "1" + } + } }, "Package": { "Id": "NovaExampleModule", @@ -372,12 +378,41 @@

Manifest settings

publicly. + + PrivateData + No + Adds arbitrary consumer-defined metadata to the manifest PrivateData hashtable. + Nova serializes this data itself so arbitrary property names and nested objects, arrays, booleans, numbers, strings, and null values remain valid PowerShell data in the generated manifest. +

If Tags, ProjectUri, ReleaseNotes, or LicenseUri are omitted, packaging still succeeds. The corresponding package metadata fields are simply left out.

+

Use Manifest.PrivateData for arbitrary module metadata. Nova keeps the standard PowerShell-generated + PrivateData.PSData metadata and merges your configured keys into the same top-level + PrivateData hashtable. Property names remain consumer-defined, so keys such as + Example Product, Feature-Flag, or 123abc are serialized safely.

+

Nova validates the rewritten manifest by re-importing the generated + .psd1. PowerShell's own Test-ModuleManifest cmdlet can still reject + consumer-defined top-level PrivateData entries even when the manifest is valid + PowerShell data, so Nova treats the data-file import round-trip as the compatibility check for + custom metadata.

+
{
+  "Manifest": {
+    "PrivateData": {
+      "Example Product": {
+        "Feature-Flag": true,
+        "ApiVersion": "1",
+        "RetryCount": 3,
+        "Nested Data": {
+          "Mode": "Test"
+        }
+      }
+    }
+  }
+}
diff --git a/docs/schema/v3/project.json b/docs/schema/v3/project.json index 8efcc16..66ad0c4 100644 --- a/docs/schema/v3/project.json +++ b/docs/schema/v3/project.json @@ -85,6 +85,11 @@ "type": "string", "description": "Sets the copyright string in the module manifest." }, + "PrivateData": { + "type": "object", + "additionalProperties": true, + "description": "Arbitrary consumer-defined module metadata serialized into the generated manifest PrivateData hashtable." + }, "RequiredModules": { "type": "array", "description": "Lists modules that must be imported before this module. Each entry is either a module name string or a module-specification object.", diff --git a/project.json b/project.json index 4139f81..fed86c9 100644 --- a/project.json +++ b/project.json @@ -1,7 +1,7 @@ { "ProjectName": "NovaModuleTools", "Description": "NovaModuleTools is an enterprise-focused build tool for PowerShell module development, with a strong emphasis on structure, maintainability, and build for automated CI/CD pipelines.", - "Version": "3.3.1", + "Version": "3.3.2-preview", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" diff --git a/src/private/build/BuildManifest.ps1 b/src/private/build/BuildManifest.ps1 index 2372562..ebe9ea0 100644 --- a/src/private/build/BuildManifest.ps1 +++ b/src/private/build/BuildManifest.ps1 @@ -6,34 +6,9 @@ function Build-Manifest { Write-Verbose 'Building psd1 data file Manifest' $data = Get-NovaBuildProjectInfo -ProjectInfo $ProjectInfo - - $PubFunctionFiles = @(Get-ChildItem -Path $data.PublicDir -Filter *.ps1) - $functionToExport = @() - $aliasToExport = @() - foreach ($pubFunctionFile in $PubFunctionFiles) { - $functionToExport += Get-FunctionNameFromFile -filePath $pubFunctionFile.FullName - $aliasToExport += Get-AliasInFunctionFromFile -filePath $pubFunctionFile.FullName - } - - ## Import Format.ps1xml (if any) - $FormatsToProcess = @() - Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Format.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object { - if ($data.CopyResourcesToModuleRoot) { - $FormatsToProcess += $_.Name - } else { - $FormatsToProcess += Join-Path -Path 'resources' -ChildPath $_.Name - } - } - - ## Import Types.ps1xml1 (if any) - $TypesToProcess = @() - Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Types.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object { - if ($data.CopyResourcesToModuleRoot) { - $TypesToProcess += $_.Name - } else { - $TypesToProcess += Join-Path -Path 'resources' -ChildPath $_.Name - } - } + $exportDefinition = Get-ManifestExportDefinition -PublicDir $data.PublicDir + $formatFiles = Get-ManifestResourceFilePath -ResourcesDir $data.ResourcesDir -CopyResourcesToModuleRoot:$data.CopyResourcesToModuleRoot -Filter '*Format.ps1xml' + $typeFiles = Get-ManifestResourceFilePath -ResourcesDir $data.ResourcesDir -CopyResourcesToModuleRoot:$data.CopyResourcesToModuleRoot -Filter '*Types.ps1xml' $ManfiestAllowedParams = (Get-Command New-ModuleManifest).Parameters.Keys Assert-ManifestSchema -Manifest $data.Manifest -AllowedParameter $ManfiestAllowedParams @@ -41,12 +16,12 @@ function Build-Manifest { $ParmsManifest = @{ Path = $data.ManifestFilePSD1 Description = $data.Description - FunctionsToExport = $functionToExport - AliasesToExport = $aliasToExport + FunctionsToExport = $exportDefinition.FunctionToExport + AliasesToExport = $exportDefinition.AliasToExport RootModule = "$( $data.ProjectName ).psm1" ModuleVersion = [version]$sv - FormatsToProcess = $FormatsToProcess - TypesToProcess = $TypesToProcess + FormatsToProcess = $formatFiles + TypesToProcess = $typeFiles } ## Release lable @@ -54,18 +29,88 @@ function Build-Manifest { $ParmsManifest['Prerelease'] = $sv.PreReleaseLabel } - # Accept only valid Manifest Parameters - $data.Manifest.Keys | ForEach-Object { - if ($ManfiestAllowedParams -contains $_) { - if ($data.Manifest.$_) { - $ParmsManifest.add($_, $data.Manifest.$_) - } - } - } + Add-AllowedManifestParameterEntry -ManifestParameters $ParmsManifest -Manifest $data.Manifest -AllowedParameter $ManfiestAllowedParams try { New-ModuleManifest @ParmsManifest } catch { Stop-NovaOperation -Message ('Failed to create Manifest: {0}' -f $_.Exception.Message) -ErrorId 'Nova.Dependency.ModuleManifestCreationFailed' -Category OpenError -TargetObject $data.ManifestFilePSD1 } + + if ($data.Manifest.Contains('PrivateData') -and $data.Manifest['PrivateData'] -is [System.Collections.IDictionary]) { + Update-ManifestPrivateData -ManifestPath $data.ManifestFilePSD1 -PrivateData $data.Manifest['PrivateData'] + Assert-GeneratedManifestPrivateDataCanBeImported -ManifestPath $data.ManifestFilePSD1 + } +} + +function Assert-GeneratedManifestPrivateDataCanBeImported { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ManifestPath + ) + + try { + $null = Import-PowerShellDataFile -LiteralPath $ManifestPath -ErrorAction Stop + } catch { + Stop-NovaOperation -Message ('Generated manifest contains invalid PrivateData: {0}' -f $_.Exception.Message) -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataValidationFailed' -Category InvalidData -TargetObject $ManifestPath + } +} + +function Get-ManifestExportDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$PublicDir + ) + + $functionToExport = @() + $aliasToExport = @() + foreach ($publicFunctionFile in @(Get-ChildItem -Path $PublicDir -Filter *.ps1)) { + $functionToExport += Get-FunctionNameFromFile -filePath $publicFunctionFile.FullName + $aliasToExport += Get-AliasInFunctionFromFile -filePath $publicFunctionFile.FullName + } + + return [pscustomobject]@{ + FunctionToExport = $functionToExport + AliasToExport = $aliasToExport + } +} + +function Get-ManifestResourceFilePath { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourcesDir, + [Parameter(Mandatory)][string]$Filter, + [switch]$CopyResourcesToModuleRoot + ) + + $resourceFilePath = @() + Get-ChildItem -Path $ResourcesDir -File -Filter $Filter -ErrorAction SilentlyContinue | ForEach-Object { + if ($CopyResourcesToModuleRoot) { + $resourceFilePath += $_.Name + return + } + + $resourceFilePath += Join-Path -Path 'resources' -ChildPath $_.Name + } + + return $resourceFilePath +} + +function Add-AllowedManifestParameterEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$ManifestParameters, + [Parameter(Mandatory)][System.Collections.IDictionary]$Manifest, + [Parameter(Mandatory)][string[]]$AllowedParameter + ) + + foreach ($name in $Manifest.Keys) { + if ($name -eq 'PrivateData' -or $AllowedParameter -notcontains $name) { + continue + } + + if ($Manifest.$name) { + $ManifestParameters.add($name, $Manifest.$name) + } + } } diff --git a/src/private/build/ConvertToPowerShellDataLiteral.ps1 b/src/private/build/ConvertToPowerShellDataLiteral.ps1 new file mode 100644 index 0000000..f272fca --- /dev/null +++ b/src/private/build/ConvertToPowerShellDataLiteral.ps1 @@ -0,0 +1,201 @@ +function ConvertTo-PowerShellDataLiteral { + [CmdletBinding()] + param( + [AllowNull()]$Value, + [int]$IndentLevel = 0 + ) + + if ($null -eq $Value) { + return '$null' + } + + if ($Value -is [string]) { + return ConvertTo-PowerShellQuotedStringLiteral -Value $Value + } + + if ($Value -is [bool]) { + if ($Value) { + return '$true' + } + + return '$false' + } + + if (Test-PowerShellDataNumber -Value $Value) { + return [System.Management.Automation.LanguagePrimitives]::ConvertTo($Value, [string], [System.Globalization.CultureInfo]::InvariantCulture) + } + + if ($Value -is [System.Collections.IDictionary]) { + return ConvertTo-PowerShellCollectionLiteral -Value $Value -IndentLevel $IndentLevel + } + + if (Test-PowerShellDataList -Value $Value) { + return ConvertTo-PowerShellCollectionLiteral -Value $Value -IndentLevel $IndentLevel + } + + return ConvertTo-PowerShellQuotedStringLiteral -Value ([string]$Value) +} + +function ConvertTo-PowerShellQuotedStringLiteral { + [CmdletBinding()] + param( + [AllowEmptyString()] + [Parameter(Mandatory)][string]$Value + ) + + return "'$( $Value -replace '''', '''''' )'" +} + +function Test-PowerShellDataNumber { + [CmdletBinding()] + param( + [AllowNull()]$Value + ) + + if ($Value -isnot [ValueType]) { + return $false + } + + return @( + 'System.Byte' + 'System.SByte' + 'System.Int16' + 'System.UInt16' + 'System.Int32' + 'System.UInt32' + 'System.Int64' + 'System.UInt64' + 'System.Decimal' + 'System.Double' + 'System.Single' + ) -contains $Value.GetType().FullName +} + +function Test-PowerShellDataList { + [CmdletBinding()] + param( + [AllowNull()]$Value + ) + + return $Value -is [System.Collections.IList] -and $Value -isnot [string] +} + +function ConvertTo-PowerShellCollectionLiteral { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Value, + [int]$IndentLevel = 0 + ) + + $blockMetadata = Get-PowerShellCollectionBlockDescriptor -Value $Value + if ($null -ne $blockMetadata.EmptyLiteral) { + return $blockMetadata.EmptyLiteral + } + + return Format-PowerShellDataBlock ` + -OpeningToken $blockMetadata.OpeningToken ` + -ItemList (Get-PowerShellCollectionBlockItemList -Value $Value -IndentLevel $IndentLevel) ` + -IndentLevel $IndentLevel ` + -ClosingToken $blockMetadata.ClosingToken +} + +function Get-PowerShellDataIndent { + [CmdletBinding()] + param( + [int]$IndentLevel = 0 + ) + + return (' ' * 4 * $IndentLevel) +} + +function Format-PowerShellDataBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$OpeningToken, + [Parameter(Mandatory)][string[]]$ItemList, + [int]$IndentLevel = 0, + [string]$ClosingToken = '}' + ) + + $joinedItems = $ItemList -join "`n" + $closingIndent = Get-PowerShellDataIndent -IndentLevel $IndentLevel + return "$OpeningToken`n$joinedItems`n$closingIndent$ClosingToken" +} + +function Get-PowerShellCollectionBlockDescriptor { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Value + ) + + if ($Value -is [System.Collections.IDictionary]) { + return Get-PowerShellCollectionBlockDescriptorForCount -Count $Value.Count -EmptyLiteral '@{}' -OpeningToken '@{' -ClosingToken '}' + } + + if ($Value -is [System.Collections.IList]) { + return Get-PowerShellCollectionBlockDescriptorForCount -Count $Value.Count -EmptyLiteral '@()' -OpeningToken '@(' -ClosingToken ')' + } + + throw "Unsupported PowerShell data collection type: $($Value.GetType().FullName)" +} + +function Get-PowerShellCollectionBlockDescriptorForCount { + [CmdletBinding()] + param( + [Parameter(Mandatory)][int]$Count, + [Parameter(Mandatory)][string]$EmptyLiteral, + [Parameter(Mandatory)][string]$OpeningToken, + [Parameter(Mandatory)][string]$ClosingToken + ) + + if ($Count -eq 0) { + return [pscustomobject]@{ + EmptyLiteral = $EmptyLiteral + OpeningToken = $null + ClosingToken = $null + } + } + + return [pscustomobject]@{ + EmptyLiteral = $null + OpeningToken = $OpeningToken + ClosingToken = $ClosingToken + } +} + +function Get-PowerShellCollectionBlockItemList { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Value, + [int]$IndentLevel = 0 + ) + + $childIndent = Get-PowerShellDataIndent -IndentLevel ($IndentLevel + 1) + if ($Value -is [System.Collections.IDictionary]) { + foreach ($key in (Get-PowerShellDataDictionaryKeyList -Dictionary $Value)) { + $keyLiteral = ConvertTo-PowerShellQuotedStringLiteral -Value $key + "$childIndent$keyLiteral = $( ConvertTo-PowerShellDataLiteral -Value $Value[$key] -IndentLevel ($IndentLevel + 1) )" + } + + return + } + + foreach ($item in $Value) { + "$childIndent$( ConvertTo-PowerShellDataLiteral -Value $item -IndentLevel ($IndentLevel + 1) )" + } +} + +function Get-PowerShellDataDictionaryKeyList { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Collections.IDictionary]$Dictionary + ) + + $keyList = @($Dictionary.Keys | ForEach-Object {[string]$_}) + if ($Dictionary -is [System.Collections.Specialized.OrderedDictionary] -or + $Dictionary.GetType().FullName -eq 'System.Management.Automation.OrderedHashtable') { + return $keyList + } + + return @($keyList | Sort-Object) +} diff --git a/src/private/build/UpdateManifestPrivateData.ps1 b/src/private/build/UpdateManifestPrivateData.ps1 new file mode 100644 index 0000000..9383e41 --- /dev/null +++ b/src/private/build/UpdateManifestPrivateData.ps1 @@ -0,0 +1,143 @@ +function Update-ManifestPrivateData { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][System.Collections.IDictionary]$PrivateData + ) + + if ($PrivateData.Contains('PSData')) { + Stop-NovaOperation -Message "Manifest.PrivateData cannot contain the reserved key 'PSData'. Nova preserves PowerShell-generated PSData separately." -ErrorId 'Nova.Configuration.ManifestPrivateDataReservedKey' -Category InvalidData -TargetObject 'Manifest.PrivateData.PSData' + } + + $manifest = Import-PowerShellDataFile -LiteralPath $ManifestPath -ErrorAction Stop + $mergedPrivateData = Get-MergedManifestPrivateData -Manifest $manifest -PrivateData $PrivateData + $replacementBlock = "PrivateData = $( ConvertTo-PowerShellDataLiteral -Value $mergedPrivateData )" + $updatedContent = Get-ManifestContentWithUpdatedHashtableEntryValue -ManifestPath $ManifestPath -Name 'PrivateData' -ReplacementValue $replacementBlock + + Set-Content -LiteralPath $ManifestPath -Value $updatedContent -NoNewline +} + +function Get-MergedManifestPrivateData { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)][System.Collections.IDictionary]$PrivateData + ) + + $mergedPrivateData = [ordered]@{} + $generatedPsData = Get-GeneratedManifestPsData -Manifest $Manifest + if ($null -ne $generatedPsData) { + $mergedPrivateData['PSData'] = $generatedPsData + } + + foreach ($key in (Get-PowerShellDataDictionaryKeyList -Dictionary $PrivateData)) { + $mergedPrivateData[$key] = $PrivateData[$key] + } + + return $mergedPrivateData +} + +function Get-GeneratedManifestPsData { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Manifest + ) + + $privateData = $Manifest.PrivateData + if ($privateData -is [System.Collections.IDictionary]) { + return Get-ManifestPsDataDictionary -PSData $privateData['PSData'] + } + + if ($null -eq $privateData) { + return $null + } + + return Get-ManifestPsDataDictionary -PSData $privateData.PSData +} + +function Get-ManifestPsDataDictionary { + [CmdletBinding()] + param( + [AllowNull()]$PSData + ) + + if ($null -eq $PSData) { + return $null + } + + if ($PSData -is [System.Collections.IDictionary]) { + return [ordered]@{} + $PSData + } + + $dictionary = [ordered]@{} + foreach ($property in $PSData.PSObject.Properties) { + $dictionary[$property.Name] = $property.Value + } + + return $dictionary +} + +function Get-ManifestContentWithUpdatedHashtableEntryValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$ReplacementValue + ) + + $content = Get-Content -LiteralPath $ManifestPath -Raw + $pair = Get-ManifestHashtableEntryAst -ManifestPath $ManifestPath -Name $Name + $keyStart = $pair.Item1.Extent.StartOffset + $valueEnd = $pair.Item2.Extent.EndOffset + + return "$( $content.Substring(0, $keyStart) )$ReplacementValue$( $content.Substring($valueEnd) )" +} + +function Get-ManifestHashtableEntryAst { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][string]$Name + ) + + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($ManifestPath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { + Stop-NovaOperation -Message "Failed to parse generated manifest before updating PrivateData: $ManifestPath" -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' -Category ParserError -TargetObject $ManifestPath + } + + $topLevelHashtable = Get-TopLevelManifestHashtableAst -Ast $ast + $pair = @( + $topLevelHashtable.KeyValuePairs | + Where-Object {$_.Item1.SafeGetValue() -eq $Name} | + Select-Object -First 1 + )[0] + if ($null -eq $pair) { + Stop-NovaOperation -Message "Generated manifest is missing the top-level $Name entry: $ManifestPath" -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataMissing' -Category InvalidData -TargetObject $ManifestPath + } + + return $pair +} + +function Get-TopLevelManifestHashtableAst { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Management.Automation.Language.ScriptBlockAst]$Ast + ) + + $hashtableList = @( + $Ast.FindAll( + { param($node) $node -is [System.Management.Automation.Language.HashtableAst] }, + $true + ) | + Sort-Object { + $_.Extent.EndOffset - $_.Extent.StartOffset + } -Descending + ) + if ($hashtableList.Count -eq 0) { + Stop-NovaOperation -Message 'Generated manifest does not contain a top-level hashtable.' -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' -Category InvalidData -TargetObject $Ast.Extent.File + } + + return $hashtableList[0] +} diff --git a/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 b/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 index 6ad50db..979e613 100644 --- a/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 +++ b/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 @@ -1,3 +1,15 @@ +function Get-NovaModuleSelfUpdateFailureMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$FailureDetail + ) + + return @( + "NovaModuleTools self-update failed: $FailureDetail" + 'Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun the self-update command.' + ) -join [Environment]::NewLine +} + function Invoke-NovaModuleSelfUpdateOrStop { [CmdletBinding()] param( @@ -7,7 +19,7 @@ function Invoke-NovaModuleSelfUpdateOrStop { try { $null = Invoke-NovaModuleSelfUpdate -ModuleName $Plan.ModuleName -AllowPrerelease:$Plan.UsedAllowPrerelease } catch { - $message = "NovaModuleTools self-update failed: $( $_.Exception.Message ) Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun Update-NovaModuleTool." + $message = Get-NovaModuleSelfUpdateFailureMessage -FailureDetail $_.Exception.Message Stop-NovaOperation -Message $message -ErrorId 'Nova.Dependency.ModuleSelfUpdateFailed' -Category InvalidOperation -TargetObject $Plan.ModuleName } } diff --git a/src/resources/Schema-Project.json b/src/resources/Schema-Project.json index 8efcc16..66ad0c4 100644 --- a/src/resources/Schema-Project.json +++ b/src/resources/Schema-Project.json @@ -85,6 +85,11 @@ "type": "string", "description": "Sets the copyright string in the module manifest." }, + "PrivateData": { + "type": "object", + "additionalProperties": true, + "description": "Arbitrary consumer-defined module metadata serialized into the generated manifest PrivateData hashtable." + }, "RequiredModules": { "type": "array", "description": "Lists modules that must be imported before this module. Each entry is either a module name string or a module-specification object.", diff --git a/src/resources/agentic-copilot/.github/skills/skills/SKILL.md b/src/resources/agentic-copilot/.github/skills/skills/SKILL.md new file mode 100644 index 0000000..7e0f41f --- /dev/null +++ b/src/resources/agentic-copilot/.github/skills/skills/SKILL.md @@ -0,0 +1,20 @@ +--- +name: skills +description: Index folder for vendored shared skills used by {{ProjectName}} scaffolding and local guidance. +--- + +# Skill: skills + +## When to use + +Use this folder only as a container for nested shared skills that are maintained separately from the repository-local skill entry points. + +## Relevant files + +- `.github/skills/skills/*` + +## Expected practices + +- Do not reference this container skill directly from prompts or agents. +- Keep nested shared skills organized beneath this folder when they are intentionally vendored for local reuse. +- Prefer the repository-local top-level skills under `.github/skills/*/SKILL.md` for normal {{ProjectName}} task routing. diff --git a/tests/private/build/BuildManifest.Tests.ps1 b/tests/private/build/BuildManifest.Tests.ps1 index b8b2cfe..a6651d2 100644 --- a/tests/private/build/BuildManifest.Tests.ps1 +++ b/tests/private/build/BuildManifest.Tests.ps1 @@ -1,6 +1,8 @@ BeforeAll { $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) . (Join-Path $projectRoot 'src/private/build/BuildManifest.ps1') + . (Join-Path $projectRoot 'src/private/build/UpdateManifestPrivateData.ps1') + . (Join-Path $projectRoot 'src/private/build/ConvertToPowerShellDataLiteral.ps1') . (Join-Path $projectRoot 'src/private/build/manifest/GetFunctionNameFromFile.ps1') . (Join-Path $projectRoot 'src/private/build/manifest/GetAliasNameFromFunction.ps1') . (Join-Path $projectRoot 'src/private/build/manifest/AssertManifestSchema.ps1') @@ -74,6 +76,72 @@ Describe 'Build-Manifest' { $manifest.PrivateData.PSData.Prerelease | Should -Be 'beta1' } + It 'merges generated PSData and configured Manifest.PrivateData into the generated manifest' { + $script:ctx.Manifest = @{ + Author = 'Me' + Tags = @('Example') + PrivateData = [ordered]@{ + 'Example Product' = [ordered]@{ + 'Feature-Flag' = $true + ApiVersion = '1' + RetryCount = 3 + 'Nested Data' = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + } + } + } + Mock Get-NovaBuildProjectInfo { $script:ctx } + Mock Assert-ManifestSchema {} + + Build-Manifest -ProjectInfo ([pscustomobject]@{}) + + $manifest = Import-PowerShellDataFile -Path $script:ctx.ManifestFilePSD1 + $manifest.PrivateData.PSData.Tags | Should -Be @('Example') + $manifest.PrivateData['Example Product']['Feature-Flag'] | Should -BeTrue + $manifest.PrivateData['Example Product'].ApiVersion | Should -Be '1' + $manifest.PrivateData['Example Product'].RetryCount | Should -Be 3 + $manifest.PrivateData['Example Product']['Nested Data'].Mode | Should -Be 'Test' + $manifest.PrivateData['Example Product'].Values | Should -Be @('one', 'two') + } + + It 'stops with friendly error when rewritten PrivateData leaves an invalid manifest' { + $script:ctx.Manifest = @{ + Author = 'Me' + PrivateData = [ordered]@{ + ExampleProduct = [ordered]@{ + Enabled = $true + } + } + } + Mock Get-NovaBuildProjectInfo { $script:ctx } + Mock Assert-ManifestSchema {} + Mock Update-ManifestPrivateData {} + Mock Import-PowerShellDataFile -ParameterFilter {$LiteralPath -eq $script:ctx.ManifestFilePSD1} { + throw [System.Exception]::new('invalid data file') + } + + { Build-Manifest -ProjectInfo ([pscustomobject]@{}) } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataValidationFailed' + } + + It 'throws when configured Manifest.PrivateData collides with PSData' { + $script:ctx.Manifest = @{ + Author = 'Me' + PrivateData = @{ + PSData = @{ + ExampleProduct = 'bad' + } + } + } + Mock Get-NovaBuildProjectInfo { $script:ctx } + Mock Assert-ManifestSchema {} + + { Build-Manifest -ProjectInfo ([pscustomobject]@{}) } | + Should -Throw -ErrorId 'Nova.Configuration.ManifestPrivateDataReservedKey' + } + It 'stops with friendly error when New-ModuleManifest fails' { $script:ctx.ManifestFilePSD1 = '/nonexistent/x/y/Out.psd1' Mock Get-NovaBuildProjectInfo { $script:ctx } diff --git a/tests/private/build/ConvertToPowerShellDataLiteral.Tests.ps1 b/tests/private/build/ConvertToPowerShellDataLiteral.Tests.ps1 new file mode 100644 index 0000000..bcfceae --- /dev/null +++ b/tests/private/build/ConvertToPowerShellDataLiteral.Tests.ps1 @@ -0,0 +1,111 @@ +BeforeAll { + $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + . (Join-Path $projectRoot 'src/private/build/ConvertToPowerShellDataLiteral.ps1') +} + +Describe 'ConvertTo-PowerShellDataLiteral' { + It 'serializes an empty string' { + ConvertTo-PowerShellDataLiteral -Value '' | Should -Be "''" + } + + It 'serializes a string with apostrophes safely' { + ConvertTo-PowerShellDataLiteral -Value "don't run" | Should -Be "'don''t run'" + } + + It 'quotes arbitrary dictionary keys safely' { + $value = [ordered]@{ + 'Example Product' = $true + 'Example-Product' = 3 + "Owner's Choice" = 'ready' + '123abc' = $null + } + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.Contains('''Example Product'' = $true') | Should -BeTrue + $result.Contains('''Example-Product'' = 3') | Should -BeTrue + $result.Contains('''Owner''''s Choice'' = ''ready''') | Should -BeTrue + $result.Contains('''123abc'' = $null') | Should -BeTrue + } + + It 'serializes boolean true' { + ConvertTo-PowerShellDataLiteral -Value $true | Should -Be '$true' + } + + It 'serializes boolean false' { + ConvertTo-PowerShellDataLiteral -Value $false | Should -Be '$false' + } + + It 'serializes null' { + ConvertTo-PowerShellDataLiteral -Value $null | Should -Be '$null' + } + + It 'serializes integers' { + ConvertTo-PowerShellDataLiteral -Value 3 | Should -Be '3' + } + + It 'serializes numbers' { + ConvertTo-PowerShellDataLiteral -Value 3.5 | Should -Be '3.5' + } + + It 'serializes unsupported scalar values as quoted strings' { + $version = [version]'1.2.3' + + ConvertTo-PowerShellDataLiteral -Value $version | Should -Be "'1.2.3'" + } + + It 'serializes empty objects' { + ConvertTo-PowerShellDataLiteral -Value ([ordered]@{}) | Should -Be '@{}' + } + + It 'serializes nested objects and arrays recursively' { + $value = [ordered]@{ + ExampleProduct = [ordered]@{ + Enabled = $true + ApiVersion = '1' + RetryCount = 3 + Nested = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + } + } + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.Contains("`n 'ExampleProduct' = @{") | Should -BeTrue + $result.Contains('''Enabled'' = $true') | Should -BeTrue + $result.Contains('''ApiVersion'' = ''1''') | Should -BeTrue + $result.Contains('''RetryCount'' = 3') | Should -BeTrue + $result.Contains('''Nested'' = @{') | Should -BeTrue + $result.Contains('''Mode'' = ''Test''') | Should -BeTrue + $result.Contains('''Values'' = @(') | Should -BeTrue + $result.Contains('''one''') | Should -BeTrue + $result.Contains('''two''') | Should -BeTrue + } + + It 'sorts plain hashtable keys deterministically' { + $value = @{b = 2; a = 1} + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.IndexOf("'a' = 1") | Should -BeLessThan $result.IndexOf("'b' = 2") + } + + It 'preserves ordered dictionary keys in their configured order' { + $value = [ordered]@{ + b = 2 + a = 1 + } + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.IndexOf("'b' = 2") | Should -BeLessThan $result.IndexOf("'a' = 1") + } + + It 'throws for unsupported collection values' { + $queue = [System.Collections.Queue]::new() + + { Get-PowerShellCollectionBlockDescriptor -Value $queue } | Should -Throw 'Unsupported PowerShell data collection type*' + } +} diff --git a/tests/private/build/TestProjectSchema.Tests.ps1 b/tests/private/build/TestProjectSchema.Tests.ps1 index 62af82e..d2d8519 100644 --- a/tests/private/build/TestProjectSchema.Tests.ps1 +++ b/tests/private/build/TestProjectSchema.Tests.ps1 @@ -27,4 +27,58 @@ Describe 'Test-ProjectSchema' { {Test-ProjectSchema} | Should -Throw } + + It 'accepts project.json when Manifest.PrivateData is omitted' { + $schemaPath = Join-Path $projectRoot 'src/resources/Schema-Project.json' + $projectJsonPath = Join-Path $TestDrive 'project-no-private-data.json' + Set-Content -LiteralPath $projectJsonPath -Value @' +{ + "ProjectName": "Demo", + "Description": "Demo module", + "Version": "1.0.0", + "Manifest": { + "Author": "Nova", + "PowerShellHostVersion": "7.4", + "GUID": "11111111-1111-1111-1111-111111111111" + } +} +'@ + + Test-Json -Path $projectJsonPath -Schema (Get-Content -LiteralPath $schemaPath -Raw) | Should -BeTrue + } + + It 'accepts project.json when Manifest.PrivateData contains arbitrary nested data' { + $schemaPath = Join-Path $projectRoot 'src/resources/Schema-Project.json' + $projectJsonPath = Join-Path $TestDrive 'project-private-data.json' + Set-Content -LiteralPath $projectJsonPath -Value @' +{ + "ProjectName": "Demo", + "Description": "Demo module", + "Version": "1.0.0", + "Manifest": { + "Author": "Nova", + "PowerShellHostVersion": "7.4", + "GUID": "11111111-1111-1111-1111-111111111111", + "PrivateData": { + "ExampleProduct": { + "Enabled": true, + "ApiVersion": "1", + "RetryCount": 3, + "Nested": { + "Mode": "Test" + }, + "Values": [ + "one", + "two" + ], + "Nothing": null, + "EmptyObject": {} + } + } + } +} +'@ + + Test-Json -Path $projectJsonPath -Schema (Get-Content -LiteralPath $schemaPath -Raw) | Should -BeTrue + } } diff --git a/tests/private/build/UpdateManifestPrivateData.Tests.ps1 b/tests/private/build/UpdateManifestPrivateData.Tests.ps1 new file mode 100644 index 0000000..0837af0 --- /dev/null +++ b/tests/private/build/UpdateManifestPrivateData.Tests.ps1 @@ -0,0 +1,140 @@ +BeforeAll { + $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + . (Join-Path $projectRoot 'src/private/build/UpdateManifestPrivateData.ps1') + . (Join-Path $projectRoot 'src/private/build/ConvertToPowerShellDataLiteral.ps1') + + function Stop-NovaOperation { + param([string]$Message, [string]$ErrorId, $Category, $TargetObject) + + $exception = [System.Exception]::new($Message) + $record = [System.Management.Automation.ErrorRecord]::new($exception, $ErrorId, $Category, $TargetObject) + throw $record + } +} + +Describe 'Update-ManifestPrivateData' { + BeforeEach { + $script:manifestPath = Join-Path $TestDrive 'Demo.psd1' + New-ModuleManifest -Path $script:manifestPath -RootModule 'Demo.psm1' -ModuleVersion '1.0.0' -Author 'Me' -Tags @('Example') | Out-Null + } + + It 'merges generated PSData with consumer-defined PrivateData' { + $privateData = [ordered]@{ + ExampleProduct = [ordered]@{ + Enabled = $true + ApiVersion = '1' + RetryCount = 3 + Nested = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + } + } + + Update-ManifestPrivateData -ManifestPath $script:manifestPath -PrivateData $privateData + + $manifest = Import-PowerShellDataFile -LiteralPath $script:manifestPath + $manifest.PrivateData.PSData.Tags | Should -Be @('Example') + $manifest.PrivateData.ExampleProduct.Enabled | Should -BeTrue + $manifest.PrivateData.ExampleProduct.ApiVersion | Should -Be '1' + $manifest.PrivateData.ExampleProduct.RetryCount | Should -Be 3 + $manifest.PrivateData.ExampleProduct.Nested.Mode | Should -Be 'Test' + $manifest.PrivateData.ExampleProduct.Values | Should -Be @('one', 'two') + + (Get-Content -LiteralPath $script:manifestPath -Raw) | Should -Match "'PSData' = @\{" + } + + It 'preserves arbitrary dictionary keys that require quoting' { + $privateData = [ordered]@{ + 'Example Product' = [ordered]@{ + 'Feature-Flag' = $true + "Owner's Choice" = 'ready' + '123abc' = 3 + } + } + + Update-ManifestPrivateData -ManifestPath $script:manifestPath -PrivateData $privateData + + $manifest = Import-PowerShellDataFile -LiteralPath $script:manifestPath + $manifest.PrivateData['Example Product']['Feature-Flag'] | Should -BeTrue + $manifest.PrivateData['Example Product']["Owner's Choice"] | Should -Be 'ready' + $manifest.PrivateData['Example Product']['123abc'] | Should -Be 3 + + (Get-Content -LiteralPath $script:manifestPath -Raw) | Should -Match "'Example Product' = @\{" + } + + It 'throws when consumer PrivateData collides with reserved PSData' { + $privateData = [ordered]@{ + PSData = [ordered]@{ + ExampleProduct = 'bad' + } + } + + { Update-ManifestPrivateData -ManifestPath $script:manifestPath -PrivateData $privateData } | + Should -Throw -ErrorId 'Nova.Configuration.ManifestPrivateDataReservedKey' + } +} + +Describe 'Get-GeneratedManifestPsData' { + It 'returns null when the manifest has no PrivateData' { + $manifest = [pscustomobject]@{ + PrivateData = $null + } + + Get-GeneratedManifestPsData -Manifest $manifest | Should -BeNullOrEmpty + } + + It 'converts object-based PSData into an ordered dictionary' { + $manifest = [pscustomobject]@{ + PrivateData = [pscustomobject]@{ + PSData = [pscustomobject]@{ + Tags = @('Example') + ReleaseNotes = 'https://example.test/release' + } + } + } + + $result = Get-GeneratedManifestPsData -Manifest $manifest + + $result | Should -BeOfType ([System.Collections.Specialized.OrderedDictionary]) + $result.Tags | Should -Be @('Example') + $result.ReleaseNotes | Should -Be 'https://example.test/release' + } +} + +Describe 'Get-ManifestPsDataDictionary' { + It 'returns null when PSData is null' { + Get-ManifestPsDataDictionary -PSData $null | Should -BeNullOrEmpty + } +} + +Describe 'Get-ManifestHashtableEntryAst' { + It 'throws when the generated manifest cannot be parsed' { + $invalidManifestPath = Join-Path $TestDrive 'Invalid.psd1' + Set-Content -LiteralPath $invalidManifestPath -Value '@{ PrivateData = ' -NoNewline + + { Get-ManifestHashtableEntryAst -ManifestPath $invalidManifestPath -Name 'PrivateData' } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' + } + + It 'throws when the generated manifest is missing the requested top-level entry' { + $manifestPath = Join-Path $TestDrive 'MissingEntry.psd1' + Set-Content -LiteralPath $manifestPath -Value "@{`n RootModule = 'Demo.psm1'`n}" -NoNewline + + { Get-ManifestHashtableEntryAst -ManifestPath $manifestPath -Name 'PrivateData' } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataMissing' + } +} + +Describe 'Get-TopLevelManifestHashtableAst' { + It 'throws when the parsed file does not contain a top-level hashtable' { + $scriptPath = Join-Path $TestDrive 'NoHashtable.ps1' + Set-Content -LiteralPath $scriptPath -Value "'plain text'" -NoNewline + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors) + + { Get-TopLevelManifestHashtableAst -Ast $ast } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' + } +} diff --git a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 index a74c714..d7b09f2 100644 --- a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 +++ b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 @@ -22,6 +22,17 @@ Describe 'Complete-NovaModuleSelfUpdateResult' { } } +Describe 'Get-NovaModuleSelfUpdateFailureMessage' { + It 'splits failure details and recovery guidance across separate lines' { + $message = Get-NovaModuleSelfUpdateFailureMessage -FailureDetail 'gallery offline' + + $message | Should -Be (@( + 'NovaModuleTools self-update failed: gallery offline' + 'Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun the self-update command.' + ) -join [Environment]::NewLine) + } +} + Describe 'Invoke-NovaModuleSelfUpdateOrStop' { It 'calls Invoke-NovaModuleSelfUpdate with module name and prerelease flag' { Mock Invoke-NovaModuleSelfUpdate {} @@ -39,7 +50,7 @@ Describe 'Invoke-NovaModuleSelfUpdateOrStop' { $thrown | Should -Not -BeNullOrEmpty $thrown.FullyQualifiedErrorId | Should -Be 'Nova.Dependency.ModuleSelfUpdateFailed' - $thrown.Exception.Message | Should -Be 'NovaModuleTools self-update failed: gallery offline Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun Update-NovaModuleTool.' + $thrown.Exception.Message | Should -Be (Get-NovaModuleSelfUpdateFailureMessage -FailureDetail 'gallery offline') } } diff --git a/tests/public/TestNovaBuild.Integration.Tests.ps1 b/tests/public/TestNovaBuild.Integration.Tests.ps1 index 5fcbc38..42a51cf 100644 --- a/tests/public/TestNovaBuild.Integration.Tests.ps1 +++ b/tests/public/TestNovaBuild.Integration.Tests.ps1 @@ -66,4 +66,70 @@ Describe 'Test-NovaBuild integration' { $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) } + + It 'builds a manifest that preserves structured Manifest.PrivateData and generated PSData' { + $exampleProjectRoot = Join-Path $script:projectRoot 'src/resources/example' + $scenarioRoot = Join-Path $TestDrive 'private-data-build-validation' + $projectJsonPath = Join-Path $scenarioRoot 'project.json' + $integrationTestPath = Join-Path $scenarioRoot 'tests/public/ManifestPrivateData.Integration.Tests.ps1' + $null = New-Item -ItemType Directory -Path $scenarioRoot -Force + Copy-Item -Path (Join-Path $exampleProjectRoot '*') -Destination $scenarioRoot -Recurse -Force + + $projectData = Get-Content -LiteralPath $projectJsonPath -Raw | ConvertFrom-Json -AsHashtable + $projectData.Manifest.Tags = @('Example') + $projectData.Manifest.PrivateData = [ordered]@{ + 'Example Product' = [ordered]@{ + 'Feature-Flag' = $true + ApiVersion = '1' + RetryCount = 3 + 'Nested Data' = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + "Owner's Choice" = 'ready' + '123abc' = $null + } + } + $projectData | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $projectJsonPath + + Set-Content -LiteralPath $integrationTestPath -Value @' +BeforeAll { + $projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $projectFile = Join-Path $projectRoot 'project.json' + $projectData = Get-Content -LiteralPath $projectFile -Raw | ConvertFrom-Json -AsHashtable + $script:moduleName = [string]$projectData.ProjectName + $script:manifestPath = Join-Path $projectRoot "dist/$($script:moduleName)/$($script:moduleName).psd1" + $script:manifest = Import-PowerShellDataFile -LiteralPath $script:manifestPath +} + +Describe 'Manifest.PrivateData integration' { + It 'preserves native structured values in the generated manifest' { + $script:manifest.PrivateData['Example Product']['Feature-Flag'] | Should -BeTrue + $script:manifest.PrivateData['Example Product'].ApiVersion | Should -Be '1' + $script:manifest.PrivateData['Example Product'].RetryCount | Should -Be 3 + $script:manifest.PrivateData['Example Product']['Nested Data'].Mode | Should -Be 'Test' + $script:manifest.PrivateData['Example Product'].Values | Should -Be @('one', 'two') + $script:manifest.PrivateData['Example Product']["Owner's Choice"] | Should -Be 'ready' + $script:manifest.PrivateData['Example Product']['123abc'] | Should -BeNullOrEmpty + } + + It 'preserves generated PSData alongside custom PrivateData' { + $script:manifest.PrivateData.PSData.Tags | Should -Be @('Example') + } + + It 'writes a manifest that remains importable after the PrivateData rewrite' { + $content = Get-Content -LiteralPath $script:manifestPath -Raw + + $script:manifest.PrivateData.PSData.Tags | Should -Be @('Example') + $content | Should -Match "'Example Product' = @\{" + } +} +'@ + + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $scenarioRoot -ScriptBlock { + Test-NovaBuild + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + } }