Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ Keep stable `Update-NovaModuleVersion` / `% nova bump` releases on the SemVer ma
- Fix the repository `run.ps1` quality loop after the CI installer refactor introduced new ScriptAnalyzer warnings.
- The internal CI installer helper now follows ScriptAnalyzer naming and `ShouldProcess` expectations.
- `run.ps1` no longer stops in the analyzer step because of those helper warnings.
- Fix interactive `nova init` / `nova init -e` scaffold validation so invalid answers retry immediately at the prompt.
- Invalid module names now show the validation message inline instead of failing after the full questionnaire.
- Standard and example scaffold flows now share the same retry-first validation behavior through the common prompt
path.

### Security

Expand Down
8 changes: 7 additions & 1 deletion docs/NovaModuleTools/en-US/Initialize-NovaModule.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ PS> Initialize-NovaModule [-Path <string>] [-Example] [-WhatIf] [-Confirm] [<Com
The command collects project details interactively, including the module name, description, version, author, minimum
PowerShell version, Git initialization, and, for the standard scaffold, optional basic Pester support.

If you enter an invalid answer during the interactive flow, `Initialize-NovaModule` reports the validation problem
immediately and retries that prompt before it continues to the next question.

Use this command when you want to start a new module in the NovaModuleTools structure without hand-creating the project
layout.

Expand All @@ -38,7 +41,8 @@ supported.

Use `-Example` when you want the scaffold to start from the packaged example project instead of the minimal default
layout. The example flow keeps the example source, resource, and test files, skips the Pester enable/disable question,
and applies the interactive metadata values to the copied `project.json`.
and applies the interactive metadata values to the copied `project.json`. The standard and example flows share the same
inline validation and retry behavior for interactive answers.

This command supports `-WhatIf` and `-Confirm` through PowerShell `SupportsShouldProcess`. Use `-WhatIf` to preview the
scaffold target after the interactive answers have been collected, without creating folders, writing `project.json`, or
Expand All @@ -53,6 +57,7 @@ PS> Initialize-NovaModule -Path ~/Work
```

Starts the interactive scaffold flow and creates the new module under `~/Work`.
Invalid interactive answers are retried immediately before the command continues.

### EXAMPLE 2

Expand All @@ -70,6 +75,7 @@ PS> Initialize-NovaModule -Example -Path ~/Work

Creates a new project under `~/Work` from the packaged example template and applies the answers from the interactive
prompt flow to the copied `project.json`.
Invalid interactive answers are retried immediately here as well.

### EXAMPLE 4

Expand Down
15 changes: 15 additions & 0 deletions src/private/cli/GetAwesomePromptFieldDescription.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
function Get-AwesomePromptFieldDescription {
param([Parameter(Mandatory)][object]$Ask)

$fieldDescription = [System.Management.Automation.Host.FieldDescription]::new(
(Get-AwesomePromptValue -Ask $Ask -Name 'Prompt')
)
$defaultValue = Get-AwesomePromptValue -Ask $Ask -Name 'Default'
if ($defaultValue -ne 'MANDATORY') {
$fieldDescription.DefaultValue = $defaultValue
}

return $fieldDescription
}


4 changes: 2 additions & 2 deletions src/private/cli/GetAwesomePromptResult.ps1
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
function Get-AwesomePromptResult {
param(
[Parameter(Mandatory)][pscustomobject]$Ask,
[Parameter(Mandatory)][object]$Ask,
[Parameter(Mandatory)][object]$Response
)

if ( [string]::IsNullOrEmpty($Response.Values)) {
return $Ask.Default
return Get-AwesomePromptValue -Ask $Ask -Name 'Default'
}

return $Response.Values
Expand Down
28 changes: 28 additions & 0 deletions src/private/cli/GetAwesomePromptValidationFailure.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
function Get-AwesomePromptValidationFailure {
param(
[Parameter(Mandatory)][object]$Ask,
[AllowNull()]$Value
)

$validation = Get-AwesomePromptValue -Ask $Ask -Name 'Validation'
if ($null -eq $validation) {
return $null
}

$validator = Get-AwesomePromptValue -Ask $validation -Name 'Test'
if ($null -eq $validator) {
return $null
}

if ([bool](& $validator $Value)) {
return $null
}

return [pscustomobject]@{
Message = Get-AwesomePromptValue -Ask $validation -Name 'Message'
ErrorId = Get-AwesomePromptValue -Ask $validation -Name 'ErrorId'
Category = Get-AwesomePromptValue -Ask $validation -Name 'Category'
TargetObject = $Value
}
}

22 changes: 22 additions & 0 deletions src/private/cli/GetAwesomePromptValue.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
function Get-AwesomePromptValue {
param(
[Parameter(Mandatory)][object]$Ask,
[Parameter(Mandatory)][string]$Name
)

if ($Ask -is [System.Collections.IDictionary]) {
if ( $Ask.Contains($Name)) {
return $Ask[$Name]
}

return $null
}

$property = $Ask.PSObject.Properties[$Name]
if ($null -eq $property) {
return $null
}

return $property.Value
}

13 changes: 9 additions & 4 deletions src/private/cli/ReadAwesomeChoicePrompt.ps1
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
function Read-AwesomeChoicePrompt {
param(
[Parameter(Mandatory)][pscustomobject]$Ask,
[Parameter(Mandatory)][object]$Ask,
[Parameter(Mandatory)][object]$HostUi
)

$options = Get-AwesomeChoiceOptionList -Choice $Ask.Choice
$defaultIndex = $options.Label.IndexOf('&' + $Ask.Default)
$response = $HostUi.PromptForChoice($Ask.Caption, $Ask.Message, $options, $defaultIndex)
$options = Get-AwesomeChoiceOptionList -Choice (Get-AwesomePromptValue -Ask $Ask -Name 'Choice')
$defaultIndex = $options.Label.IndexOf('&' + (Get-AwesomePromptValue -Ask $Ask -Name 'Default'))
$response = $HostUi.PromptForChoice(
(Get-AwesomePromptValue -Ask $Ask -Name 'Caption'),
(Get-AwesomePromptValue -Ask $Ask -Name 'Message'),
$options,
$defaultIndex
)

return $options.Label[$response] -replace '&'
}
17 changes: 11 additions & 6 deletions src/private/cli/ReadAwesomeStandardPrompt.ps1
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
function Read-AwesomeStandardPrompt {
param(
[Parameter(Mandatory)][pscustomobject]$Ask,
[Parameter(Mandatory)][object]$Ask,
[Parameter(Mandatory)][object]$HostUi
)

$fieldDescription = [System.Management.Automation.Host.FieldDescription]::new($Ask.Prompt)
if ($Ask.Default -ne 'MANDATORY') {
$fieldDescription.DefaultValue = $Ask.Default
}
$fieldDescription = Get-AwesomePromptFieldDescription -Ask $Ask

do {
$response = $HostUi.Prompt($Ask.Caption, $Ask.Message, @($fieldDescription))
$response = $HostUi.Prompt(
(Get-AwesomePromptValue -Ask $Ask -Name 'Caption'),
(Get-AwesomePromptValue -Ask $Ask -Name 'Message'),
@($fieldDescription)
)

if (Test-AwesomePromptRequiresRetry -Ask $Ask -Response $response) {
Write-AwesomePromptRetryMessage -Ask $Ask -Response $response
}
} while (Test-AwesomePromptRequiresRetry -Ask $Ask -Response $response)

return Get-AwesomePromptResult -Ask $Ask -Response $response
Expand Down
9 changes: 7 additions & 2 deletions src/private/cli/TestAwesomePromptRequiresRetry.ps1
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
function Test-AwesomePromptRequiresRetry {
param(
[Parameter(Mandatory)][pscustomobject]$Ask,
[Parameter(Mandatory)][object]$Ask,
[Parameter(Mandatory)][object]$Response
)

return $Ask.Default -eq 'MANDATORY' -and [string]::IsNullOrEmpty($Response.Values)
if ((Get-AwesomePromptValue -Ask $Ask -Name 'Default') -eq 'MANDATORY' -and [string]::IsNullOrEmpty($Response.Values)) {
return $true
}

$value = Get-AwesomePromptResult -Ask $Ask -Response $Response
return $null -ne (Get-AwesomePromptValidationFailure -Ask $Ask -Value $value)
}
15 changes: 15 additions & 0 deletions src/private/cli/WriteAwesomePromptRetryMessage.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
function Write-AwesomePromptRetryMessage {
param(
[Parameter(Mandatory)][object]$Ask,
[Parameter(Mandatory)][object]$Response
)

$value = Get-AwesomePromptResult -Ask $Ask -Response $Response
$failure = Get-AwesomePromptValidationFailure -Ask $Ask -Value $value
if ($null -eq $failure) {
return
}

Write-Message -Text $failure.Message -color Yello
}

14 changes: 14 additions & 0 deletions src/private/scaffold/AssertNovaModuleQuestionAnswerValid.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
function Assert-NovaModuleQuestionAnswerValid {
param(
[Parameter(Mandatory)][object]$Question,
[AllowNull()]$Value
)

$validationFailure = Get-AwesomePromptValidationFailure -Ask $Question -Value $Value
if ($null -eq $validationFailure) {
return
}

Stop-NovaOperation -Message $validationFailure.Message -ErrorId $validationFailure.ErrorId -Category $validationFailure.Category -TargetObject $validationFailure.TargetObject
}

54 changes: 38 additions & 16 deletions src/private/scaffold/GetNovaModuleQuestions.ps1
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
function Get-NovaModuleQuestionSet {
[CmdletBinding()]
param(
[switch]$Example
)
function Get-NovaModuleProjectNameValidation {
return @{
Test = {
param($Value)

$questions = [ordered]@{
return $Value -match '^[A-Za-z][A-Za-z0-9_.]*$'
}
Message = 'Module name is invalid. Use a single word that starts with a letter and contains only letters, numbers, underscores, or periods.'
ErrorId = 'Nova.Validation.ScaffoldProjectNameInvalid'
Category = [System.Management.Automation.ErrorCategory]::InvalidData
}
}

function Get-NovaModuleBaseQuestionSet {
return [ordered]@{
ProjectName = @{
Caption = 'Module Name'
Message = 'Enter Module name of your choice, should be single word with no special characters'
Prompt = 'Name'
Default = 'MANDATORY'
Validation = Get-NovaModuleProjectNameValidation
}
Description = @{
Caption = 'Module Description'
Expand Down Expand Up @@ -46,19 +55,32 @@ function Get-NovaModuleQuestionSet {
}
}
}
}

if (-not $Example) {
$questions.EnablePester = @{
Caption = 'Pester Testing'
Message = 'Do you want to enable basic Pester Testing'
Prompt = 'EnablePester'
Default = 'No'
Choice = [ordered]@{
Yes = 'Enable pester to perform testing'
No = 'Skip pester testing'
}
function Get-NovaModulePesterQuestion {
return @{
Caption = 'Pester Testing'
Message = 'Do you want to enable basic Pester Testing'
Prompt = 'EnablePester'
Default = 'No'
Choice = [ordered]@{
Yes = 'Enable pester to perform testing'
No = 'Skip pester testing'
}
}
}

function Get-NovaModuleQuestionSet {
[CmdletBinding()]
param(
[switch]$Example
)

$questions = Get-NovaModuleBaseQuestionSet

if (-not $Example) {
$questions.EnablePester = Get-NovaModulePesterQuestion
}

return $questions
}
5 changes: 1 addition & 4 deletions src/private/scaffold/ReadNovaModuleAnswers.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ function Read-NovaModuleAnswerSet {
$answer = [ordered]@{}
foreach ($question in $Questions.GetEnumerator()) {
$answer[$question.Key] = Read-AwesomeHost -Ask $question.Value
}

if ($answer.ProjectName -notmatch '^[A-Za-z][A-Za-z0-9_.]*$') {
Stop-NovaOperation -Message 'Module name is invalid. Use a single word that starts with a letter and contains only letters, numbers, underscores, or periods.' -ErrorId 'Nova.Validation.ScaffoldProjectNameInvalid' -Category InvalidData -TargetObject $answer.ProjectName
Assert-NovaModuleQuestionAnswerValid -Question $question.Value -Value $answer[$question.Key]
}

return $answer
Expand Down
5 changes: 3 additions & 2 deletions src/resources/cli/help/init.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Description = @(
'Create a new Nova module scaffold.',
'Run without options for the interactive flow, or pass an explicit destination path when you want a non-interactive target.',
'When an interactive answer is invalid, Nova shows the validation message immediately and retries that prompt before moving on.',
'For more information, documentation, and examples, visit:',
'https://www.novamoduletools.com/core-workflows.html#scaffold'
)
Expand All @@ -25,15 +26,15 @@
Examples = @(
@{
Command = 'nova init'
Description = 'Start the interactive scaffold flow.'
Description = 'Start the interactive scaffold flow with immediate inline retry for invalid answers.'
},
@{
Command = 'nova init --path ~/Work'
Description = 'Create a new module scaffold at an explicit destination.'
},
@{
Command = 'nova init --example --path ~/Work'
Description = 'Create the packaged example scaffold at an explicit destination.'
Description = 'Create the packaged example scaffold at an explicit destination with the same inline validation behavior.'
}
)
}
Loading
Loading