Private/Start-BsnUnixPtyRecording.ps1

<#
.SYNOPSIS
    Records a command inside a Unix pseudo-terminal via script(1) to a cast file.
 
.DESCRIPTION
    Uses the system script(1) utility, which runs the command inside a
    pseudo-terminal, proxies it to the real terminal and logs the stream:
 
    - util-linux script (most Linux distributions): records with exact
      timing (--log-timing/--log-out) which is then converted to asciicast v2.
    - BSD script (macOS): records the raw stream only; timestamps are
      approximated by spreading line events across the measured duration.
 
    The script process is monitored in a polling loop; creating StopFilePath
    on disk (the Stop-InteractiveTranscript signaling channel) terminates it
    and finalizes the recording.
 
    Input recording is not supported on Unix in this release; a warning is
    emitted when -RecordInput is requested.
#>

function Start-BsnUnixPtyRecording {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Confirmation is owned by the public Start-InteractiveTranscript cmdlet.')]
    [CmdletBinding()]
    param(
        [string]$Command,

        [Parameter(Mandatory = $true)]
        [string]$CastPath,

        [switch]$RecordInput,

        [string]$ShellName = 'shell',

        [string]$StopFilePath,

        [ValidateSet(2, 3)]
        [int]$CastVersion = 2
    )

    $scriptTool = Get-Command -Name 'script' -ErrorAction 'SilentlyContinue'
    if (-not $scriptTool) {
        throw ('Interactive transcripts on Unix require the script(1) utility, which was not found on PATH. Detected platform: {0}. Install it (for example util-linux on Linux, BSD userland on macOS) and retry.' -f (Get-BsnOperatingSystem))
    }

    if ($RecordInput.IsPresent) {
        Write-Warning -Message 'Recording keyboard input is not supported on Unix platforms yet; only terminal output is recorded.'
    }

    $width = 80
    $height = 24
    try {
        $tputColumns = (& tput cols 2>$null)
        if ($tputColumns) { $width = [int]$tputColumns }
    }
    catch {
        Write-Verbose -Message ("Could not query terminal width: {0}" -f $_.Exception.Message)
    }
    try {
        $tputLines = (& tput lines 2>$null)
        if ($tputLines) { $height = [int]$tputLines }
    }
    catch {
        Write-Verbose -Message ("Could not query terminal height: {0}" -f $_.Exception.Message)
    }

    # util-linux script supports exact timing logs; BSD script does not.
    $scriptHelp = (& $scriptTool.Source --help 2>&1 | Out-String)
    $supportsTiming = $scriptHelp -match '--log-timing'

    $temporaryPaths = @()
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
    $exitCode = 0
    $stoppedByRequest = $false
    try {
        $recordedCommand = if ($Command) { $Command } else { Get-BsnDefaultShell }

        $processInfo = [System.Diagnostics.ProcessStartInfo]::new()
        $processInfo.FileName = $scriptTool.Source
        $processInfo.UseShellExecute = $false

        if ($supportsTiming) {
            $timingPath = [System.IO.Path]::GetTempFileName()
            $outputPath = [System.IO.Path]::GetTempFileName()
            $temporaryPaths = @($timingPath, $outputPath)

            $processInfo.Arguments = ('-q -f -e -c "{0}" --log-timing "{1}" --log-out "{2}" /dev/null' -f $recordedCommand, $timingPath, $outputPath)
        }
        else {
            $rawPath = [System.IO.Path]::GetTempFileName()
            $temporaryPaths = @($rawPath)

            if ($Command) {
                $processInfo.Arguments = ('-q "{0}" /bin/sh -c "{1}"' -f $rawPath, $Command)
            }
            else {
                $processInfo.Arguments = ('-q "{0}" "{1}"' -f $rawPath, (Get-BsnDefaultShell))
            }
        }

        Write-Verbose -Message ("Recording via: {0} {1}" -f $processInfo.FileName, $processInfo.Arguments)
        $recordingProcess = [System.Diagnostics.Process]::Start($processInfo)
        try {
            while (-not $recordingProcess.HasExited) {
                if ($StopFilePath -and (Test-Path -LiteralPath $StopFilePath)) {
                    $stoppedByRequest = $true
                    $recordingProcess.Kill()
                    $recordingProcess.WaitForExit(5000) | Out-Null
                    break
                }
                Start-Sleep -Milliseconds 200
            }
            $exitCode = $recordingProcess.ExitCode
        }
        finally {
            if (-not $recordingProcess.HasExited) {
                $recordingProcess.Kill()
            }
            $recordingProcess.Dispose()
        }
        $stopwatch.Stop()

        if ($supportsTiming) {
            ConvertTo-BsnAsciicast -OutputPath $outputPath -TimingPath $timingPath -CastPath $CastPath -Width $width -Height $height -ShellName $ShellName -CastVersion $CastVersion -ExitCode $exitCode
        }
        else {
            ConvertTo-BsnAsciicast -OutputPath $temporaryPaths[0] -CastPath $CastPath -Width $width -Height $height -DurationSec $stopwatch.Elapsed.TotalSeconds -ShellName $ShellName -CastVersion $CastVersion -ExitCode $exitCode
        }
    }
    finally {
        $stopwatch.Stop()
        foreach ($temporaryPath in $temporaryPaths) {
            if (Test-Path -LiteralPath $temporaryPath) {
                Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction 'SilentlyContinue'
            }
        }
    }

    return [pscustomobject]@{
        ExitCode         = $exitCode
        DurationSec      = $stopwatch.Elapsed.TotalSeconds
        StoppedByRequest = $stoppedByRequest
        Completed        = $true
    }
}