Public/Start-InteractiveTranscript.ps1

using module Bsn.Common

<#
.SYNOPSIS
    Records an interactive terminal session - including full-screen TUI
    applications - to a replayable asciicast v2 file, optionally rendered
    as video.
 
.DESCRIPTION
    Start-InteractiveTranscript fills the gap where Start-Transcript cannot
    help: it records the terminal itself, not just the PowerShell pipeline.
    The recorded command runs inside a pseudo-terminal (ConPTY on Windows,
    script(1) on Linux/macOS) whose output stream is proxied to the real
    console and teed, with timestamps, into an asciicast v2 (.cast) file
    that plays back with Show-InteractiveTranscript, 'asciinema play' or
    any asciicast v2 player.
 
    Full-screen TUI applications (sconfig, vim, htop, nmtui, ...) are fully
    captured because the recorded process is attached to a real
    pseudo-terminal. Console resizes that happen while recording are
    forwarded to the pseudo-console and recorded as 'r' events.
 
    START/STOP WORKFLOW
 
    Without -Command, the cmdlet starts a recorded interactive shell
    (pwsh if available, otherwise powershell/cmd.exe on Windows, $SHELL on
    Unix). On Windows the nested pwsh auto-imports Bsn.Host, so the
    Start-Transcript-like workflow is:
 
        Start-InteractiveTranscript # enter the recorded session
        <anything, including TUI apps> # everything is recorded
        Stop-InteractiveTranscript # finalize and return to the outer shell
 
    Typing 'exit' in the nested shell finalizes the recording as well, and
    Ctrl+C aborts it while keeping the cast file playable (it is written
    incrementally). With -Command, recording ends when the command exits.
 
    What a module cannot do - and why: transparently recording the CURRENT
    outer PowerShell session (the way Start-Transcript does) would require
    intercepting the host's console I/O (CONIN$/CONOUT$ on Windows,
    /dev/tty on Unix). PowerShell exposes no public hook, event or handler
    for that at the C# API level; only the host process itself can (the
    subject of PowerShell-RFC#421). The recorded nested session is the
    closest a PowerShell Gallery module can deliver.
 
.PARAMETER Command
    The command line to record. When omitted, an interactive shell session
    is recorded. Recording stops when the command or shell exits, when
    Stop-InteractiveTranscript runs inside it, or on Ctrl+C.
 
.PARAMETER Path
    The asciicast v2 (.cast) file to write. Defaults to
    InteractiveTranscript_<timestamp>.cast in the temporary directory. The
    cast file is always written and is the source of truth; any video file
    is a render product of it (RFC 421).
 
.PARAMETER RecordingFormat
    The deliverable format (RFC 421), typed as the BsnRecordingFormat enum
    (accepts the names Asciicast, Gif, WebM, Mp4):
    - Asciicast (default): write only the .cast file.
    - Gif: render an animated GIF via the external 'agg' renderer.
    - WebM / Mp4: render via 'agg' and transcode via 'ffmpeg'.
    Video output derives its file name from -Path by replacing the
    extension. When a required external tool is not on PATH, a warning is
    emitted and the .cast file remains complete and valid.
 
.PARAMETER RecordInput
    Also records keystrokes sent to the recorded process as 'i' events.
    Disabled by default: captured input includes passwords and other
    secrets typed at hidden prompts (RFC 421 security note); when enabled,
    the fact is recorded in the cast file header. Windows only in this
    release.
 
.PARAMETER CastVersion
    The asciicast format version written: 2 (default) or 3. v3 matches the
    current PowerShell-RFC#421 draft - it stores per-event intervals at
    millisecond precision, carries the terminal size in the header's term
    object, and finalizes the stream with an 'x' exit-status event. v2
    remains the default for compatibility with the widest set of players
    and renderers. Show-InteractiveTranscript reads both versions.
 
.PARAMETER RecordIdleTimeLimit
    When greater than zero (seconds), idle periods between events longer
    than this limit are time-compressed in the cast timing so playback and
    video renders of long, idle-heavy sessions stay watchable (RFC 421).
    Zero (default) keeps original timing.
 
.PARAMETER ShowRecordingIndicator
    Customizes the nested shell's prompt to show a recording indicator
    (a dot glyph plus 'REC PS <path>> ') so it is visible that the session
    is being recorded. Optional, off by default, Windows only, and applies
    only to the interactive shell mode (when -Command is omitted).
 
.PARAMETER Force
    Overwrites the cast file if it already exists.
 
.INPUTS
    None
 
.OUTPUTS
    Bsn.Host.InteractiveTranscript
        An object with CastPath, ExitCode (of the recorded process, also
        propagated to $LASTEXITCODE), DurationSec, StoppedByRequest and
        VideoPath (the rendered video file, or $null when recording
        asciicast only or when rendering was skipped).
 
.EXAMPLE
    Start-InteractiveTranscript
 
    Enters a recorded interactive shell session (Start-Transcript-like
    workflow). Run Stop-InteractiveTranscript (or 'exit') to finalize.
 
.EXAMPLE
    Start-InteractiveTranscript -Command 'sconfig'
 
    Records the full-screen sconfig tool; recording ends when it exits.
 
.EXAMPLE
    Start-InteractiveTranscript -ShowRecordingIndicator
 
    Enters a recorded interactive shell whose prompt shows a recording
    indicator, so it is always visible that the session is recorded.
 
