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 = @() # Find all Stepper ignore regions $ignoredRegions = @() $inIgnoreRegion = $false $regionStart = -1 for ($i = 0; $i -lt $ScriptLines.Count; $i++) { $line = $ScriptLines[$i].Trim() if ($line -match '^\s*#region\s+Stepper\s+ignore') { $inIgnoreRegion = $true $regionStart = $i } elseif ($line -match '^\s*#endregion\s+Stepper\s+ignore' -and $inIgnoreRegion) { $ignoredRegions += @{ Start = $regionStart End = $i } $inIgnoreRegion = $false } } # Find all multi-line comment blocks $commentBlocks = @() $inCommentBlock = $false $commentStart = -1 for ($i = 0; $i -lt $ScriptLines.Count; $i++) { $line = $ScriptLines[$i] if (-not $inCommentBlock -and $line -match '<#') { $inCommentBlock = $true $commentStart = $i } if ($inCommentBlock -and $line -match '#>') { $commentBlocks += @{ Start = $commentStart End = $i } $inCommentBlock = $false } } 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++) { # Skip if line is in an ignored region if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $ignoredRegions) { continue } # Skip if line is in a multi-line comment block if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $commentBlocks) { continue } $line = $ScriptLines[$j].Trim() # Skip comments, empty lines, and common non-executable statements if ($line -and $line -notmatch '^\s*#' -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++) { # Skip if line is in an ignored region if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $ignoredRegions) { continue } # Skip if line is in a multi-line comment block if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $commentBlocks) { continue } $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 (or end of file if no Stop-Stepper) $lastBlock = $NewStepBlocks[$NewStepBlocks.Count - 1] $gapStart = $lastBlock.End + 1 # If Stop-Stepper exists, check up to it; otherwise check to end of file $gapEnd = if ($StopStepperLine -ge 0) { $StopStepperLine - 1 } else { $ScriptLines.Count - 1 } $blockLines = @() for ($j = $gapStart; $j -le $gapEnd; $j++) { # Skip if line is in an ignored region if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $ignoredRegions) { continue } # Skip if line is in a multi-line comment block if (Test-LineInIgnoredRegion -LineIndex $j -IgnoredRegions $commentBlocks) { continue } $line = $ScriptLines[$j].Trim() if ($line -and $line -notmatch '^\s*#') { $blockLines += $j } } if ($blockLines.Count -gt 0) { $nonResumableBlocks += @{ Lines = $blockLines IsBeforeStop = ($StopStepperLine -ge 0) } } } 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', 'MarkIgnored', '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 Magenta Write-Host " This code will execute on every run of this script," -ForegroundColor Magenta Write-Host " including resumed runs:" -ForegroundColor Magenta Write-Host "" foreach ($lineNum in $blockLineNums) { $lineContent = $ScriptLines[$lineNum - 1].Trim() Write-Host " ${lineNum}: $lineContent" -ForegroundColor Gray } Write-Host "" Write-Host "How would you like to handle this?" Write-Host "" Write-Host " [W] Wrap in New-Step block (Default)" -ForegroundColor Cyan Write-Host " [M] Mark as expected to ignore this code on future script runs" -ForegroundColor White Write-Host " [D] Delete this code" -ForegroundColor White if ($hasStepperVar) { Write-Host " WARNING: Because this code references `$Stepper variables," -ForegroundColor Yellow Write-Host " deleting it may impact functionality." -ForegroundColor Yellow } Write-Host " [I] Ignore and continue" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice? [" -NoNewline Write-Host "W" -NoNewline -ForegroundColor Cyan Write-Host "/m/d/i/q]: " -NoNewline $choice = Read-Host switch ($choice.ToLower()) { 'w' { return 'Wrap' } '' { return 'Wrap' } # Default to Wrap 'm' { return 'MarkIgnored' } 'd' { return 'Delete' } 'i' { return 'Ignore' } 'q' { return 'Quit' } default { return 'Wrap' } # Default to Wrap } } 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 # Skip if it's from the module's Private/Public folders or the main PSM1 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" -or $normalizedScript -like "*$([System.IO.Path]::DirectorySeparatorChar)Modules$([System.IO.Path]::DirectorySeparatorChar)Stepper$([System.IO.Path]::DirectorySeparatorChar)*") { 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, ScriptContents, 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 Show-MoreDetails { <# .SYNOPSIS Displays detailed state and script context for an incomplete run. .PARAMETER ExistingState The object returned by Read-StepperState. .PARAMETER ScriptPath Path to the current script. .PARAMETER CurrentHash The current script hash. .PARAMETER LastStep Identifier of the last completed step (format: "path:line"). .PARAMETER NextStepLine Line number where the next step will execute. .PARAMETER ShowHashComparison If set, prints both previous and current script hashes (used when script differs). #> [CmdletBinding()] param( [Parameter(Mandatory)] [pscustomobject]$ExistingState, [Parameter(Mandatory)] [string]$ScriptPath, [Parameter(Mandatory)] [string]$CurrentHash, [Parameter(Mandatory)] [string]$LastStep, [Parameter(Mandatory)] [int]$NextStepLine, [switch]$ShowHashComparison ) Write-Host "" Write-Host "More details:" -ForegroundColor Yellow Write-Host "" if ($ShowHashComparison) { Write-Host " Previous script hash: $($ExistingState.ScriptHash)" } Write-Host " Current script hash: $CurrentHash" # Show Stepper variables and their contents Write-Host "" Write-Host "Stepper variables:" Write-Host "" if ($ExistingState.StepperData -and $ExistingState.StepperData.Count -gt 0) { foreach ($var in ($ExistingState.StepperData.Keys | Sort-Object)) { $val = $ExistingState.StepperData[$var] try { $valStr = $val | ConvertTo-Json -Depth 4 -ErrorAction Stop } catch { $valStr = ($val | Out-String).TrimEnd() } if ($valStr -match "`n") { Write-Host (" {0}:" -f $var) $valStr -split "`n" | ForEach-Object { Write-Host " $_" } } else { Write-Host (" {0}: {1}" -f $var, $valStr) } } } else { Write-Host " None" } # Show full contents of the last completed New-Step using saved script contents if available Write-Host "" Write-Host "Last completed step:" Write-Host "" if ($ExistingState.ScriptContents) { $prevLines = $ExistingState.ScriptContents -split "`n" $prevStepLine = $null if ($LastStep -and ($LastStep -match ':(\d+)$')) { $prevStepLine = [int]$Matches[1] } else { Write-Host " No valid LastCompletedStep recorded." -ForegroundColor Yellow } # Attempt to extract the full New-Step block using PowerShell's AST, # so that braces inside strings or comments don't confuse the logic. $block = @() $tokens = $null $parseErrors = $null $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput( $ExistingState.ScriptContents, [ref]$tokens, [ref]$parseErrors ) if (-not $parseErrors -or $parseErrors.Count -eq 0) { # Find the New-Step command that contains the previous step line, then get its script-block argument $cmdAst = $scriptAst.Find( { param($node) $node -is [System.Management.Automation.Language.CommandAst] -and ($node.GetCommandName() -eq 'New-Step') -and $node.Extent.StartLineNumber -le $prevStepLine -and $node.Extent.EndLineNumber -ge $prevStepLine }, $true ) # Collect all New-Step commands with scriptblock arguments $cmdList = $scriptAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] -and ($node.GetCommandName() -eq 'New-Step') }, $true) | ForEach-Object { $sb = $_.CommandElements | Where-Object { $_ -is [System.Management.Automation.Language.ScriptBlockAst] } | Select-Object -First 1 if ($sb) { [pscustomobject]@{ Command = $_ ScriptBlock = $sb Start = $sb.Extent.StartLineNumber End = $sb.Extent.EndLineNumber CmdStart = $_.Extent.StartLineNumber CmdEnd = $_.Extent.EndLineNumber } } } $scriptBlockAst = $null # Prefer a scriptblock that actually spans the previous step line $candidate = $cmdList | Where-Object { $_.Start -le $prevStepLine -and $_.End -ge $prevStepLine } | Select-Object -First 1 # If none span the line, prefer a command whose declaration line matches the step line if (-not $candidate) { $candidate = $cmdList | Where-Object { $_.CmdStart -eq $prevStepLine } | Select-Object -First 1 } # If still none, pick the closest preceding scriptblock (highest Start <= prevStepLine) if (-not $candidate) { $candidate = $cmdList | Where-Object { $_.Start -lt $prevStepLine } | Sort-Object -Property Start -Descending | Select-Object -First 1 } if ($candidate) { $scriptBlockAst = $candidate.ScriptBlock $startLine = $candidate.Start $endLine = $candidate.End for ($i = $startLine; $i -le $endLine -and $i -le $prevLines.Count; $i++) { $block += $prevLines[$i - 1] } } else { Write-Verbose "Show-MoreDetails: AST lookup failed for New-Step around line $prevStepLine; attempting manual brace-match fallback" # Search upward for the nearest 'New-Step {' start line $foundStart = $null for ($s = $prevStepLine; $s -ge 1; $s--) { if ($prevLines[$s - 1] -match '^\s*New-Step\s*\{') { $foundStart = $s break } } if ($foundStart) { $level = 0 $endLine = $foundStart for ($i = $foundStart; $i -le $prevLines.Count; $i++) { $lineText = $prevLines[$i - 1] # Strip simple quoted strings to reduce brace noise $stripped = $lineText -replace "'[^']*'", '' -replace '"[^"]*"', '' $opens = ($stripped -split '{').Count - 1 $closes = ($stripped -split '}').Count - 1 $level += $opens - $closes if ($level -le 0) { $endLine = $i break } } if ($endLine -ge $foundStart) { for ($i = $foundStart; $i -le $endLine -and $i -le $prevLines.Count; $i++) { $block += $prevLines[$i - 1] } } else { Write-Verbose "Show-MoreDetails: Manual brace-match failed to find end for New-Step starting at $foundStart" } } else { Write-Verbose "Show-MoreDetails: No 'New-Step {' found above line $prevStepLine" } } } if ($block.Count -gt 0) { $block | ForEach-Object { Write-Host " $_" } } else { Write-Host " Unable to extract step body." -ForegroundColor Yellow } } else { Write-Host " No saved script contents available." -ForegroundColor Yellow } # Show context around the restart line in the current script (2 lines before / 3 lines after) Write-Host "" Write-Host "Context around next restart line:" Write-Host "" $ns = [int]$NextStepLine $start2 = [Math]::Max(1, $ns - 2) $end2 = $ns + 3 # Read only as many lines as we need for context, instead of the entire file $contextLines = Get-Content -Path $ScriptPath -TotalCount $end2 $lastLineToShow = [Math]::Min($end2, $contextLines.Count) for ($ln = $start2; $ln -le $lastLineToShow; $ln++) { $line = $contextLines[$ln - 1] $display = ($line -replace "`t", " ").TrimEnd() if ($ln -eq $ns) { Write-Host ("{0,5}: {1}" -f $ln, $display) -ForegroundColor Cyan } else { Write-Host ("{0,5}: {1}" -f $ln, $display) } } Write-Host "" # Prompt header for available actions Write-Host "How would you like to proceed?" Write-Host "" } function Test-LineInIgnoredRegion { <# .SYNOPSIS Checks if a line is within a Stepper ignore region. .PARAMETER LineIndex The zero-based line index to check. .PARAMETER IgnoredRegions Array of hashtables with Start and End properties marking ignored regions. .OUTPUTS Boolean - True if the line is in an ignored region, false otherwise. #> [CmdletBinding()] param( [Parameter(Mandatory)] [int]$LineIndex, [Parameter(Mandatory)] [AllowEmptyCollection()] [array]$IgnoredRegions ) foreach ($region in $IgnoredRegions) { if ($LineIndex -ge $region.Start -and $LineIndex -le $region.End) { return $true } } return $false } 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-Host "[!] Script requirements check for ${scriptName}:" -ForegroundColor Magenta Write-Host "" 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?" Write-Host "" Write-Host " [A] Add missing declarations (Default)" -ForegroundColor Cyan Write-Host " [S] Skip" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice? [" -NoNewline Write-Host "A" -NoNewline -ForegroundColor Cyan Write-Host "/s/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 'A' -or $response -eq 'a') { $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 # Delete state file since script was modified $statePath = Get-StepperStatePath -ScriptPath $ScriptPath Remove-StepperState -StatePath $statePath Write-Host "" Write-Host "Declarations added. Please re-run $scriptName." -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/MarkIgnored/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 = @() # 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) } # Group lines to mark as ignored by consecutive sequences $linesToMarkIgnored = @($Actions.Keys | Where-Object { $Actions[$_].Action -eq 'MarkIgnored' } | Sort-Object) $markIgnoredGroups = @() if ($linesToMarkIgnored.Count -gt 0) { $currentGroup = @($linesToMarkIgnored[0]) for ($i = 1; $i -lt $linesToMarkIgnored.Count; $i++) { if ($linesToMarkIgnored[$i] -eq $linesToMarkIgnored[$i - 1] + 1) { $currentGroup += $linesToMarkIgnored[$i] } else { $markIgnoredGroups += , @($currentGroup) $currentGroup = @($linesToMarkIgnored[$i]) } } $markIgnoredGroups += , @($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 } } $markedIgnoredLines = @{} foreach ($group in $markIgnoredGroups) { foreach ($idx in $group) { $markedIgnoredLines[$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 } # Check if this line starts a mark ignored group $startsMarkIgnoredGroup = $false $markIgnoredGroup = $null foreach ($group in $markIgnoredGroups) { if ($group[0] -eq $i) { $startsMarkIgnoredGroup = $true $markIgnoredGroup = $group break } } if ($startsMarkIgnoredGroup) { # Add region start $newScriptLines += "#region Stepper ignore" foreach ($idx in $markIgnoredGroup) { $newScriptLines += $ScriptLines[$idx] } $newScriptLines += "#endregion Stepper ignore" # Skip to after this group $i = $markIgnoredGroup[-1] continue } # Skip lines that are wrapped/marked/moved/deleted (but not the start of a group) if ($wrappedLines.ContainsKey($i) -or $markedIgnoredLines.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 # Delete state file since script was modified $statePath = Get-StepperStatePath -ScriptPath $ScriptPath Remove-StepperState -StatePath $statePath $scriptName = Split-Path $ScriptPath -Leaf Write-Host "" Write-Host "Changes applied to non-resumable code. Please re-run $scriptName." -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. .PARAMETER ScriptContents The full contents of the script at the time of saving (string). Useful for inspection when the script changes. .OUTPUTS None #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$StatePath, [Parameter(Mandatory)] [string]$ScriptHash, [Parameter(Mandatory)] [string]$LastCompletedStep, [Parameter()] [hashtable]$StepperData, [Parameter()] [string]$ScriptContents ) $state = [PSCustomObject]@{ ScriptHash = $ScriptHash ScriptContents = $ScriptContents 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" } # Check if this is the first step of this script execution # We use a variable in the calling scope to track initialization per execution $initVarName = '__StepperInitialized' $isFirstStep = $false try { $initVar = $callingScope.PSVariable.Get($initVarName) if (-not $initVar -or -not $initVar.Value) { $isFirstStep = $true $callingScope.PSVariable.Set($initVarName, $true) } } catch { $isFirstStep = $true $callingScope.PSVariable.Set($initVarName, $true) } # Initialize execution state on first step if ($isFirstStep) { # Store execution state in calling scope $executionState = @{ RestoreMode = $false TargetStep = $null CurrentScriptPath = $scriptPath CurrentScriptHash = $currentHash StatePath = $statePath } $callingScope.PSVariable.Set('__StepperExecutionState', $executionState) # Check script requirements (declarations) first $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 } } # Verify the script contains Stop-Stepper (last check) $scriptContent = Get-Content -Path $scriptPath -Raw if ($scriptContent -notmatch 'Stop-Stepper') { $scriptName = Split-Path $scriptPath -Leaf Write-Host "" Write-Host "[!] Script '$scriptName' does not call Stop-Stepper." -ForegroundColor Magenta Write-Host "" Write-Host "Stop-Stepper ensures the state file is removed when the script completes successfully." Write-Host "" Write-Host "How would you like to proceed?" Write-Host "" Write-Host " [A] Add 'Stop-Stepper' to the end of the script (Default)" -ForegroundColor Cyan Write-Host " [C] Continue without Stop-Stepper" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice? [" -NoNewline Write-Host "A" -NoNewline -ForegroundColor Cyan Write-Host "/c/q]: " -NoNewline $response = Read-Host if ($response -eq '' -or $response -eq 'A' -or $response -eq 'a') { # 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 # Delete state file since script was modified Remove-StepperState -StatePath $statePath Write-Host "" Write-Host "Stop-Stepper added. Please re-run $scriptName." -ForegroundColor Green # Exit this execution - the script will need to be run again exit } elseif ($response -eq 'C' -or $response -eq 'c') { Write-Warning "Continuing without Stop-Stepper. State file will not be cleaned up automatically." } elseif ($response -eq 'Q' -or $response -eq 'q') { Write-Host "" Write-Host "Exiting..." -ForegroundColor Yellow exit } else { # Default to Add for invalid input # 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 # Delete state file since script was modified Remove-StepperState -StatePath $statePath Write-Host "" Write-Host "Stop-Stepper added. Please re-run $scriptName." -ForegroundColor Green # Exit this execution - the script will need to be run again 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) { # Script has been modified since last run — prompt user for action $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 $lines = $scriptContent -split "`r?`n" foreach ($line in $lines) { if ($line -match '^\s*New-Step\s+\{') { $stepLines += "${scriptPath}:${lineNumber}" } $lineNumber++ } $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') $availableVars = if ($existingState.StepperData -and $existingState.StepperData.Count -gt 0) { ($existingState.StepperData.Keys | Sort-Object) -join ', ' } else { 'None' } $scriptName = Split-Path $scriptPath -Leaf $nextStepId = $stepLines[$lastStepIndex + 1] $nextStepLine = ($nextStepId -split ':')[-1] while ($true) { Write-Host "" Write-Host "[!] Incomplete script run detected, but $scriptName has been modified." -ForegroundColor Magenta Write-Host "" Write-Host "Total Steps: $totalSteps" Write-Host "Steps Completed: $($lastStepIndex + 1)" Write-Host "Variables: $availableVars" Write-Host "Last Activity: $timestamp" Write-Host "" Write-Host "How would you like to proceed?" Write-Host "" Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (May produce inconsistent results)" -ForegroundColor White Write-Host " [S] Start over (Default)" -ForegroundColor Cyan Write-Host " [M] More details" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice? [" -NoNewline Write-Host "r" -NoNewline -ForegroundColor White Write-Host "/S" -NoNewline -ForegroundColor Cyan Write-Host "/m/q]: " -NoNewline $response = Read-Host if ($response -eq '' -or $response -eq 'S' -or $response -eq 's') { Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath break } elseif ($response -eq 'R' -or $response -eq 'r') { Write-Host "" Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green $executionState.RestoreMode = $true $executionState.TargetStep = $lastStep break } elseif ($response -eq 'M' -or $response -eq 'm') { Show-MoreDetails -ExistingState $existingState -ScriptPath $scriptPath -CurrentHash $currentHash -LastStep $lastStep -NextStepLine $nextStepLine -ShowHashComparison # Re-display the bottom inline menu and accept an immediate choice Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (May produce inconsistent results)" -ForegroundColor White Write-Host " [S] Start over (Default)" -ForegroundColor Cyan Write-Host " [M] More details" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice? [r/S/m/q]: " -NoNewline $moreResponse = Read-Host if ($moreResponse -eq '' -or $moreResponse -eq 'S' -or $moreResponse -eq 's') { Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath break } elseif ($moreResponse -eq 'R' -or $moreResponse -eq 'r') { Write-Host "" Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green $executionState.RestoreMode = $true $executionState.TargetStep = $lastStep break } elseif ($moreResponse -eq 'M' -or $moreResponse -eq 'm') { # Re-display details (loop) continue } elseif ($moreResponse -eq 'Q' -or $moreResponse -eq 'q') { Write-Host "" Write-Host "Exiting..." -ForegroundColor Yellow exit } else { # Default to Start over for invalid input Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath break } } elseif ($response -eq 'Q' -or $response -eq 'q') { Write-Host "" Write-Host "Exiting..." -ForegroundColor Yellow exit } else { # Default to Start over for invalid input Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath break } } } 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 Magenta Write-Host "" Write-Host "Total Steps: $totalSteps" Write-Host "Steps Completed: $($lastStepIndex + 1)" Write-Host "Variables: $availableVars" Write-Host "Last Activity: $timestamp" 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] while ($true) { Write-Host "How would you like to proceed?" Write-Host "" Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (Default)" -ForegroundColor Cyan Write-Host " [S] Start over" -ForegroundColor White Write-Host " [M] More details" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice? [" -NoNewline Write-Host "R" -NoNewline -ForegroundColor Cyan Write-Host "/s/m/q]: " -NoNewline $response = Read-Host if ($response -eq '' -or $response -eq 'R' -or $response -eq 'r') { Write-Host "" Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green $executionState.RestoreMode = $true $executionState.TargetStep = $lastStep break } elseif ($response -eq 'S' -or $response -eq 's') { Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath break } elseif ($response -eq 'M' -or $response -eq 'm') { Show-MoreDetails -ExistingState $existingState -ScriptPath $scriptPath -CurrentHash $currentHash -LastStep $lastStep -NextStepLine $nextStepLine # Print the action menu again at the bottom of the details and accept an immediate choice Write-Host " [R] Resume $scriptName from Line ${nextStepLine} (Default)" -ForegroundColor Cyan Write-Host " [S] Start over" -ForegroundColor White Write-Host " [M] More details" -ForegroundColor White Write-Host " [Q] Quit" -ForegroundColor White Write-Host "" Write-Host "Choice? [R/s/m/q]: " -NoNewline $moreResponse = Read-Host if ($moreResponse -eq '' -or $moreResponse -eq 'S' -or $moreResponse -eq 's') { Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath break } elseif ($moreResponse -eq 'R' -or $moreResponse -eq 'r') { Write-Host "" Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green $executionState.RestoreMode = $true $executionState.TargetStep = $lastStep break } elseif ($moreResponse -eq 'M' -or $moreResponse -eq 'm') { # Re-display details (loop) continue } elseif ($moreResponse -eq 'Q' -or $moreResponse -eq 'q') { Write-Host "" Write-Host "Exiting..." -ForegroundColor Yellow exit } else { # Default to Start over for invalid input Write-Host "Starting fresh..." -ForegroundColor Yellow Remove-StepperState -StatePath $statePath break } } elseif ($response -eq 'Q' -or $response -eq 'q') { Write-Host "" Write-Host "Exiting..." -ForegroundColor Yellow exit } else { # Default to Resume for invalid input Write-Host "" Write-Host "Resuming from Step $nextStepNumber..." -ForegroundColor Green $executionState.RestoreMode = $true $executionState.TargetStep = $lastStep break } } } 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 # Get execution state from calling scope try { $executionState = $callingScope.PSVariable.Get('__StepperExecutionState').Value } catch { $executionState = $null } if ($executionState -and $executionState.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 $executionState.TargetStep) { # This is the last completed step, skip it and disable restore mode $executionState.RestoreMode = $false $shouldExecute = $false } elseif ($executionState.RestoreMode) { # Still skipping $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 # Persist state including the script contents for better change inspection $scriptContents = Get-Content -Path $scriptPath -Raw Write-StepperState -StatePath $statePath -ScriptHash $currentHash -LastCompletedStep $stepId -StepperData $stepperData -ScriptContents $scriptContents 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" 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 @() |