Stepper.psm1
|
function Clear-StepperState { <# .SYNOPSIS Removes the saved state file. .DESCRIPTION Deletes the Stepper state file from disk, effectively resetting all progress. Supports -WhatIf and -Confirm for safety. .PARAMETER Path The path to the state file to remove. If not specified, uses the default path from Get-StateFilePath. .EXAMPLE Clear-StepperState Removes the default state file. .EXAMPLE Clear-StepperState -WhatIf Shows what would happen without actually removing the file. .EXAMPLE Clear-StepperState -Path 'C:\Temp\my-state.json' Removes a custom state file. .NOTES If the file doesn't exist, a warning is displayed. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $false)] [string]$Path = (Get-StateFilePath) ) if (Test-Path $Path) { if ($PSCmdlet.ShouldProcess($Path, "Remove Stepper state file")) { try { Remove-Item -Path $Path -Force -ErrorAction Stop Write-Host "State file removed: $Path" -ForegroundColor Green Write-Verbose "State successfully cleared" } catch { Write-Error "Failed to remove state file '$Path': $_" } } } else { Write-Warning "No state file found at: $Path" } } function Get-StateFilePath { <# .SYNOPSIS Returns the path to the state file. .DESCRIPTION Returns the full path to the Stepper state file, creating the directory if it doesn't exist. .PARAMETER FileName The name of the state file. Default is 'stepper-state.json'. .OUTPUTS System.String - The full path to the state file. .EXAMPLE $statePath = Get-StateFilePath Gets the default state file path. .EXAMPLE $statePath = Get-StateFilePath -FileName 'custom-state.json' Gets a custom state file path. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $false)] [string]$FileName = 'stepper-state.json' ) # Store in user profile for persistence across sessions # Alternative: Use temp folder for session-only persistence # Cross-platform home directory detection $homeDir = if ($IsWindows -or $PSVersionTable.PSVersion.Major -le 5) { $env:USERPROFILE } else { $env:HOME } $stateDir = Join-Path -Path $homeDir -ChildPath '.stepper' if (-not (Test-Path $stateDir)) { New-Item -Path $stateDir -ItemType Directory -Force | Out-Null Write-Verbose "Created state directory: $stateDir" } $statePath = Join-Path -Path $stateDir -ChildPath $FileName Write-Verbose "State file path: $statePath" return $statePath } function Get-StepperState { <# .SYNOPSIS Loads the Stepper state from file, or creates a new one. .DESCRIPTION Attempts to load a saved Stepper state from disk. If the file doesn't exist or can't be loaded, returns a new empty state. Converts the JSON PSCustomObject back to a hashtable for easier manipulation in PowerShell. .PARAMETER Path The path to the state file. If not specified, uses the default path from Get-StateFilePath. .OUTPUTS System.Collections.Hashtable - The loaded or new Stepper state. .EXAMPLE $state = Get-StepperState Loads the state from the default location or creates new. .EXAMPLE $state = Get-StepperState -Path 'C:\Temp\my-state.json' Loads the state from a custom location. .NOTES If loading fails, warnings are displayed and a new state is returned. This ensures the Stepper can always proceed. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory = $false)] [string]$Path = (Get-StateFilePath) ) if (Test-Path $Path) { try { Write-Verbose "Loading state from: $Path" $json = Get-Content -Path $Path -Raw -Encoding UTF8 $stateObject = $json | ConvertFrom-Json # Convert PSCustomObject back to hashtable for easier manipulation $state = @{ Version = $stateObject.Version StepperId = $stateObject.StepperId StartedAt = $stateObject.StartedAt LastUpdated = $stateObject.LastUpdated CompletedSteps = @($stateObject.CompletedSteps) CurrentStepIndex = $stateObject.CurrentStepIndex Status = $stateObject.Status StepResults = @{} Metadata = @{} } # Convert nested objects if ($stateObject.StepResults) { $stateObject.StepResults.PSObject.Properties | ForEach-Object { $state.StepResults[$_.Name] = $_.Value } } if ($stateObject.Metadata) { $stateObject.Metadata.PSObject.Properties | ForEach-Object { $state.Metadata[$_.Name] = $_.Value } } Write-Verbose "State loaded successfully (ID: $($state.StepperId))" return $state } catch { Write-Warning "Failed to load state file: $_" Write-Warning "Starting with fresh state..." return New-StepperState } } else { Write-Verbose "No existing state file found, creating new state" return New-StepperState } } function Invoke-StepperStep { <# .SYNOPSIS Executes a single Stepper step and tracks the result. .DESCRIPTION Runs the script block for a given Stepper step, tracks timing, handles errors, and records results in the state object. .PARAMETER Step A PSCustomObject containing the step definition with Name, Description, ScriptBlock, and AcceptsAllResults properties. .PARAMETER State The Stepper state hashtable to update with results. .PARAMETER AllData Optional array containing results from all previously completed steps. Array index matches step number: AllData[1] = Step 1 result, AllData[2] = Step 2 result, etc. AllData[0] is reserved for initial input data. .OUTPUTS System.Boolean - $true if step succeeded, $false if it failed. .EXAMPLE $success = Invoke-StepperStep -Step $stepDef -State $state Executes a step and updates the state. .EXAMPLE $success = Invoke-StepperStep -Step $stepDef -State $state -AllResults $allResults Executes a step that needs access to previous results. .NOTES Results are automatically added to the state object. Duration is tracked automatically. Errors are caught and logged in the state. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [PSCustomObject]$Step, [Parameter(Mandatory = $true)] [hashtable]$State, [Parameter(Mandatory = $false)] [array]$AllData = @() ) $stepName = $Step.Name $startTime = Get-Date Write-Verbose "Executing step: $stepName" try { # Execute the step's script block # Always pass AllData if there are previous results # Array index matches step number: AllData[1] = Step 1, etc. $result = if ($AllData.Count -gt 1) { Write-Verbose "Passing AllData array to step (Count: $($AllData.Count - 1) previous results)" & $Step.ScriptBlock -AllData $AllData } else { Write-Verbose "No previous results to pass, executing directly" & $Step.ScriptBlock } $duration = (Get-Date) - $startTime # Record the result $State.StepResults[$stepName] = @{ Status = 'Completed' CompletedAt = Get-Date -Format 'o' Duration = "$([math]::Round($duration.TotalSeconds, 2))s" Result = $result } # Mark as completed if ($State.CompletedSteps -notcontains $stepName) { $State.CompletedSteps += $stepName } Write-Host "`n ✓ Step completed successfully in $([math]::Round($duration.TotalSeconds, 2))s" -ForegroundColor Green Write-Verbose "Step '$stepName' completed successfully" return $true } catch { $duration = (Get-Date) - $startTime # Record the failure $State.StepResults[$stepName] = @{ Status = 'Failed' FailedAt = Get-Date -Format 'o' Duration = "$([math]::Round($duration.TotalSeconds, 2))s" Error = $_.Exception.Message ErrorDetails = $_.ScriptStackTrace } Write-Host "`n ✗ Step failed: $($_.Exception.Message)" -ForegroundColor Red Write-Host " $($_.ScriptStackTrace)" -ForegroundColor DarkRed Write-Verbose "Step '$stepName' failed: $($_.Exception.Message)" return $false } } function New-StepperState { <# .SYNOPSIS Creates a new empty Stepper state object. .DESCRIPTION Initializes a new hashtable containing the default structure for tracking Stepper progress, including metadata about the environment. .OUTPUTS System.Collections.Hashtable - A new Stepper state object. .EXAMPLE $state = New-StepperState Creates a new Stepper state with default values. .NOTES The state object includes: - Version: State file format version - StepperId: Unique identifier for this Stepper run - StartedAt: Timestamp when Stepper started - LastUpdated: Timestamp of last state update - CompletedSteps: Array of completed step names - CurrentStepIndex: Index of current/next step - Status: Current status (InProgress, Completed, Failed) - StepResults: Hashtable of results per step - Metadata: Environment information #> [CmdletBinding()] [OutputType([hashtable])] param() Write-Verbose "Creating new Stepper state" return @{ Version = '1.0.0' StepperId = [guid]::NewGuid().ToString() StartedAt = Get-Date -Format 'o' LastUpdated = Get-Date -Format 'o' CompletedSteps = @() CurrentStepIndex = 0 Status = 'InProgress' StepResults = @{} Metadata = @{ ComputerName = $env:COMPUTERNAME UserName = $env:USERNAME PSVersion = $PSVersionTable.PSVersion.ToString() Domain = $env:USERDNSDOMAIN } } } function Save-StepperState { <# .SYNOPSIS Saves the Stepper state to a JSON file. .DESCRIPTION Serializes the Stepper state hashtable to JSON format and saves it to disk. Updates the LastUpdated timestamp automatically. .PARAMETER State The Stepper state hashtable to save. .PARAMETER Path The path where the state file should be saved. If not specified, uses the default path from Get-StateFilePath. .EXAMPLE Save-StepperState -State $state Saves the state to the default location. .EXAMPLE Save-StepperState -State $state -Path 'C:\Temp\my-state.json' Saves the state to a custom location. .NOTES The state is saved with indentation for readability. Throws an error if save fails. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$State, [Parameter(Mandatory = $false)] [string]$Path = (Get-StateFilePath) ) try { # Update timestamp $State.LastUpdated = Get-Date -Format 'o' Write-Verbose "Saving state to: $Path" # Convert to JSON with good depth for nested objects $json = $State | ConvertTo-Json -Depth 10 -Compress:$false # Save to file $json | Set-Content -Path $Path -Force -Encoding UTF8 Write-Verbose "State saved successfully" Write-Debug "State content: $json" } catch { Write-Error "Failed to save state to '$Path': $_" throw } } function Show-StepperHeader { <# .SYNOPSIS Displays a formatted header for the Stepper. .DESCRIPTION Shows a visually formatted header with the Stepper tool name and version at the start of the Stepper. .PARAMETER Title The title to display in the header. Default is "Multi-Step Stepper Tool". .PARAMETER Version The version string to display. Default is "1.0.0". .EXAMPLE Show-StepperHeader Displays the default header. .EXAMPLE Show-StepperHeader -Title "Security Stepper" -Version "2.0.0" Displays a custom header. #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$Title = "Multi-Step Stepper Tool", [Parameter(Mandatory = $false)] [string]$Version = "1.0.0" ) $border = "=" * 70 Write-Host $border -ForegroundColor Cyan Write-Host " $Title" -ForegroundColor Cyan Write-Host " Version $Version" -ForegroundColor Cyan Write-Host $border -ForegroundColor Cyan Write-Host "" } function Show-StepperProgress { <# .SYNOPSIS Displays current progress summary. .DESCRIPTION Shows a formatted summary of the Stepper progress including: - Stepper ID - Start and update timestamps - Current status - Completed steps with durations - Overall percentage complete .PARAMETER State The Stepper state hashtable containing progress information. .PARAMETER TotalSteps The total number of steps in the Stepper. .EXAMPLE Show-StepperProgress -State $state -TotalSteps 5 Displays progress for a 5-step Stepper. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$State, [Parameter(Mandatory = $true)] [int]$TotalSteps ) $completed = $State.CompletedSteps.Count $percentComplete = if ($TotalSteps -gt 0) { [math]::Round(($completed / $TotalSteps) * 100, 1) } else { 0 } Write-Host "`nProgress Summary:" -ForegroundColor Cyan Write-Host (" " + ("-" * 50)) -ForegroundColor Gray Write-Host " Stepper ID : $($State.StepperId)" -ForegroundColor Gray Write-Host " Started At : $($State.StartedAt)" -ForegroundColor Gray Write-Host " Last Updated : $($State.LastUpdated)" -ForegroundColor Gray Write-Host " Status : $($State.Status)" -ForegroundColor $( if ($State.Status -eq 'Completed') { 'Green' } elseif ($State.Status -eq 'Failed') { 'Red' } else { 'Yellow' } ) Write-Host " Completed Steps : $completed / $TotalSteps ($percentComplete%)" -ForegroundColor Gray Write-Host (" " + ("-" * 50)) -ForegroundColor Gray if ($State.CompletedSteps.Count -gt 0) { Write-Host "`nCompleted Steps:" -ForegroundColor Green $State.CompletedSteps | ForEach-Object { $stepResult = $State.StepResults[$_] $duration = if ($stepResult.Duration) { " ($($stepResult.Duration))" } else { "" } Write-Host " ✓ $_$duration" -ForegroundColor Green } } Write-Host "" } function Show-StepperStepHeader { <# .SYNOPSIS Displays a header for the current Stepper step. .DESCRIPTION Shows a formatted header indicating the current step number, total steps, and step name. .PARAMETER StepName The name of the current step. .PARAMETER StepNumber The current step number (1-based). .PARAMETER TotalSteps The total number of steps in the Stepper. .EXAMPLE Show-StepperStepHeader -StepName "Environment Check" -StepNumber 1 -TotalSteps 5 Displays header for step 1 of 5. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$StepName, [Parameter(Mandatory = $true)] [int]$StepNumber, [Parameter(Mandatory = $true)] [int]$TotalSteps ) $border = "-" * 70 Write-Host "`n$border" -ForegroundColor Cyan Write-Host "Step $StepNumber of ${TotalSteps}: $StepName" -ForegroundColor Cyan Write-Host "$border" -ForegroundColor Cyan } function Test-StepperStateValidity { <# .SYNOPSIS Validates if the saved state is still relevant. .DESCRIPTION Checks the Stepper state for validity by verifying: - Version compatibility - Age of the state (default max 7 days) - Valid timestamp format .PARAMETER State The Stepper state hashtable to validate. .PARAMETER MaxAgeDays Maximum age in days before state is considered stale. Default is 7 days. .OUTPUTS System.Boolean - $true if state is valid, $false otherwise. .EXAMPLE if (Test-StepperStateValidity -State $state) { # State is valid, proceed } Validates state with default 7-day age limit. .EXAMPLE if (Test-StepperStateValidity -State $state -MaxAgeDays 30) { # State is valid, proceed } Validates state with custom 30-day age limit. .NOTES Displays warnings if state is invalid. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [hashtable]$State, [Parameter(Mandatory = $false)] [int]$MaxAgeDays = 7 ) # Check version compatibility if ($State.Version -ne '1.0.0') { Write-Warning "State file version mismatch. Expected 1.0.0, found $($State.Version)" return $false } # Check age if ($State.LastUpdated) { try { $lastUpdate = [DateTime]::Parse($State.LastUpdated) $age = (Get-Date) - $lastUpdate if ($age.TotalDays -gt $MaxAgeDays) { Write-Warning "State is $([math]::Round($age.TotalDays, 1)) days old (max: $MaxAgeDays days)" return $false } Write-Verbose "State age: $([math]::Round($age.TotalDays, 1)) days (valid)" } catch { Write-Warning "Invalid LastUpdated timestamp in state: $($State.LastUpdated)" return $false } } else { Write-Warning "State missing LastUpdated timestamp" return $false } Write-Verbose "State validation passed" return $true } function Get-StepperSteps { <# .SYNOPSIS Loads Stepper steps from JSON configuration. .DESCRIPTION Reads the stepper-config.json file and dynamically loads step scripts from the configured paths. Returns an array of PSCustomObjects with Name, Description, ScriptBlock, and AcceptsAllResults properties. Steps are loaded from individual .ps1 files in the Steps directory. Only enabled steps are included in the returned array. .PARAMETER ConfigPath Path to the configuration JSON file. If not specified, attempts to find a JSON file with the same base name as the calling script (e.g., MyScript.ps1 -> MyScript.json). Falls back to stepper-config.json in the module root if no match is found. .OUTPUTS PSCustomObject[] - Array of step definition objects with Name, Description, ScriptBlock, and AcceptsAllResults properties. .EXAMPLE $steps = Get-StepperSteps Automatically finds a JSON config with the same name as the calling script. .EXAMPLE $steps = Get-StepperSteps -ConfigPath "C:\MyProject\custom-config.json" Uses a specific configuration file. .NOTES Configuration file structure: { "StepperSteps": [ { "name": "StepName", "description": "Step description", "scriptPath": "Steps/Step-ScriptName.ps1", "enabled": true, "order": 1, "acceptsAllResults": false } ] } To add new steps: 1. Create a new .ps1 file in the Steps directory 2. Add an entry to stepper-config.json 3. Set "enabled": true and assign an order number To disable a step without deleting it: - Set "enabled": false in the JSON configuration #> [CmdletBinding()] [OutputType([array])] param( [Parameter(Mandatory = $false)] [string]$ConfigPath ) Write-Verbose "Loading Stepper step configuration" # Default config path logic if (-not $ConfigPath) { # Try to find a JSON config with the same name as the calling script # Walk the call stack to find the user's script (not a .psm1 module file) $callStack = Get-PSCallStack $userScript = $null foreach ($caller in $callStack | Select-Object -Skip 1) { $scriptPath = $caller.ScriptName if ($scriptPath -and (Test-Path $scriptPath) -and $scriptPath -notlike '*.psm1') { $userScript = $scriptPath break } } if ($userScript) { $scriptDirectory = Split-Path -Parent $userScript $scriptBaseName = [System.IO.Path]::GetFileNameWithoutExtension($userScript) $matchingConfig = Join-Path -Path $scriptDirectory -ChildPath "$scriptBaseName.json" if (Test-Path -Path $matchingConfig) { $ConfigPath = $matchingConfig Write-Verbose "Auto-discovered config: $ConfigPath" } else { Write-Verbose "No matching config found at: $matchingConfig" } } # Fall back to module root config if (-not $ConfigPath) { $moduleRoot = Split-Path -Parent $PSScriptRoot $ConfigPath = Join-Path -Path $moduleRoot -ChildPath 'stepper-config.json' Write-Verbose "Using default module config: $ConfigPath" } } # Validate config file exists if (-not (Test-Path -Path $ConfigPath)) { throw "Configuration file not found: $ConfigPath" } Write-Verbose "Reading configuration from: $ConfigPath" # Load and parse JSON configuration try { $configContent = Get-Content -Path $ConfigPath -Raw -ErrorAction Stop $config = $configContent | ConvertFrom-Json -ErrorAction Stop } catch { throw "Failed to parse configuration file: $_" } # Validate configuration structure if (-not $config.StepperSteps) { throw "Invalid configuration: 'StepperSteps' property not found" } Write-Verbose "Found $($config.StepperSteps.Count) step(s) in configuration" # Build step definitions $steps = @() # Sort by order and filter to enabled only $enabledSteps = $config.StepperSteps | Where-Object { $_.enabled -eq $true } | Sort-Object -Property order Write-Verbose "Processing $($enabledSteps.Count) enabled step(s)" foreach ($stepConfig in $enabledSteps) { Write-Verbose "Loading step: $($stepConfig.name)" # Resolve script path # If path is absolute, use it as-is # If path is relative, resolve it relative to the config file's directory if ([System.IO.Path]::IsPathRooted($stepConfig.scriptPath)) { $scriptPath = $stepConfig.scriptPath } else { $configDirectory = Split-Path -Parent $ConfigPath $scriptPath = Join-Path -Path $configDirectory -ChildPath $stepConfig.scriptPath } # Validate script file exists if (-not (Test-Path -Path $scriptPath)) { Write-Warning "Step script not found: $scriptPath (skipping step '$($stepConfig.name)')" continue } Write-Verbose " Script path: $scriptPath" Write-Verbose " Accepts AllResults: $($stepConfig.acceptsAllResults)" # Load script content try { $scriptContent = Get-Content -Path $scriptPath -Raw -ErrorAction Stop # Create scriptblock $scriptBlock = [ScriptBlock]::Create($scriptContent) # Build step definition as PSCustomObject $stepDefinition = [PSCustomObject]@{ Name = $stepConfig.name Description = $stepConfig.description ScriptBlock = $scriptBlock AcceptsAllResults = $stepConfig.acceptsAllResults } $steps += $stepDefinition Write-Verbose " ✓ Successfully loaded step: $($stepConfig.name)" } catch { Write-Warning "Failed to load step '$($stepConfig.name)': $_" continue } } Write-Verbose "Successfully loaded $($steps.Count) step(s)" if ($steps.Count -eq 0) { Write-Warning "No enabled steps were loaded from configuration" } return $steps } function New-StepperConfig { <# .SYNOPSIS Creates a new JSON configuration file for a Stepper. .DESCRIPTION Generates a JSON configuration file with step definitions. Creates the step script files by default. .PARAMETER Name Name of the configuration (without .json extension). .PARAMETER Path Directory where the JSON file will be created. Defaults to current directory. .PARAMETER StepNames Array of step names to include in the configuration. .PARAMETER SkipStepFiles If specified, does not create the step script files. .PARAMETER Force Overwrite existing files if they exist. .EXAMPLE New-StepperConfig -Name "My-Script" -StepNames "Step1", "Step2" Creates My-Script.json with two step definitions and creates the step files. .EXAMPLE New-StepperConfig -Name "Health-Check" -StepNames "DiskSpace", "Services" -SkipStepFiles Creates config only, without step files. .OUTPUTS System.IO.FileInfo - The created JSON file. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory = $false)] [string]$Path = (Get-Location).Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string[]]$StepNames, [Parameter(Mandatory = $false)] [switch]$SkipStepFiles, [Parameter(Mandatory = $false)] [switch]$Force ) begin { Write-Verbose "Starting New-StepperConfig for '$Name'" # Ensure name doesn't have .json extension if ($Name -like '*.json') { $Name = [System.IO.Path]::GetFileNameWithoutExtension($Name) } # Validate path and offer to create if it doesn't exist if (-not (Test-Path $Path)) { $title = "Directory Does Not Exist" $message = "Path does not exist: $Path`nCreate directory?" $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Create the directory" $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Cancel operation" $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no) $choice = $host.UI.PromptForChoice($title, $message, $options, 0) if ($choice -eq 0) { Write-Verbose "Creating directory: $Path" New-Item -Path $Path -ItemType Directory -Force | Out-Null } else { throw "Path does not exist and was not created: $Path" } } $configPath = Join-Path -Path $Path -ChildPath "$Name.json" $stepsDir = Join-Path -Path $Path -ChildPath "Steps" } process { try { # Check if file exists if ((Test-Path $configPath) -and -not $Force) { throw "Config file already exists: $configPath. Use -Force to overwrite." } if ($PSCmdlet.ShouldProcess($configPath, "Create Stepper configuration")) { # Build step definitions $steps = @() $order = 1 foreach ($stepName in $StepNames) { $steps += @{ name = $stepName description = "Description for $stepName" scriptPath = "Steps/Step-$stepName.ps1" enabled = $true order = $order acceptsAllResults = $false } $order++ } # Create config object $config = @{ StepperSteps = $steps } # Convert to JSON and save $json = $config | ConvertTo-Json -Depth 10 $json | Set-Content -Path $configPath -Encoding UTF8 -Force Write-Host "Created config: $configPath" -ForegroundColor Green # Create step files by default (unless -SkipStepFiles is specified) if (-not $SkipStepFiles) { # Ensure Steps directory exists if (-not (Test-Path $stepsDir)) { Write-Verbose "Creating Steps directory: $stepsDir" New-Item -Path $stepsDir -ItemType Directory -Force | Out-Null } Write-Verbose "Creating step files..." foreach ($stepName in $StepNames) { if ($PSCmdlet.ShouldProcess("Step-$stepName.ps1", "Create step script")) { try { New-StepperStep -Name $stepName -Path $stepsDir -Force:$Force | Out-Null Write-Host " Created step: Step-$stepName.ps1" -ForegroundColor Gray } catch { Write-Warning "Failed to create step file for '$stepName': $_" } } } } # Return the config file Get-Item $configPath } } catch { Write-Error "Failed to create Stepper config: $_" throw } } end { Write-Verbose "Completed New-StepperConfig" } } function New-StepperScript { <# .SYNOPSIS Creates a new Stepper script with configuration and step files. .DESCRIPTION Scaffolds a complete Stepper script structure including: - Main script file - JSON configuration file - Steps directory - Optional example step files .PARAMETER Name Name of the Stepper script (e.g., "My-HealthCheck"). The .ps1 extension will be added automatically. .PARAMETER Path Directory where the Stepper script will be created. Defaults to current directory. .PARAMETER StepNames Array of step names to create as examples. If not specified, creates one example step. .PARAMETER Force Overwrite existing files if they exist. .EXAMPLE New-StepperScript -Name "My-HealthCheck" Creates a new Stepper script in the current directory with one example step. .EXAMPLE New-StepperScript -Name "Security-Assessment" -Path "C:\Scripts" -StepNames "Scan", "Analyze", "Report" Creates a security assessment Stepper with three steps. .OUTPUTS System.IO.FileInfo - The created script file. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory = $false)] [string]$Path = (Get-Location).Path, [Parameter(Mandatory = $false)] [string[]]$StepNames = @('ExampleStep'), [Parameter(Mandatory = $false)] [switch]$Force ) begin { Write-Verbose "Starting New-StepperScript for '$Name'" # Ensure name doesn't have .ps1 extension if ($Name -like '*.ps1') { $Name = [System.IO.Path]::GetFileNameWithoutExtension($Name) } # Validate path and offer to create if it doesn't exist if (-not (Test-Path $Path)) { $title = "Directory Does Not Exist" $message = "Path does not exist: $Path`nCreate directory?" $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Create the directory" $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Cancel operation" $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no) $choice = $host.UI.PromptForChoice($title, $message, $options, 0) if ($choice -eq 0) { Write-Verbose "Creating directory: $Path" New-Item -Path $Path -ItemType Directory -Force | Out-Null } else { throw "Path does not exist and was not created: $Path" } } $scriptPath = Join-Path -Path $Path -ChildPath "$Name.ps1" $configPath = Join-Path -Path $Path -ChildPath "$Name.json" $stepsDir = Join-Path -Path $Path -ChildPath "Steps" } process { try { # Check if files exist if ((Test-Path $scriptPath) -and -not $Force) { throw "Script file already exists: $scriptPath. Use -Force to overwrite." } if ((Test-Path $configPath) -and -not $Force) { throw "Config file already exists: $configPath. Use -Force to overwrite." } if ($PSCmdlet.ShouldProcess($scriptPath, "Create Stepper script")) { # Create Steps directory if (-not (Test-Path $stepsDir)) { New-Item -Path $stepsDir -ItemType Directory -Force | Out-Null Write-Verbose "Created Steps directory: $stepsDir" } # Create main script $scriptContent = @" <# .SYNOPSIS $Name Stepper script. .DESCRIPTION Multi-step Stepper script using the Stepper module. State is automatically saved and can be resumed if interrupted. .PARAMETER Fresh Start fresh, ignoring any saved state. .PARAMETER ShowStatus Display current progress without running steps. .PARAMETER Reset Reset all saved progress. .EXAMPLE .\$Name.ps1 Run the Stepper (resumes automatically if interrupted). .EXAMPLE .\$Name.ps1 -Fresh Start fresh, ignoring any saved state. .NOTES Requires: Stepper module Configuration: $Name.json #> [CmdletBinding()] param( [Parameter(Mandatory = `$false)] [switch]`$Fresh, [Parameter(Mandatory = `$false)] [switch]`$ShowStatus, [Parameter(Mandatory = `$false)] [switch]`$Reset ) # Import Stepper module if (-not (Get-Module -Name Stepper -ListAvailable)) { Write-Error "Stepper module is not installed. Install it with: Install-Module Stepper" return } Import-Module Stepper -Force # Handle ShowStatus if (`$ShowStatus) { Show-StepperStatus return } # Handle Reset if (`$Reset) { Reset-StepperState return } # Note: Get-StepperSteps will automatically find $Name.json # in the same directory as this script # Run the Stepper using Stepper module try { if (`$Fresh) { Start-Stepper -Fresh } else { Start-Stepper } # Start-Stepper displays its own completion message when fully complete # No need to display additional message here } catch { Write-Error "Stepper failed: `$_" return } "@ $scriptContent | Set-Content -Path $scriptPath -Encoding UTF8 -Force Write-Host "Created script: $scriptPath" -ForegroundColor Green # Create JSON config using New-StepperConfig New-StepperConfig -Name $Name -Path $Path -StepNames $StepNames -Force:$Force | Out-Null # Return the script file Get-Item $scriptPath } } catch { Write-Error "Failed to create Stepper script: $_" throw } } end { Write-Verbose "Completed New-StepperScript" } } function New-StepperStep { <# .SYNOPSIS Creates a new step script file from a template. .DESCRIPTION Generates a new step script file with proper comment-based help and basic structure. The file is ready to be customized with your specific step logic. .PARAMETER Name Name of the step (e.g., "DiskSpace"). The "Step-" prefix will be added automatically if not present. .PARAMETER Path Directory where the step file will be created. Defaults to "Steps" subdirectory in current location. .PARAMETER AcceptsAllResults If specified, adds the $AllResults parameter to the step script. .PARAMETER Force Overwrite existing file if it exists. .EXAMPLE New-StepperStep -Name "DiskSpace" Creates Steps/Step-DiskSpace.ps1 with basic template. .EXAMPLE New-StepperStep -Name "GenerateReport" -AcceptsAllResults Creates a step that accepts results from previous steps. .EXAMPLE New-StepperStep -Name "CustomCheck" -Path "C:\MySteps" Creates step file in custom directory. .OUTPUTS System.IO.FileInfo - The created step file. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory = $false)] [string]$Path, [Parameter(Mandatory = $false)] [switch]$AcceptsAllResults, [Parameter(Mandatory = $false)] [switch]$Force ) begin { Write-Verbose "Starting New-StepperStep for '$Name'" # Ensure name has Step- prefix if ($Name -notlike 'Step-*') { $Name = "Step-$Name" } # Default path to Steps subdirectory if (-not $Path) { $Path = Join-Path -Path (Get-Location).Path -ChildPath "Steps" } # Create directory if it doesn't exist if (-not (Test-Path $Path)) { New-Item -Path $Path -ItemType Directory -Force | Out-Null Write-Verbose "Created directory: $Path" } $stepPath = Join-Path -Path $Path -ChildPath "$Name.ps1" } process { try { # Check if file exists if ((Test-Path $stepPath) -and -not $Force) { throw "Step file already exists: $stepPath. Use -Force to overwrite." } if ($PSCmdlet.ShouldProcess($stepPath, "Create step file")) { # Build step content based on parameters $paramBlock = if ($AcceptsAllResults) { @" param( [Parameter(Mandatory = `$false)] [hashtable]`$AllResults ) "@ } else { @" param() "@ } $allResultsExample = if ($AcceptsAllResults) { @" # Access results from previous steps # `$previousStep = `$AllResults['PreviousStepName'] # `$data = `$previousStep.SomeProperty "@ } else { "" } $stepContent = @" # $Name <# .SYNOPSIS Brief description of what this step does. .DESCRIPTION Detailed description of the step's purpose and what it checks or performs. $(if ($AcceptsAllResults) { " .PARAMETER AllResults Hashtable containing results from all previously completed steps. "}) .OUTPUTS Returns a hashtable with the step's results. These results can be accessed by subsequent steps if they have acceptsAllResults: true. .NOTES This step is part of a Stepper workflow. It will be executed in the order specified in the JSON configuration. #> $paramBlock Write-Host "`n--- $($Name.Replace('Step-', '')) ---" -ForegroundColor Yellow try {$allResultsExample # Your step logic here Write-Host " Running $($Name.Replace('Step-', ''))..." -ForegroundColor Gray # Example: Perform your checks, gather data, etc. `$result = @{ Status = "Success" Details = "Step completed successfully" # Add your custom properties here } Write-Host " ✓ Step completed" -ForegroundColor Green # Return results for potential use by other steps return `$result } catch { Write-Error "Failed to execute $Name`: `$_" return `$false } "@ $stepContent | Set-Content -Path $stepPath -Encoding UTF8 -Force Write-Host "Created step: $stepPath" -ForegroundColor Green # Return the step file Get-Item $stepPath } } catch { Write-Error "Failed to create step file: $_" throw } } end { Write-Verbose "Completed New-StepperStep" } } function Reset-StepperState { <# .SYNOPSIS Clears all saved Stepper state. .DESCRIPTION Removes the saved state file, effectively resetting the Stepper to allow starting fresh. Prompts for confirmation before removing. Supports -WhatIf and -Confirm parameters. .EXAMPLE Reset-StepperState Prompts for confirmation then clears the state. .EXAMPLE Reset-StepperState -Confirm:$false Clears the state without prompting. .EXAMPLE Reset-StepperState -WhatIf Shows what would happen without actually clearing state. .NOTES This action cannot be undone. All Stepper progress will be lost. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param() Show-StepperHeader $statePath = Get-StateFilePath if (Test-Path $statePath) { if ($PSCmdlet.ShouldProcess($statePath, "Remove Stepper state file and reset all progress")) { Clear-StepperState -Confirm:$false Write-Host "`nStepper state has been reset." -ForegroundColor Green Write-Host "Run 'Start-Stepper' to begin a new Stepper." -ForegroundColor Cyan } } else { Write-Host "No saved Stepper state found." -ForegroundColor Yellow Write-Host "Nothing to reset." -ForegroundColor Gray } } function Show-StepperStatus { <# .SYNOPSIS Displays the current Stepper progress without running any steps. .DESCRIPTION Shows the progress summary of the current or last Stepper including completed steps, current status, and overall progress percentage. Useful for checking on Stepper status without starting or resuming. .EXAMPLE Show-StepperStatus Displays current Stepper progress. .NOTES If no Stepper state exists, indicates that no Stepper has been started. #> [CmdletBinding()] param() Show-StepperHeader $state = Get-StepperState $steps = Get-StepperSteps if ($state.CompletedSteps.Count -eq 0 -and $state.Status -eq 'InProgress' -and [DateTime]::Parse($state.StartedAt) -gt (Get-Date).AddMinutes(-1)) { # This is a brand new state that was just created Write-Host "No Stepper progress found." -ForegroundColor Yellow Write-Host "Run 'Start-Stepper' to begin a new Stepper." -ForegroundColor Gray } else { Show-StepperProgress -State $state -TotalSteps $steps.Count if ($state.Status -eq 'InProgress') { Write-Host "Run 'Start-Stepper -Resume' to continue the Stepper." -ForegroundColor Cyan } elseif ($state.Status -eq 'Failed') { Write-Host "Run 'Start-Stepper -Resume' to retry from the failed step." -ForegroundColor Yellow } else { Write-Host "Stepper is complete. Run 'Reset-StepperState' to start fresh." -ForegroundColor Green } } } function Start-Stepper { <# .SYNOPSIS Main function to run the multi-step Stepper. .DESCRIPTION Orchestrates the execution of all Stepper steps, manages state persistence, handles errors, and provides progress feedback. By default, resumes from the last saved state. Use -Fresh to start over. .PARAMETER Fresh Start a completely new Stepper, ignoring any saved state. .PARAMETER ConfigPath Path to the Stepper configuration JSON file. If not specified, attempts to find a JSON file with the same base name as the calling script (e.g., MyScript.ps1 -> MyScript.json). Falls back to stepper-config.json in the module root if no match is found. .PARAMETER InitialData Optional data to pass to all steps as AllData[0]. This allows passing context or parameters from the main script to all step scripts. .EXAMPLE Start-Stepper Automatically finds a JSON config with the same name as the calling script. Resumes from the last checkpoint (default behavior). .EXAMPLE Start-Stepper -Fresh Starts a completely new Stepper from the beginning. .EXAMPLE Start-Stepper -ConfigPath ".\my-steps.json" Uses a custom configuration file. .NOTES State is automatically saved after each step. The Stepper can be safely interrupted and resumed later. Completed steps can be skipped or re-run interactively. #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [switch]$Fresh, [Parameter(Mandatory = $false)] [string]$ConfigPath, [Parameter(Mandatory = $false)] [object]$InitialData ) Show-StepperHeader # Get all Stepper steps if ($ConfigPath) { $steps = Get-StepperSteps -ConfigPath $ConfigPath } else { $steps = Get-StepperSteps } $totalSteps = $steps.Count Write-Verbose "Total steps defined: $totalSteps" # Load or create state (default is to resume) if ($Fresh) { # User explicitly wants to start fresh Write-Host "Starting fresh Stepper (ignoring saved state)..." -ForegroundColor Cyan $state = New-StepperState } else { # Default behavior: try to resume $existingState = Get-StepperState if ($existingState.Status -eq 'Completed') { # Previous session completed - automatically start new one Write-Host "Starting new Stepper session..." -ForegroundColor Cyan $state = New-StepperState } elseif (-not (Test-StepperStateValidity -State $existingState)) { Write-Host "Saved state is invalid or too old." -ForegroundColor Yellow $title = "Invalid State" $message = "Do you want to start fresh?" $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Clear state and start over" $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Use existing state anyway" $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no) $choice = $host.UI.PromptForChoice($title, $message, $options, 0) if ($choice -eq 0) { Clear-StepperState -Confirm:$false $state = New-StepperState } else { Write-Host "Using existing state anyway..." -ForegroundColor Yellow $state = $existingState } } else { # Valid in-progress state found Write-Host "Found existing Stepper session in progress." -ForegroundColor Yellow $title = "Resume or Start Fresh" $message = "Do you want to resume from where you left off?" $resumeChoice = New-Object System.Management.Automation.Host.ChoiceDescription "&Resume", "Continue from the last completed step" $freshChoice = New-Object System.Management.Automation.Host.ChoiceDescription "&Fresh", "Start over from the beginning" $cancelChoice = New-Object System.Management.Automation.Host.ChoiceDescription "&Cancel", "Exit without running" $options = [System.Management.Automation.Host.ChoiceDescription[]]($resumeChoice, $freshChoice, $cancelChoice) $choice = $host.UI.PromptForChoice($title, $message, $options, 0) if ($choice -eq 0) { Write-Host "Resuming from saved state..." -ForegroundColor Cyan $state = $existingState } elseif ($choice -eq 1) { Write-Host "Starting fresh..." -ForegroundColor Cyan Clear-StepperState -Confirm:$false $state = New-StepperState } else { Write-Host "Exiting..." -ForegroundColor Gray return } } } # Show current progress Show-StepperProgress -State $state -TotalSteps $totalSteps # Determine starting point $startIndex = $state.CurrentStepIndex if ($startIndex -ge $totalSteps) { $startIndex = 0 } Write-Verbose "Starting from step index: $startIndex" # Execute steps for ($i = $startIndex; $i -lt $totalSteps; $i++) { $step = $steps[$i] Show-StepperStepHeader -StepName $step.Name -StepNumber ($i + 1) -TotalSteps $totalSteps Write-Host " $($step.Description)" -ForegroundColor Gray Write-Host "" # Check if already completed if ($state.CompletedSteps -contains $step.Name) { Write-Host " This step was already completed." -ForegroundColor Yellow $title = "Step Already Completed" $message = "Do you want to skip this step?" $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Skip this step and continue" $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Re-run this step" $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no) $choice = $host.UI.PromptForChoice($title, $message, $options, 0) if ($choice -eq 0) { Write-Host " Skipping..." -ForegroundColor Gray continue } else { Write-Host " Re-running..." -ForegroundColor Gray } } # Prepare all results for steps that need them # Array index matches step number: AllData[1] = Step 1 result, etc. # AllData[0] is reserved for initial input data (future feature) $allData = @($InitialData) for ($j = 0; $j -lt $i; $j++) { $previousStep = $steps[$j] if ($state.CompletedSteps -contains $previousStep.Name -and $state.StepResults.ContainsKey($previousStep.Name)) { $allData += $state.StepResults[$previousStep.Name].Result } else { $allData += $null } } # Execute the step $success = Invoke-StepperStep -Step $step -State $state -AllData $allData # Update current step index $state.CurrentStepIndex = $i + 1 # Save state after each step Save-StepperState -State $state if (-not $success) { $state.Status = 'Failed' Save-StepperState -State $state Write-Host "`nStepper stopped due to error." -ForegroundColor Red Write-Host "State saved. You can resume later with the -Resume switch." -ForegroundColor Yellow Write-Host "State file location: $(Get-StateFilePath)" -ForegroundColor Gray return } # Prompt to continue after each step (except last) if ($i -lt ($totalSteps - 1)) { Write-Host "" $title = "Continue to Next Step" $message = "Ready to continue?" $continue = New-Object System.Management.Automation.Host.ChoiceDescription "&Continue", "Continue to the next step" $stop = New-Object System.Management.Automation.Host.ChoiceDescription "&Stop", "Stop and save progress (Ctrl+C also works)" $options = [System.Management.Automation.Host.ChoiceDescription[]]($continue, $stop) $choice = $host.UI.PromptForChoice($title, $message, $options, 0) if ($choice -eq 1) { Write-Host "`nStopping... Progress has been saved." -ForegroundColor Yellow return } } } # Mark as completed $state.Status = 'Completed' $state.CompletedAt = Get-Date -Format 'o' Save-StepperState -State $state # Show final summary Write-Host "" Write-Host ("=" * 70) -ForegroundColor Green Write-Host " Stepper Complete!" -ForegroundColor Green Write-Host ("=" * 70) -ForegroundColor Green Show-StepperProgress -State $state -TotalSteps $totalSteps $totalDuration = ([DateTime]::Parse($state.CompletedAt)) - ([DateTime]::Parse($state.StartedAt)) Write-Host "Total Duration: $([math]::Round($totalDuration.TotalMinutes, 2)) minutes`n" -ForegroundColor Gray } # Export functions and aliases as required Export-ModuleMember -Function @('Get-StepperSteps','New-StepperConfig','New-StepperScript','New-StepperStep','Reset-StepperState','Show-StepperStatus','Start-Stepper') -Alias @() |