Stepper.psm1
|
function Find-NewStepBlocks { <# .SYNOPSIS Finds all New-Step blocks and Stop-Stepper line in a script. .PARAMETER ScriptLines Array of script lines to analyze. .OUTPUTS Hashtable with NewStepBlocks array and StopStepperLine. #> [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$ScriptLines ) $newStepBlocks = @() $stopStepperLine = -1 for ($i = 0; $i -lt $ScriptLines.Count; $i++) { if ($ScriptLines[$i] -match '^\s*New-Step\s+\{') { # Find the closing brace for this New-Step block $braceCount = 0 $blockStart = $i $blockEnd = -1 for ($j = $i; $j -lt $ScriptLines.Count; $j++) { $line = $ScriptLines[$j] $braceCount += ($line.ToCharArray() | Where-Object { $_ -eq '{' }).Count $braceCount -= ($line.ToCharArray() | Where-Object { $_ -eq '}' }).Count if ($braceCount -eq 0 -and $j -gt $i) { $blockEnd = $j break } } if ($blockEnd -ge 0) { $newStepBlocks += @{ Start = $blockStart End = $blockEnd } } } if ($ScriptLines[$i] -match '^\s*Stop-Stepper') { $stopStepperLine = $i break } } return @{ NewStepBlocks = $newStepBlocks StopStepperLine = $stopStepperLine } } function Find-NonResumableCodeBlocks { <# .SYNOPSIS Identifies non-resumable code blocks between New-Step blocks. .PARAMETER ScriptLines Array of script lines to analyze. .PARAMETER NewStepBlocks Array of New-Step block definitions (Start/End). .PARAMETER StopStepperLine Line number where Stop-Stepper is located. .OUTPUTS Array of non-resumable code blocks with Lines and IsBeforeStop properties. #> [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$ScriptLines, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$NewStepBlocks, [Parameter(Mandatory)] [int]$StopStepperLine ) $nonResumableBlocks = @() if ($NewStepBlocks.Count -gt 0) { # Check code BEFORE the first New-Step block $firstBlock = $NewStepBlocks[0] $blockLines = @() for ($j = 0; $j -lt $firstBlock.Start; $j++) { $line = $ScriptLines[$j].Trim() # Skip comments, empty lines, and common non-executable statements if ($line -and $line -notmatch '^\s*#' -and $line -notmatch '^\s*<#' -and $line -notmatch '^\s*#>' -and $line -notmatch '^\s*\.SYNOPSIS' -and $line -notmatch '^\s*\.DESCRIPTION' -and $line -notmatch '^\s*\.NOTES' -and $line -notmatch '^\s*\.EXAMPLE' -and $line -notmatch '^\s*\.PARAMETER' -and $line -notmatch '^\s*\[CmdletBinding\(' -and $line -notmatch '^\s*param\s*\(' -and $line -notmatch '^\s*using\s+(namespace|module|assembly)' -and $line -notmatch '^\s*\)\s*$' -and $line -ne '.') { $blockLines += $j } } if ($blockLines.Count -gt 0) { $nonResumableBlocks += @{ Lines = $blockLines IsBeforeStop = $false } } # Check between consecutive New-Step blocks for ($i = 0; $i -lt $NewStepBlocks.Count - 1; $i++) { $gapStart = $NewStepBlocks[$i].End + 1 $gapEnd = $NewStepBlocks[$i + 1].Start - 1 $blockLines = @() for ($j = $gapStart; $j -le $gapEnd; $j++) { $line = $ScriptLines[$j].Trim() if ($line -and $line -notmatch '^\s*#') { $blockLines += $j } } if ($blockLines.Count -gt 0) { $nonResumableBlocks += @{ Lines = $blockLines IsBeforeStop = $false } } } # Check between last New-Step and Stop-Stepper if ($StopStepperLine -ge 0) { $lastBlock = $NewStepBlocks[$NewStepBlocks.Count - 1] $gapStart = $lastBlock.End + 1 $gapEnd = $StopStepperLine - 1 $blockLines = @() for ($j = $gapStart; $j -le $gapEnd; $j++) { $line = $ScriptLines[$j].Trim() if ($line -and $line -notmatch '^\s*#') { $blockLines += $j } } if ($blockLines.Count -gt 0) { $nonResumableBlocks += @{ Lines = $blockLines IsBeforeStop = $true } } } } return $nonResumableBlocks } function Get-NonResumableCodeAction { <# .SYNOPSIS Prompts user for action on a non-resumable code block. .PARAMETER ScriptName Name of the script file. .PARAMETER ScriptLines Array of script lines. .PARAMETER Block Code block with Lines and IsBeforeStop properties. .OUTPUTS String with chosen action: 'Wrap', 'Move', 'Delete', or 'Ignore'. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ScriptName, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$ScriptLines, [Parameter(Mandatory)] [hashtable]$Block ) $blockLineNums = $Block.Lines | ForEach-Object { $_ + 1 } # Convert to 1-based $blockCode = $Block.Lines | ForEach-Object { $ScriptLines[$_].Trim() } $hasStepperVar = ($blockCode -join ' ') -match '\$Stepper\.' Write-Host "" Write-Warning "Non-resumable code detected in ${ScriptName}:" foreach ($lineNum in $blockLineNums) { $lineContent = $ScriptLines[$lineNum - 1].Trim() Write-Host " ${lineNum}: $lineContent" -ForegroundColor Gray } Write-Host "This code will re-execute on every run, including resumed runs." -ForegroundColor Yellow Write-Host "" if ($hasStepperVar) { Write-Warning "This code references `$Stepper variables!" } Write-Host "How would you like to handle this?" -ForegroundColor Cyan Write-Host " [W] Wrap in New-Step block" -ForegroundColor White if ($Block.IsBeforeStop) { Write-Host " [M] Move after Stop-Stepper" -ForegroundColor White } if ($hasStepperVar) { Write-Host " [D] Delete this code (WARNING: This will delete code that uses `$Stepper variables)" -ForegroundColor White } else { Write-Host " [D] Delete this code" -ForegroundColor White } Write-Host " [I] Ignore and continue (Default)" -ForegroundColor Cyan Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" if ($Block.IsBeforeStop) { Write-Host "Choice [w/m/d/" -NoNewline Write-Host "I" -NoNewline -ForegroundColor Cyan Write-Host "/q]: " -NoNewline } else { Write-Host "Choice [w/d/" -NoNewline Write-Host "I" -NoNewline -ForegroundColor Cyan Write-Host "/q]: " -NoNewline } $choice = Read-Host switch ($choice.ToLower()) { 'w' { return 'Wrap' } 'm' { if ($Block.IsBeforeStop) { return 'Move' } else { return 'Ignore' } } 'd' { return 'Delete' } 'q' { return 'Quit' } default { return 'Ignore' } } } function Get-ScriptHash { <# .SYNOPSIS Calculates SHA256 hash of a script file. .DESCRIPTION Reads the content of a script file and returns its SHA256 hash. Used to detect if the script has been modified since the last run. .PARAMETER ScriptPath The path to the script file. .OUTPUTS System.String - SHA256 hash of the script content #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ScriptPath ) try { $content = Get-Content -Path $ScriptPath -Raw -ErrorAction Stop $bytes = [System.Text.Encoding]::UTF8.GetBytes($content) $hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) [System.BitConverter]::ToString($hash).Replace('-', '') } catch { throw "Failed to calculate hash for script '$ScriptPath': $_" } } function Get-StepIdentifier { <# .SYNOPSIS Gets a unique identifier for the current step based on caller location. .DESCRIPTION Analyzes the call stack to find the script and line number where New-Step was called. Returns an identifier in the format "filepath:line". .OUTPUTS System.String - Step identifier (e.g., "C:\script.ps1:42") #> [CmdletBinding()] param() $callStack = Get-PSCallStack # Walk up the call stack to find the first non-module caller # Stack typically looks like: [0]=Get-StepIdentifier, [1]=New-Step, [2]=UserScript for ($i = 0; $i -lt $callStack.Count; $i++) { $frame = $callStack[$i] $scriptName = $frame.ScriptName # Skip frames without a script name if (-not $scriptName) { continue } # Skip frames from the Stepper module directory $stepperDir = Split-Path -Path $PSScriptRoot -Parent # Normalize paths for cross-platform comparison $normalizedScript = $scriptName -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar $normalizedStepperDir = $stepperDir -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar if ($normalizedScript -like "$normalizedStepperDir$([System.IO.Path]::DirectorySeparatorChar)Private$([System.IO.Path]::DirectorySeparatorChar)*" -or $normalizedScript -like "$normalizedStepperDir$([System.IO.Path]::DirectorySeparatorChar)Public$([System.IO.Path]::DirectorySeparatorChar)*" -or $normalizedScript -like "$normalizedStepperDir$([System.IO.Path]::DirectorySeparatorChar)Stepper.psm1") { continue } # This is the user's script - return its location $line = $frame.ScriptLineNumber return "${scriptName}:${line}" } throw "Unable to determine step identifier from call stack" } function Get-StepperStatePath { <# .SYNOPSIS Gets the path to the Stepper state file for the calling script. .DESCRIPTION Generates a state file path based on the calling script's location. State files are stored in the same directory as the script with a .stepper extension. .PARAMETER ScriptPath The path to the script file. .OUTPUTS System.String - Path to the state file #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ScriptPath ) $scriptDir = Split-Path -Path $ScriptPath -Parent $scriptName = Split-Path -Path $ScriptPath -Leaf $stateFileName = "$scriptName.stepper" Join-Path -Path $scriptDir -ChildPath $stateFileName } function Read-StepperState { <# .SYNOPSIS Reads the Stepper state file. .DESCRIPTION Reads and deserializes the state file if it exists. Returns null if the file doesn't exist or can't be read. .PARAMETER StatePath The path to the state file. .OUTPUTS PSCustomObject or $null - The state object containing ScriptHash, LastCompletedStep, Timestamp, and StepperData #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$StatePath ) if (-not (Test-Path -Path $StatePath)) { return $null } try { Import-Clixml -Path $StatePath -ErrorAction Stop } catch { Write-Warning "Failed to read state file '$StatePath': $_" return $null } } function Remove-StepperState { <# .SYNOPSIS Removes the Stepper state file. .DESCRIPTION Deletes the state file if it exists. Used when starting fresh or when the script completes successfully. .PARAMETER StatePath The path to the state file. .OUTPUTS None #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$StatePath ) if (Test-Path -Path $StatePath) { try { Remove-Item -Path $StatePath -Force -ErrorAction Stop } catch { Write-Warning "Failed to remove state file '$StatePath': $_" } } } function Test-StepperScriptRequirements { <# .SYNOPSIS Checks if script has required declarations and offers to add them. .PARAMETER ScriptPath Path to the script to check. .OUTPUTS $true if script was modified and needs to be re-run, $false otherwise. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ScriptPath ) $scriptLines = Get-Content -Path $ScriptPath $scriptName = Split-Path $ScriptPath -Leaf # Check for CmdletBinding $hasCmdletBinding = $scriptLines | Where-Object { $_ -match '^\s*\[CmdletBinding\(\)\]' } # Check for #requires statement (case-insensitive) $hasRequires = $scriptLines | Where-Object { $_ -match '(?i)^\s*#requires\s+-Modules?\s+Stepper' } $needsChanges = -not $hasCmdletBinding -or -not $hasRequires if ($needsChanges) { Write-Host "" Write-Warning "Script requirements check for ${scriptName}:" if (-not $hasCmdletBinding) { Write-Host " Missing [CmdletBinding()] declaration" -ForegroundColor Gray } if (-not $hasRequires) { Write-Host " Missing #requires -Modules Stepper statement" -ForegroundColor Gray } Write-Host "" Write-Host "How would you like to handle this?" -ForegroundColor Cyan Write-Host " [Y] Add missing declarations (Default)" -ForegroundColor Cyan Write-Host " [N] Skip" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice [" -NoNewline Write-Host "Y" -NoNewline -ForegroundColor Cyan Write-Host "/n/q]: " -NoNewline $response = Read-Host if ($response -eq 'Q' -or $response -eq 'q') { Write-Host "" Write-Host "Exiting..." -ForegroundColor Yellow exit } if ($response -eq '' -or $response -eq 'Y' -or $response -eq 'y') { $newScriptLines = @() $addedDeclarations = $false # Find where to insert (after shebang/comments at top, before first code) $insertIndex = 0 for ($i = 0; $i -lt $scriptLines.Count; $i++) { $line = $scriptLines[$i].Trim() # Skip empty lines, comments (but not #requires), and shebang if ($line -eq '' -or $line -match '^#(?!requires)' -or $line -match '^#!/') { $insertIndex = $i + 1 } else { break } } # Copy lines before insertion point for ($i = 0; $i -lt $insertIndex; $i++) { $newScriptLines += $scriptLines[$i] } # Add missing declarations if (-not $hasRequires) { $newScriptLines += "#requires -Modules Stepper" $addedDeclarations = $true } if (-not $hasCmdletBinding) { $newScriptLines += "[CmdletBinding()]" $newScriptLines += "param()" $addedDeclarations = $true } if ($addedDeclarations) { $newScriptLines += "" } # Copy remaining lines, but skip existing param() if we added one $skipNextParam = (-not $hasCmdletBinding) for ($i = $insertIndex; $i -lt $scriptLines.Count; $i++) { if ($skipNextParam -and $scriptLines[$i] -match '^\s*param\s*\(\s*\)\s*$') { $skipNextParam = $false continue } $newScriptLines += $scriptLines[$i] } # Write back to file $newScriptLines | Set-Content -Path $ScriptPath -Force Write-Host "" Write-Host "Declarations added to $scriptName. Please re-run the script." -ForegroundColor Green return $true } } return $false } function Update-ScriptWithNonResumableActions { <# .SYNOPSIS Applies wrap/move/delete actions to a script. .PARAMETER ScriptPath Path to the script file to update. .PARAMETER ScriptLines Array of script lines. .PARAMETER Actions Hashtable mapping line indices to actions (Wrap/Move/Delete). .PARAMETER NewStepBlocks Array of New-Step block definitions. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ScriptPath, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$ScriptLines, [Parameter(Mandatory)] [hashtable]$Actions, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$NewStepBlocks ) $newScriptLines = @() $linesToMove = @() $lastBlock = $NewStepBlocks[$NewStepBlocks.Count - 1] # Group lines to wrap by consecutive sequences $linesToWrap = @($Actions.Keys | Where-Object { $Actions[$_].Action -eq 'Wrap' } | Sort-Object) $wrapGroups = @() if ($linesToWrap.Count -gt 0) { $currentGroup = @($linesToWrap[0]) for ($i = 1; $i -lt $linesToWrap.Count; $i++) { if ($linesToWrap[$i] -eq $linesToWrap[$i - 1] + 1) { $currentGroup += $linesToWrap[$i] } else { $wrapGroups += , @($currentGroup) $currentGroup = @($linesToWrap[$i]) } } $wrapGroups += , @($currentGroup) } # Separate lines to move foreach ($lineIdx in $Actions.Keys) { if ($Actions[$lineIdx].Action -eq 'Move') { $linesToMove += $lineIdx } } # Process all lines $wrappedLines = @{} foreach ($group in $wrapGroups) { foreach ($idx in $group) { $wrappedLines[$idx] = $true } } for ($i = 0; $i -lt $ScriptLines.Count; $i++) { # Check if this line starts a wrap group $startsWrapGroup = $false $wrapGroup = $null foreach ($group in $wrapGroups) { if ($group[0] -eq $i) { $startsWrapGroup = $true $wrapGroup = $group break } } if ($startsWrapGroup) { # Start the New-Step block $newScriptLines += "New-Step {" foreach ($idx in $wrapGroup) { $newScriptLines += " $($ScriptLines[$idx])" } $newScriptLines += "}" # Skip to after this group $i = $wrapGroup[-1] continue } # Skip lines that are wrapped (but not the start of a group) or moved or deleted if ($wrappedLines.ContainsKey($i) -or ($Actions.ContainsKey($i) -and $Actions[$i].Action -in @('Move', 'Delete'))) { continue } # Copy line as-is $newScriptLines += $ScriptLines[$i] } # Add moved code at the end if ($linesToMove.Count -gt 0) { $newScriptLines += "" foreach ($lineIdx in ($linesToMove | Sort-Object)) { $newScriptLines += $ScriptLines[$lineIdx] } } # Write back to file $newScriptLines | Set-Content -Path $ScriptPath -Force Write-Host "" Write-Host "Changes applied. Please re-run the script." -ForegroundColor Green } function Write-StepperState { <# .SYNOPSIS Writes the Stepper state file. .DESCRIPTION Serializes and writes the state object to disk. .PARAMETER StatePath The path to the state file. .PARAMETER ScriptHash SHA256 hash of the script content. .PARAMETER LastCompletedStep Identifier of the last successfully completed step (format: "filepath:line"). .PARAMETER StepperData The $Stepper hashtable to persist. .OUTPUTS None #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$StatePath, [Parameter(Mandatory)] [string]$ScriptHash, [Parameter(Mandatory)] [string]$LastCompletedStep, [Parameter()] [hashtable]$StepperData ) $state = [PSCustomObject]@{ ScriptHash = $ScriptHash LastCompletedStep = $LastCompletedStep Timestamp = (Get-Date).ToString('o') StepperData = $StepperData } try { Export-Clixml -Path $StatePath -InputObject $state -ErrorAction Stop } catch { Write-Warning "Failed to write state file '$StatePath': $_" } } function New-Step { <# .SYNOPSIS Executes a step in a resumable script. .DESCRIPTION New-Step allows scripts to be resumed from the last successfully completed step. On first execution, it checks for an existing state file and offers to resume. Each step is automatically tracked by its location (file:line) in the script. The script content is hashed to detect modifications. If the script changes between runs, the state is invalidated and execution starts fresh. .PARAMETER ScriptBlock The code to execute for this step. .EXAMPLE New-Step { Write-Host "Downloading files..." Start-Sleep -Seconds 2 } New-Step { Write-Host "Processing data..." Start-Sleep -Seconds 2 } If the script fails during processing, the next run will skip the download step. .NOTES State files are stored alongside the script with a .stepper extension. Call Stop-Stepper at the end of your script to remove the state file upon successful completion. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [scriptblock]$ScriptBlock ) # Inherit verbose preference by walking the call stack $callStack = Get-PSCallStack foreach ($frame in $callStack) { if ($frame.InvocationInfo.BoundParameters.ContainsKey('Verbose') -and $frame.InvocationInfo.BoundParameters['Verbose']) { $VerbosePreference = 'Continue' break } } # Get step identifier and script info $stepId = Get-StepIdentifier # Extract script path from identifier (format: "path:line") $lastColonIndex = $stepId.LastIndexOf(':') $scriptPath = $stepId.Substring(0, $lastColonIndex) $currentHash = Get-ScriptHash -ScriptPath $scriptPath $statePath = Get-StepperStatePath -ScriptPath $scriptPath # Initialize $Stepper hashtable in calling script scope if it doesn't exist $callingScope = $PSCmdlet.SessionState try { $existingStepper = $callingScope.PSVariable.Get('Stepper') if (-not $existingStepper) { $callingScope.PSVariable.Set('Stepper', @{}) Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Initialized `$Stepper hashtable" } } catch { $callingScope.PSVariable.Set('Stepper', @{}) Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Initialized `$Stepper hashtable" } # Initialize module state on first call in this session if (-not $script:StepperSessionState) { # Verify the script contains Stop-Stepper $scriptContent = Get-Content -Path $scriptPath -Raw if ($scriptContent -notmatch 'Stop-Stepper') { Write-Host "Script '$scriptPath' does not call Stop-Stepper." -ForegroundColor Yellow $response = Read-Host "Add 'Stop-Stepper' to the end of the script? (Y/N)" if ($response -eq 'Y' -or $response -eq 'y') { Write-Host "Adding 'Stop-Stepper' to the end of the script..." -ForegroundColor Yellow # Add Stop-Stepper to the end of the script $updatedContent = $scriptContent.TrimEnd() if (-not $updatedContent.EndsWith("`n")) { $updatedContent += "`n" } $updatedContent += "`nStop-Stepper`n" Set-Content -Path $scriptPath -Value $updatedContent -NoNewline Write-Host "Stop-Stepper added. Please run the script again." -ForegroundColor Green # Exit this execution - the script will need to be run again throw "Script modified to include Stop-Stepper. Please run the script again." } else { Write-Warning "Continuing without Stop-Stepper. State file will not be cleaned up automatically." } } $script:StepperSessionState = @{ Initialized = $false RestoreMode = $false TargetStep = $null CurrentScriptPath = $scriptPath CurrentScriptHash = $currentHash StatePath = $statePath } } # First step: Check for existing state and prompt user if (-not $script:StepperSessionState.Initialized) { $script:StepperSessionState.Initialized = $true # Check script requirements $requirementsModified = Test-StepperScriptRequirements -ScriptPath $scriptPath if ($requirementsModified) { exit } # Check for non-resumable code between New-Step blocks and before Stop-Stepper $scriptLines = Get-Content -Path $scriptPath $blockInfo = Find-NewStepBlocks -ScriptLines $scriptLines $newStepBlocks = $blockInfo.NewStepBlocks $stopStepperLine = $blockInfo.StopStepperLine $nonResumableBlocks = Find-NonResumableCodeBlocks -ScriptLines $scriptLines -NewStepBlocks $newStepBlocks -StopStepperLine $stopStepperLine # Process each non-resumable block individually if ($nonResumableBlocks.Count -gt 0) { $scriptName = Split-Path $scriptPath -Leaf $allLinesToRemove = @{} foreach ($block in $nonResumableBlocks) { $action = Get-NonResumableCodeAction -ScriptName $scriptName -ScriptLines $scriptLines -Block $block if ($action -eq 'Quit') { Write-Host "" Write-Host "Exiting..." -ForegroundColor Yellow exit } if ($action -ne 'Ignore') { # Mark these lines with the chosen action foreach ($line in $block.Lines) { $allLinesToRemove[$line] = @{ Action = $action; Code = $scriptLines[$line] } } } } # Apply all the changes if ($allLinesToRemove.Count -gt 0) { Update-ScriptWithNonResumableActions -ScriptPath $scriptPath -ScriptLines $scriptLines -Actions $allLinesToRemove -NewStepBlocks $newStepBlocks exit } } $existingState = Read-StepperState -StatePath $statePath # Try to load persisted $Stepper data from state if ($existingState -and $existingState.StepperData) { try { $callingScope.PSVariable.Set('Stepper', $existingState.StepperData) $variableNames = ($existingState.StepperData.Keys | Sort-Object) -join ', ' Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Loaded `$Stepper data from disk ($variableNames)" } catch { Write-Warning "Failed to load persisted `$Stepper data: $_" } } if ($existingState) { # Check if script has been modified if ($existingState.ScriptHash -ne $currentHash) { Write-Host "Script has been modified since last run. Starting fresh." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath } else { # Count total steps in the script by finding all New-Step calls $scriptContent = Get-Content -Path $scriptPath -Raw $stepMatches = [regex]::Matches($scriptContent, '^\s*New-Step\s+\{', [System.Text.RegularExpressions.RegexOptions]::Multiline) $totalSteps = $stepMatches.Count # Find all step line numbers to determine which step number we're on $stepLines = @() $lineNumber = 1 foreach ($line in (Get-Content -Path $scriptPath)) { if ($line -match '^\s*New-Step\s+\{') { $stepLines += "${scriptPath}:${lineNumber}" } $lineNumber++ } # Find the index of the last completed step $lastStep = $existingState.LastCompletedStep $lastStepIndex = $stepLines.IndexOf($lastStep) $nextStepNumber = $lastStepIndex + 2 # +1 for next step, +1 because index is 0-based $timestamp = [DateTime]::Parse($existingState.Timestamp).ToString('yyyy-MM-dd HH:mm:ss') # Get available variable names from StepperData $availableVars = if ($existingState.StepperData -and $existingState.StepperData.Count -gt 0) { ($existingState.StepperData.Keys | Sort-Object) -join ', ' } else { 'None' } Write-Host "" Write-Host "Incomplete script run detected!" -ForegroundColor Cyan Write-Host "" Write-Host "Total Steps: $totalSteps" -ForegroundColor Cyan Write-Host "Steps Completed: $($lastStepIndex + 1)" -ForegroundColor Cyan Write-Host "Variables: $availableVars" -ForegroundColor Cyan Write-Host "Last Activity: $timestamp" -ForegroundColor Cyan Write-Host "" if ($nextStepNumber -le $totalSteps) { # Get the script name and next step line number $scriptName = Split-Path $scriptPath -Leaf $nextStepId = $stepLines[$lastStepIndex + 1] $nextStepLine = ($nextStepId -split ':')[-1] $response = Read-Host "Resume $scriptName from Line ${nextStepLine}? (Y/n)" if ($response -eq '' -or $response -eq 'Y' -or $response -eq 'y') { Write-Host "Resuming from step $nextStepNumber..." -ForegroundColor Green $script:StepperSessionState.RestoreMode = $true $script:StepperSessionState.TargetStep = $lastStep } else { Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath } } else { Write-Host "All steps were completed. Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath } Write-Host "" } } } # Determine if we should execute this step $shouldExecute = $true if ($script:StepperSessionState.RestoreMode) { # Format step identifier for display messages $stepIdParts = $stepId -split ':' $scriptName = Split-Path $stepIdParts[0] -Leaf $displayStepId = "${scriptName}:$($stepIdParts[1])" # In restore mode: skip steps until we reach the target if ($stepId -eq $script:StepperSessionState.TargetStep) { # This is the last completed step, skip it and disable restore mode $script:StepperSessionState.RestoreMode = $false $shouldExecute = $false } elseif ($script:StepperSessionState.RestoreMode) { # Still skipping Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Skipping step: $displayStepId" -ForegroundColor DarkGray $shouldExecute = $false } } # Execute the step if needed if ($shouldExecute) { # Format step identifier for display (scriptname:line instead of full path) $stepIdParts = $stepId -split ':' $scriptName = Split-Path $stepIdParts[0] -Leaf $displayStepId = "${scriptName}:$($stepIdParts[1])" # Calculate step number (X/Y) $scriptContent = Get-Content -Path $scriptPath -Raw $stepMatches = [regex]::Matches($scriptContent, '^\s*New-Step\s+\{', [System.Text.RegularExpressions.RegexOptions]::Multiline) $totalSteps = $stepMatches.Count # Find all step line numbers $stepLines = @() $lineNumber = 1 foreach ($line in (Get-Content -Path $scriptPath)) { if ($line -match '^\s*New-Step\s+\{') { $stepLines += "${scriptPath}:${lineNumber}" } $lineNumber++ } $currentStepNumber = $stepLines.IndexOf($stepId) + 1 Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Executing step $currentStepNumber/$totalSteps ($displayStepId)" # Show current $Stepper data try { $stepperData = $callingScope.PSVariable.Get('Stepper').Value if ($stepperData -and $stepperData.Count -gt 0) { $variableNames = ($stepperData.Keys | Sort-Object) -join ', ' Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Available `$Stepper data ($variableNames)" } } catch { # Ignore if unable to read $Stepper } try { & $ScriptBlock # Update state file after successful execution (including $Stepper data) $stepperData = $callingScope.PSVariable.Get('Stepper').Value Write-StepperState -StatePath $statePath -ScriptHash $currentHash -LastCompletedStep $stepId -StepperData $stepperData Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Step $currentStepNumber/$totalSteps completed ($displayStepId)" if ($stepperData -and $stepperData.Count -gt 0) { Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Saved `$Stepper data ($($stepperData.Count) items)" } } catch { Write-Error "Step failed at $stepId : $_" throw } } } function Stop-Stepper { <# .SYNOPSIS Stops Stepper and clears the state file for the calling script. .DESCRIPTION Removes the state file, typically called at the end of a script when it completes successfully. This ensures the next run starts fresh. .EXAMPLE # At the end of your script: New-Step { Write-Host "Final step" } Stop-Stepper .NOTES This function automatically determines which script called it and removes the corresponding state file. #> [CmdletBinding()] param() # Inherit verbose preference by walking the call stack $callStack = Get-PSCallStack foreach ($frame in $callStack) { if ($frame.InvocationInfo.BoundParameters.ContainsKey('Verbose') -and $frame.InvocationInfo.BoundParameters['Verbose']) { $VerbosePreference = 'Continue' break } } Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] All steps complete. Cleaning up..." try { $callStack = Get-PSCallStack # Find the calling script (skip this function) for ($i = 1; $i -lt $callStack.Count; $i++) { $frame = $callStack[$i] $scriptPath = $frame.ScriptName # Skip frames without a script name if (-not $scriptPath) { continue } # Skip frames from within the Stepper module # Normalize path for cross-platform comparison $normalizedPath = $scriptPath -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar $sep = [System.IO.Path]::DirectorySeparatorChar if ($normalizedPath -like '*Stepper.psm1' -or $normalizedPath -like "*${sep}Private${sep}*.ps1" -or $normalizedPath -like "*${sep}Public${sep}*.ps1") { continue } # Found the user's script $statePath = Get-StepperStatePath -ScriptPath $scriptPath Remove-StepperState -StatePath $statePath $scriptName = Split-Path $scriptPath -Leaf Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Cleared Stepper state for $scriptName" # Clear the session state if ($script:StepperSessionState) { $script:StepperSessionState = $null } Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')][Stepper] Cleanup complete!" return } Write-Warning "Unable to determine calling script from call stack" } catch { Write-Error "Failed to clear Stepper state: $_" } } # Export functions and aliases as required Export-ModuleMember -Function @('New-Step', 'Stop-Stepper') -Alias @() |