.EXAMPLE
    Start-InteractiveTranscript -Command 'nmtui' -Path .\demo\intro -RecordingFormat WebM -RecordIdleTimeLimit 5
 
    Records nmtui, compresses idle gaps longer than five seconds, and
    renders a .webm video (requires agg and ffmpeg on PATH).
 
.NOTES
    Environment constraints are checked before recording starts and
    reported explicitly:
    - Windows requires Windows 10 1809 / Server 2019+ (pseudo-console); on
      older versions the cmdlet fails with a platform-not-supported error.
    - Unix requires the script(1) utility on PATH.
    - When the calling process has redirected standard I/O (CI, scripted
      harnesses), a warning is emitted: Windows may route the recorded
      process output around the pseudo-console. Run from an interactive
      console session for full capture.
    - Non-console hosts receive a warning that recording may not work.
 
    Implements https://github.com/PowerShell/PowerShell/issues/28353
    following the parameter and format semantics of PowerShell-RFC#421 at
    module scope.
#>

function Start-InteractiveTranscript {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Position = 0)]
        [string]$Command,

        [string]$Path,

        [BsnRecordingFormat]$RecordingFormat = [BsnRecordingFormat]::Asciicast,

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

        [ValidateRange(0, [int]::MaxValue)]
        [int]$RecordIdleTimeLimit = 0,

        [switch]$RecordInput,

        [switch]$ShowRecordingIndicator,

        [switch]$Force
    )

    process {
        $platform = Get-BsnOperatingSystem

        if (-not $Path) {
            $Path = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('InteractiveTranscript_{0}.cast' -f (Get-Date -Format 'yyyyMMdd_HHmmss'))
        }

        $castDirectory = Split-Path -Parent -Path $Path
        if ($castDirectory -and (-not (Test-Path -LiteralPath $castDirectory))) {
            throw ("The directory '{0}' does not exist." -f $castDirectory)
        }

        if ((Test-Path -LiteralPath $Path) -and -not $Force.IsPresent) {
            throw ("The cast file '{0}' already exists. Use -Force to overwrite it." -f $Path)
        }

        # Environment constraint checks with explicit messages (RFC 421 platform contract).
        Test-BsnRecordingEnvironment -Platform $platform

        $resolvedCastPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)

        if (-not $PSCmdlet.ShouldProcess($resolvedCastPath, 'Record interactive terminal session')) {
            return
        }

        if ($ShowRecordingIndicator.IsPresent -and $Command) {
            Write-Warning -Message '-ShowRecordingIndicator applies only to the interactive shell mode (used when -Command is omitted); the indicator is not applied to command recordings.'
        }
        if ($ShowRecordingIndicator.IsPresent -and ($platform -ne [BsnOperatingSystem]::Windows)) {
            Write-Warning -Message '-ShowRecordingIndicator is supported on Windows only (the nested pwsh prompt is customized); it is ignored on Unix platforms.'
        }

        $shellName = if ($Command) {
            ($Command -split '\s+')[0]
        }
        else {
            Split-Path -Leaf -Path (Get-BsnDefaultShell)
        }

        # Stop signaling channel: Stop-InteractiveTranscript (running inside the recorded
        # session) creates this file; the recording engine finalizes and returns.
        $previousStopFile = $env:BsnHostRecordingStopFile
        $stopFilePath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('BsnHostRecordingStop_{0}.stop' -f [guid]::NewGuid().ToString('N'))
        $env:BsnHostRecordingStopFile = $stopFilePath

        try {
            if ($platform -eq [BsnOperatingSystem]::Windows) {
                $commandLine = Get-BsnRecordingCommandLine -Command $Command -ShowRecordingIndicator:$ShowRecordingIndicator.IsPresent
                $result = Start-BsnWindowsPtyRecording -CommandLine $commandLine -CastPath $resolvedCastPath -RecordInput:$RecordInput.IsPresent -ShellName $shellName -StopFilePath $stopFilePath -CastVersion $CastVersion
            }
            else {
                # Linux and macOS are both Unix: they share the script(1)-based engine,
                # which picks util-linux (exact timing) or BSD script (approximated) itself.
                $result = Start-BsnUnixPtyRecording -Command $Command -CastPath $resolvedCastPath -RecordInput:$RecordInput.IsPresent -ShellName $shellName -StopFilePath $stopFilePath -CastVersion $CastVersion
            }
        }
        finally {
            $env:BsnHostRecordingStopFile = $previousStopFile
            if (Test-Path -LiteralPath $stopFilePath) {
                Remove-Item -LiteralPath $stopFilePath -Force -ErrorAction 'SilentlyContinue'
            }
        }

        if ($RecordIdleTimeLimit -gt 0) {
            Optimize-BsnCastIdleTime -Path $resolvedCastPath -LimitSec $RecordIdleTimeLimit
        }

        $videoPath = $null
        if ($RecordingFormat -ne [BsnRecordingFormat]::Asciicast) {
            $videoPath = Invoke-BsnCastRender -CastPath $resolvedCastPath -Format $RecordingFormat
        }

        # Propagate the recorded process exit code like a native command would.
        Set-Variable -Name 'LASTEXITCODE' -Scope 'Global' -Value $result.ExitCode

        [pscustomobject]@{
            PSTypeName       = 'Bsn.Host.InteractiveTranscript'
            CastPath         = $resolvedCastPath
            ExitCode         = $result.ExitCode
            DurationSec      = [math]::Round($result.DurationSec, 3)
            StoppedByRequest = $result.StoppedByRequest
            VideoPath        = $videoPath
        }
    }
}