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 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-Host "Non-resumable code detected in ${ScriptName}:" -ForegroundColor Yellow foreach ($lineNum in $blockLineNums) { $lineContent = $ScriptLines[$lineNum - 1].Trim() Write-Host " Line ${lineNum}: $lineContent" -ForegroundColor Gray } Write-Host "This code will re-execute on every run, including resumed runs." -ForegroundColor Yellow Write-Host "" -ForegroundColor Yellow if ($hasStepperVar) { Write-Host "⚠️ This code references" -NoNewline -ForegroundColor Red Write-Host " `$Stepper " -NoNewline -ForegroundColor Yellow Write-Host "variables!" -ForegroundColor Red } Write-Host "How would you like to fix 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 } Write-Host " [D] Delete this code" -ForegroundColor White if ($hasStepperVar) { Write-Host " (Warning: This will delete code that uses " -NoNewline -ForegroundColor DarkYellow Write-Host "`$Stepper" -NoNewline -ForegroundColor Yellow Write-Host " variables)" -ForegroundColor DarkYellow } Write-Host " [I] Ignore (continue anyway)" -ForegroundColor White Write-Host "" $choice = Read-Host "Choice" switch ($choice.ToLower()) { 'w' { return 'Wrap' } 'm' { if ($Block.IsBeforeStop) { return 'Move' } else { return 'Ignore' } } 'd' { return 'Delete' } 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 if ($scriptName -like "$stepperDir/Private/*" -or $scriptName -like "$stepperDir/Public/*" -or $scriptName -like "$stepperDir/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 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 = @() $linesToWrap = @() $linesToMove = @() $lastBlock = $NewStepBlocks[$NewStepBlocks.Count - 1] # Separate lines by action foreach ($lineIdx in $Actions.Keys) { switch ($Actions[$lineIdx].Action) { 'Wrap' { $linesToWrap += $lineIdx } 'Move' { $linesToMove += $lineIdx } } } # Copy all lines except ones being removed for ($i = 0; $i -lt $ScriptLines.Count; $i++) { if (-not $Actions.ContainsKey($i)) { $newScriptLines += $ScriptLines[$i] } # After the last New-Step block, add wrapped code if ($i -eq $lastBlock.End -and $linesToWrap.Count -gt 0) { $newScriptLines += "" $newScriptLines += "New-Step {" foreach ($lineIdx in ($linesToWrap | Sort-Object)) { $newScriptLines += " $($ScriptLines[$lineIdx])" } $newScriptLines += "}" } } # 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 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 -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 if ($scriptPath -like '*Stepper.psm1' -or $scriptPath -like '*/Private/*.ps1' -or $scriptPath -like '*/Public/*.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 @() |