Public/Invoke-CommandWatch.ps1

<#
.SYNOPSIS
    Invoke a command repeatedly at a fixed interval (PowerShell equivalent of Linux watch).
 
.DESCRIPTION
    Runs either a PowerShell expression or an external executable on a schedule, refreshing the
    console each iteration (unless -NoClear) and printing a header with timing, exit status, and
    iteration count. Output can be truncated (-NoWrap), diffed against prior iterations
    (-Differences / -DifferencesPermanent), surfaced as colored changes (-Color), logged to disk
    (-LogPath), and emitted as rich objects (-PassThru) for automation. Defaults can be persisted
    via Set-CommandWatchConfig (queried with Get-CommandWatchConfig).
 
.PARAMETER Command
    The PowerShell expression or executable to run.
 
.PARAMETER UseExec
    Treat Command as an external executable and invoke it with the call operator (&) plus -Args.
 
.PARAMETER Args
    Arguments passed to the command when -UseExec is set.
 
.PARAMETER Interval
    Interval in seconds between executions. Alias: -n.
 
.PARAMETER Precise
    Retained for backward compatibility; precise stopwatch scheduling is now always enabled.
 
.PARAMETER Count
    Stop after the specified number of iterations.
 
.PARAMETER NoTitle
    Suppress the header output (still clears unless -NoClear is provided or stored as default).
 
.PARAMETER NoWrap
    Truncate long lines to the computed width using ellipsis instead of wrapping.
 
.PARAMETER NoClear
    Skip Clear-Host between iterations (useful for CI logs / transcript captures).
 
.PARAMETER StreamOutput
    Stream command output directly to the console as it arrives (native tools like ping show per-iteration results
    immediately). Incompatible with -Differences/-DifferencesPermanent because diff rendering requires buffered
    output.
 
.PARAMETER Differences
    Show only differences compared to the previous iteration, with +/- prefixes (optionally colorized).
 
.PARAMETER DifferencesPermanent
    Always diff against the first iteration (baseline) instead of the immediately previous iteration.
 
.PARAMETER ChangeExit
    Exit the loop as soon as the command output changes versus the prior iteration.
 
.PARAMETER ErrorExit
    Exit the loop immediately when the command returns a non-zero exit code.
 
.PARAMETER Beep
    Emit a console beep when a non-zero exit code is observed.
 
.PARAMETER Color
    Enable colored output for headers and diff lines (green additions, red removals).
 
.PARAMETER Width
    Override the console width when wrapping/truncating output.
 
.PARAMETER waitTime
    Legacy compatibility that maps to -Interval. Alias: -wait.
 
.PARAMETER waitInterval
    Legacy units for -waitTime (ms, s, m, h). Alias: -i.
 
.PARAMETER PassThru
    Emit per-iteration objects (command, output, exit code, diffs, timestamps) in addition to UI output.
 
.PARAMETER LogPath
    Append each iteration summary to the specified log file (directories are created automatically).
 
.NOTES
    Defaults can be persisted via Set-CommandWatchConfig / Get-CommandWatchConfig.
 
.EXAMPLE
    Invoke-CommandWatch -n 3 -UseExec ping -Args '-n','1','1.1.1.1' -StreamOutput
    Streams a single ICMP request every three seconds so each reply is visible for the full interval.
 
.EXAMPLE
    Invoke-CommandWatch -Command "Get-Process | Sort-Object CPU -Descending | Select -First 5" -Count 3 -PassThru -NoClear -NoTitle
    Captures three iterations of process snapshots without clearing or headers; returns objects for automation.
 
.EXAMPLE
    Invoke-CommandWatch -Command "'tick-' + (Get-Random)" -Differences -Color -ChangeExit
    Highlights line-level differences, colors additions/removals, and stops once the output changes.
 
.NOTES
    Backward compatibility alias: Watch-Command
#>

