Public/Invoke-WithEcho.ps1

function Invoke-WithEcho {
    <#
    .SYNOPSIS
        Runs a script block and logs the command text beforehand — like ECHO ON in DOS batch files.

    .DESCRIPTION
        The command text is written unchanged via Write-Host (information stream:
        visible in the console, captured by Start-Transcript, redirectable with 6>).
        Below it, every variable the block reads appears as a row in an indented
        table (Variable, Type, Count, Value), the value collapsed to a single
        line and truncated to -MaxValueLength characters. Resolution only reads
        variable values in the caller's scope and never executes anything.

        The block's pipeline output is passed through unchanged. Assignments
        therefore belong outside the call, not inside the block:
            $x = Invoke-WithEcho { Get-ChildItem $path } # correct
            Invoke-WithEcho { $x = Get-ChildItem $path } # $x evaporates in the child scope

        With -Retry (or any Retry* tuning parameter) the block is re-invoked on
        terminating errors with exponential backoff. Only terminating errors
        trigger a retry; use -ErrorAction Stop inside the block to promote
        non-terminating ones. While retrying, output is buffered per attempt:
        a failed attempt's partial output is discarded and only the successful
        attempt's output reaches the pipeline (so it arrives only after the
        attempt completes). Each failed attempt logs a '!!' notice; after the
        last attempt the original error record is rethrown.

    .PARAMETER ScriptBlock
        The block to execute.

    .PARAMETER MaxValueLength
        Maximum length per logged variable value (default 100); longer values end with ….

    .PARAMETER NoExpand
        Suppresses the variable lines; only the command text is logged.

    .PARAMETER CommandColor
        Console color of the command lines (default: Cyan). Affects console
        rendering only; the transcript contains plain text.

    .PARAMETER ValueColor
        Console color of the variable lines (default: DarkGray).

    .PARAMETER NoColor
        Writes all echo lines in the console's default color.

    .PARAMETER Retry
        Re-invokes the block when it throws a terminating error, using the
        Retry* defaults (5 retries, 2 s initial delay, doubling per retry,
        capped at 60 s). Implied by any of the tuning parameters below.

    .PARAMETER RetryCount
        Number of retries after the first attempt (default 5, so 6 attempts
        in total). Implies -Retry.

    .PARAMETER RetryDelaySeconds
        Delay before the first retry in seconds (default 2). Implies -Retry.

    .PARAMETER RetryBackoffFactor
        Multiplier applied to the delay after each retry (default 2);
        1 keeps the delay fixed. Implies -Retry.

    .PARAMETER RetryMaxDelaySeconds
        Upper limit for the growing delay in seconds (default 60). Implies -Retry.

    .PARAMETER RetryNoticeColor
        Console color of the '!!' retry notice lines (default: Yellow).
        Does not imply -Retry.

    .EXAMPLE
        $files = Invoke-WithEcho { Get-ChildItem $sourcePath -Filter *.csv }

    .EXAMPLE
        $mailboxes = Invoke-WithEcho -Retry { Get-EXOMailbox -ResultSize Unlimited }

        Retries transient Exchange Online failures (e.g. throttling) up to
        5 times with exponential backoff. For a fixed cadence instead:
        -RetryCount 10 -RetryDelaySeconds 5 -RetryBackoffFactor 1
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Write-Host is a deliberate design decision here: the information stream is captured by Start-Transcript, redirectable with 6>, and never pollutes the return value.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [scriptblock] $ScriptBlock,

        [ValidateRange(1, [int]::MaxValue)]
        [int] $MaxValueLength = 100,

        [switch] $NoExpand,

        [ConsoleColor] $CommandColor = [ConsoleColor]::Cyan,

        [ConsoleColor] $ValueColor = [ConsoleColor]::DarkGray,

        [switch] $NoColor,

        [switch] $Retry,

        [ValidateRange(1, 100)]
        [int] $RetryCount = 5,

        [ValidateRange(0, 3600)]
        [double] $RetryDelaySeconds = 2,

        [ValidateRange(1, 10)]
        [double] $RetryBackoffFactor = 2,

        [ValidateRange(0, 3600)]
        [double] $RetryMaxDelaySeconds = 60,

        [ConsoleColor] $RetryNoticeColor = [ConsoleColor]::Yellow
    )

    $commandStyle = @{}
    $valueStyle = @{}
    if (-not $NoColor) {
        $commandStyle['ForegroundColor'] = $CommandColor
        $valueStyle['ForegroundColor'] = $ValueColor
    }

    $lines = $ScriptBlock.ToString().Trim() -split '\r?\n'
    $indent = ($lines | Select-Object -Skip 1 | Where-Object { $_.Trim() } |
        ForEach-Object { $_.Length - $_.TrimStart().Length } |
        Measure-Object -Minimum).Minimum
    foreach ($line in $lines) {
        if ($indent -and $line.Length -ge $indent -and -not $line.Substring(0, $indent).Trim()) {
            $line = $line.Substring($indent)
        }
        Write-Host ">> $line" @commandStyle
    }

    if (-not $NoExpand) {
        $occurrences = $ScriptBlock.Ast.FindAll(
            { param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] },
            $true) | Sort-Object { $_.Extent.StartOffset }

        # Only values read from the caller's scope are logged: a variable whose
        # first read happens after a completed assignment inside the block is
        # block-local and does not appear.
        $firstRead = @{}
        $firstWrite = @{}
        foreach ($occurrence in $occurrences) {
            $name = $occurrence.VariablePath.UserPath
            if ($name -in $script:SkipVariables) { continue }
            $use = Get-EchoVariableUse -Variable $occurrence
            if ($use.Reads -and -not $firstRead.ContainsKey($name)) {
                $firstRead[$name] = $occurrence.Extent.StartOffset
            }
            if ($null -ne $use.WriteOffset -and
                (-not $firstWrite.ContainsKey($name) -or $use.WriteOffset -lt $firstWrite[$name])) {
                $firstWrite[$name] = $use.WriteOffset
            }
        }
        $names = $firstRead.Keys | Where-Object {
            -not $firstWrite.ContainsKey($_) -or $firstRead[$_] -lt $firstWrite[$_]
        } | Sort-Object

        $rows = foreach ($name in $names) {
            try { $value = $PSCmdlet.GetVariableValue($name) } catch { continue }
            $itemCount = if ($null -eq $value) { '-' }
                elseif ($value -is [System.Collections.IDictionary]) { "$($value.Count)" }
                elseif ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { "$(@($value).Count)" }
                else { '1' }
            [pscustomobject]@{
                Name      = "`$$name"
                Type      = if ($null -eq $value) { '-' } else { $value.GetType().Name }
                ItemCount = $itemCount
                Value     = Format-EchoValue -Value $value -MaxLength $MaxValueLength
            }
        }

        if ($rows) {
            $nameWidth  = (@('Variable') + @($rows.Name)      | Measure-Object -Property Length -Maximum).Maximum
            $typeWidth  = (@('Type')     + @($rows.Type)      | Measure-Object -Property Length -Maximum).Maximum
            $countWidth = (@('Count')    + @($rows.ItemCount) | Measure-Object -Property Length -Maximum).Maximum
            $rowFormat  = " {0,-$nameWidth} {1,-$typeWidth} {2,$countWidth} {3}"
            Write-Host ($rowFormat -f 'Variable', 'Type', 'Count', 'Value') @valueStyle
            Write-Host ($rowFormat -f ('-' * 8), ('-' * 4), ('-' * 5), ('-' * 5)) @valueStyle
            foreach ($row in $rows) {
                Write-Host ($rowFormat -f $row.Name, $row.Type, $row.ItemCount, $row.Value) @valueStyle
            }
        }
    }

    $tuningParams = 'RetryCount', 'RetryDelaySeconds', 'RetryBackoffFactor', 'RetryMaxDelaySeconds'
    $retryEnabled = $Retry.IsPresent -or (@($PSBoundParameters.Keys) | Where-Object { $_ -in $tuningParams })

    if (-not $retryEnabled) {
        return & $ScriptBlock
    }

    $noticeStyle = @{}
    if (-not $NoColor) { $noticeStyle['ForegroundColor'] = $RetryNoticeColor }

    # Retrying implies buffering: output already streamed by a failed attempt
    # could not be taken back, so each attempt's output is held and emitted
    # only once the attempt succeeds.
    $totalAttempts = $RetryCount + 1
    $delay = $RetryDelaySeconds
    for ($attempt = 1; $attempt -le $totalAttempts; $attempt++) {
        try {
            $output = & $ScriptBlock
            return $output
        }
        catch {
            if ($attempt -eq $totalAttempts) {
                Write-Host ('!! Attempt {0}/{1} failed: {2} - giving up' -f $attempt, $totalAttempts, $_.Exception.Message) @noticeStyle
                $PSCmdlet.ThrowTerminatingError($_)
            }
            $delayText = $delay.ToString('0.###', [System.Globalization.CultureInfo]::InvariantCulture)
            Write-Host ('!! Attempt {0}/{1} failed: {2} - retrying in {3}s' -f $attempt, $totalAttempts, $_.Exception.Message, $delayText) @noticeStyle
            if ($delay -gt 0) { Start-Sleep -Milliseconds ([int][math]::Round($delay * 1000)) }
            $delay = [math]::Min($delay * $RetryBackoffFactor, $RetryMaxDelaySeconds)
        }
    }
}