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)
+ }
}