function Invoke-CommandWatch {
    [CmdletBinding(DefaultParameterSetName='Expression')]
    param (
        [Alias('ct')]
        [Parameter(Mandatory=$true, Position=0, ParameterSetName='Expression')]
        [Parameter(Mandatory=$true, Position=0, ParameterSetName='Exec')]
        [Parameter(Mandatory=$true, Position=0, ParameterSetName='Legacy')]
        [string]$Command,

        [Parameter(ParameterSetName='Exec')]
        [switch]$UseExec,

        [Parameter(ParameterSetName='Exec')]
        [string[]]$Args,

        [Alias('n')]
        [double]$Interval = 2,

        [switch]$Precise,

        [Alias('t')]
        [switch]$NoTitle,

        [Alias('d')]
        [switch]$Differences,

        [switch]$DifferencesPermanent,

        [Alias('g')]
        [switch]$ChangeExit,

        [Alias('e')]
        [switch]$ErrorExit,

        [Alias('b')]
        [switch]$Beep,

        [Alias('c')]
        [switch]$Color,

        [Alias('w')]
        [switch]$NoWrap,

        [switch]$NoClear,

        [switch]$StreamOutput,

        [int]$Count,

        [int]$Width,

        # Legacy compatibility (maps to -Interval). Note: -w alias from legacy is not reused.
        [Alias('wait')]
        [Parameter(ParameterSetName='Legacy')]
        [int]$waitTime,

        [Alias('i')]
        [Parameter(ParameterSetName='Legacy')]
        [ValidateSet('ms','milliseconds','s','seconds','m','minutes','h','hours')]
        [string]$waitInterval,

        [switch]$PassThru,

        [string]$LogPath
    )

    $configDefaults = @{}
    try {
        $config = Get-CommandWatchConfig -ErrorAction Stop
        if ($config -and $config.Defaults) {
            $configDefaults = $config.Defaults
        }
    } catch {
        $configDefaults = @{}
    }

    if (-not $PSBoundParameters.ContainsKey('Interval') -and $configDefaults.ContainsKey('Interval') -and $null -ne $configDefaults.Interval) {
        $Interval = [double]$configDefaults.Interval
    }
    if (-not $PSBoundParameters.ContainsKey('NoTitle') -and $configDefaults.ContainsKey('NoTitle') -and $configDefaults.NoTitle) {
        $NoTitle = $true
    }
    if (-not $PSBoundParameters.ContainsKey('NoWrap') -and $configDefaults.ContainsKey('NoWrap') -and $configDefaults.NoWrap) {
        $NoWrap = $true
    }
    if (-not $PSBoundParameters.ContainsKey('NoClear') -and $configDefaults.ContainsKey('NoClear') -and $configDefaults.NoClear) {
        $NoClear = $true
    }

    $widthFromConfig = $false
    if (-not $PSBoundParameters.ContainsKey('Width') -and $configDefaults.ContainsKey('Width') -and $null -ne $configDefaults.Width) {
        $Width = [int]$configDefaults.Width
        $widthFromConfig = $true
    }

    if (-not $PSBoundParameters.ContainsKey('LogPath') -and $configDefaults.ContainsKey('LogPath') -and $configDefaults.LogPath) {
        $LogPath = [string]$configDefaults.LogPath
    }

    $effectiveWidth = if ($PSBoundParameters.ContainsKey('Width') -or $widthFromConfig) { [int]$Width } else { try { $Host.UI.RawUI.BufferSize.Width } catch { 120 } }

    $legacyIntervalUsed = $false
    if ($PSBoundParameters.ContainsKey('waitTime')) {
        $legacyIntervalUsed = $true
        $unit = ($waitInterval | ForEach-Object { ($_ -as [string]) })
        switch ($unit.ToLower()) {
            'ms' { $Interval = [double]$waitTime / 1000 }
            'milliseconds' { $Interval = [double]$waitTime / 1000 }
            's' { $Interval = [double]$waitTime }
            'seconds' { $Interval = [double]$waitTime }
            'm' { $Interval = [double]$waitTime * 60 }
            'minutes' { $Interval = [double]$waitTime * 60 }
            'h' { $Interval = [double]$waitTime * 3600 }
            'hours' { $Interval = [double]$waitTime * 3600 }
            default { throw 'Invalid legacy wait interval provided.' }
        }
    }

    if ($legacyIntervalUsed) {
        Write-Warning 'Legacy parameters -waitTime/-waitInterval are deprecated; prefer -Interval/-n.'
    }

    $displayCmd = if ($PSCmdlet.ParameterSetName -eq 'Exec' -or $UseExec.IsPresent) {
        if ($Args) { "$Command $($Args -join ' ')" } else { $Command }
    } else { $Command }

    if ($StreamOutput -and ($Differences -or $DifferencesPermanent)) {
        throw '-StreamOutput cannot be combined with -Differences or -DifferencesPermanent.'
    }

    $diffMode = $Differences -or $DifferencesPermanent
    $diffModeLabel = if ($DifferencesPermanent) { 'Permanent' } elseif ($Differences) { 'Rolling' } else { 'None' }

    if ($PSBoundParameters.ContainsKey('Precise')) {
        Write-Verbose '-Precise scheduling is always enabled; switch retained for compatibility.'
    }

    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $nextDue = $sw.Elapsed.TotalSeconds
    $iteration = 0
    $lastExit = 0
    $stopReason = 'Completed'
    $previousOutputRaw = $null
    $previousLines = $null
    $baselineLines = $null

    try {
        while ($true) {
            $iteration++
            if (-not $NoClear) { Clear-Host }

            $timestamp = Get-Date

            if (-not $NoTitle) {
                $hdr = Format-Header -Interval $Interval -Command $displayCmd -Timestamp ($timestamp.ToString('yyyy-MM-dd HH:mm:ss')) -ExitCode $lastExit -Iteration $iteration
                if ($Color) {
                    Write-Host $hdr -ForegroundColor Cyan
                } else {
                    Write-Host $hdr
                }
                Write-Host ''
            }

            try {
                $result = Invoke-Once -Command $Command -Args $Args -UseExec:($PSCmdlet.ParameterSetName -eq 'Exec' -or $UseExec.IsPresent) -Width $effectiveWidth -StreamOutput:$StreamOutput -ErrorAction Stop
            } catch {
                Write-Error -ErrorRecord $_
                $result = [pscustomobject]@{ Output = ($_ | Out-String -Width $effectiveWidth); ExitCode = 1 }
            }

            $lastExit = [int]$result.ExitCode

            if ($lastExit -ne 0 -and $Beep) {
                try { [console]::Beep() } catch { Write-Host "`a" }
            }

            $displayLines = if ($NoWrap) {
                @(Truncate-Lines -Text $result.Output -Width $effectiveWidth)
            } else {
                $result.Output -split "`r?`n"
            }

            if ($null -eq $displayLines) {
                $displayLines = @('')
            } elseif ($displayLines -isnot [System.Array]) {
                $displayLines = @($displayLines)
            }

            if ($displayLines.Count -eq 0) { $displayLines = @('') }

            if ($DifferencesPermanent -and -not $baselineLines) {
                $baselineLines = $displayLines
            }

            $diffEntries = @()
            $renderDiffEntries = $false
            $linesToRender = $displayLines

            if ($diffMode) {
                $referenceLines = if ($DifferencesPermanent) {
                    if ($iteration -gt 1) { $baselineLines } else { $null }
                } else {
                    $previousLines
                }

                if ($referenceLines) {
                    $diffEntries = @(Get-CommandWatchDifference -Reference $referenceLines -Current $displayLines)
                    if ($diffEntries.Count -gt 0) {
                        $renderDiffEntries = $true
                    } else {
                        $linesToRender = @('(no changes detected)')
                    }
                }
            }

            $alreadyStreamed = ($StreamOutput -and $result.PSObject.Properties.Match('Streamed').Count -gt 0 -and $result.Streamed)

            if (-not $alreadyStreamed) {
                if ($renderDiffEntries) {
                    foreach ($entry in $diffEntries) {
                        if ($Color) {
                            $fg = if ($entry.Type -eq 'Added') { 'Green' } else { 'Red' }
                            Write-Host $entry.Text -ForegroundColor $fg
                        } else {
                            Write-Host $entry.Text
                        }
                    }
                } else {
                    foreach ($line in $linesToRender) {
                        Write-Host $line
                    }
                }
            }

            $hasChanged = $false
            if ($iteration -gt 1) {
                $hasChanged = ($result.Output -ne $previousOutputRaw)
            }
            $previousOutputRaw = $result.Output
            $previousLines = $displayLines

            if ($PassThru -or $LogPath) {
                $payload = [pscustomobject]@{
                    Command        = $Command
                    DisplayCommand = $displayCmd
                    Output         = $result.Output
                    DisplayLines   = $displayLines
                    DiffLines      = if ($diffEntries) { $diffEntries.Text } else { @() }
                    DiffMode       = $diffModeLabel
                    ExitCode       = $lastExit
                    Iteration      = $iteration
                    Timestamp      = $timestamp
                    ParameterSet   = $PSCmdlet.ParameterSetName
                }

                $payload.PSObject.TypeNames.Insert(0, 'CommandWatch.TickResult')

                if ($PassThru) {
                    Write-Output $payload
                }

                if ($LogPath) {
                    try {
                        $logDir = Split-Path -Parent $LogPath
                        if ($logDir -and -not (Test-Path -LiteralPath $logDir)) {
                            New-Item -ItemType Directory -Path $logDir -Force | Out-Null
                        }
                        $line = '{0:o} {1} [exit:{2}] [iter:{3}]' -f $payload.Timestamp, $payload.DisplayCommand, $payload.ExitCode, $payload.Iteration
                        Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8
                    } catch {
                        Write-Warning ('Unable to write log file: {0}' -f $_.Exception.Message)
                    }
                }
            }

            $shouldBreak = $false
            if ($ErrorExit -and $lastExit -ne 0) {
                $shouldBreak = $true
                $stopReason = 'ErrorExit'
            } elseif ($ChangeExit -and $hasChanged) {
                $shouldBreak = $true
                $stopReason = 'ChangeExit'
            } elseif ($Count -gt 0 -and $iteration -ge $Count) {
                $shouldBreak = $true
                $stopReason = 'Count'
            }

            if ($shouldBreak) {
                switch ($stopReason) {
                    'ErrorExit' {
                        Write-Information ('CommandWatch stopping due to -ErrorExit (exit code {0}).' -f $lastExit)
                    }
                    'ChangeExit' {
                        Write-Information 'CommandWatch stopping due to -ChangeExit (output changed).'
                    }
                    'Count' {
                        Write-Information 'CommandWatch stopping because the requested iteration count was reached.'
                    }
                }
                break
            }

            if ($Interval -le 0) {
                $nextDue = $sw.Elapsed.TotalSeconds
                continue
            }

            $nextDue += [double]$Interval
            Wait-CommandWatchInterval -Stopwatch $sw -NextDueSeconds $nextDue
        }
    }
    finally {
        if ($iteration -gt 0) {
            $infoMessage = 'CommandWatch finished after {0} iteration(s); last exit code {1}; reason: {2}.' -f $iteration, $lastExit, $stopReason
            Write-Information $infoMessage
        }
    }
}

# Back-compat alias for legacy entry point
Set-Alias -Name 'Watch-Command' -Value 'Invoke-CommandWatch' -Force