Public/Invoke-ParallelRun.ps1

function Invoke-ParallelRun {
    <#
    .SYNOPSIS
        Runs every service in a JSON profile in parallel, merges their live output into
        one terminal with a colored label per line, and tears the whole process tree
        down cleanly on 'q' or Ctrl+C.
 
    .DESCRIPTION
        Point it at a JSON profile describing an ordered list of services (a name, a
        shell command, and optionally a working directory, environment variables, and a
        color). Each service is launched in a hidden child pwsh, in the order it appears
        in the profile, with a short stagger so startup banners don't collide. All
        stdout/stderr from every service is multiplexed back into this one console, each
        line prefixed with a colored, column-aligned label so you can tell at a glance
        who said what.
 
        Stop everything with 'q', Ctrl+C, or by creating a `.stop-<profile-name>` file
        next to the profile — a backstop for terminals where key input can't be read
        (some CI runners, redirected consoles).
 
    .PARAMETER ProfilePath
        Path to a JSON profile file. See examples/profile.example.json for the schema.
 
    .EXAMPLE
        Invoke-ParallelRun -ProfilePath ./profile.json
 
        Starts every service in profile.json and streams their combined output until you
        press q or Ctrl+C.
 
    .NOTES
        Windows only today — teardown shells out to `taskkill /T` to kill each service's
        whole process tree. A cross-platform (pkill-based) teardown is tracked as a
        follow-up; contributions welcome, see CONTRIBUTING.md.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [Alias('Profile')]
        [string]$ProfilePath
    )

    $resolved = Resolve-ParallelRunProfile -ProfilePath $ProfilePath
    $profileDir = $resolved.ProfileDir
    $svcs = $resolved.Services | ForEach-Object {
        [pscustomobject]@{ Name = $_.Name; Inner = $_.Inner; Color = $_.Color; Proc = $null }
    }

    # ── launch: each service runs in a hidden pwsh whose stdout/stderr stream back here ──
    # OutputDataReceived/ErrorDataReceived fire on background threads, so lines are pushed
    # onto a ConcurrentQueue; the main loop below drains it and prints each line prefixed
    # with its service's colored label.
    $queue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    $subscribers = @()
    $labelWidth = ($svcs | ForEach-Object { $_.Name.Length } | Measure-Object -Maximum).Maximum
    $lineFmt = "{0,-$labelWidth} | "
    $sink = {
        if ($null -ne $EventArgs.Data) {
            $m = $Event.MessageData
            $m.Q.Enqueue([pscustomobject]@{ Label = $m.Label; Color = $m.Color; Text = $EventArgs.Data; Err = $m.Err })
        }
    }

    try {
        if ($resolved.Name) { Write-Host "Profile: $($resolved.Name)" -ForegroundColor DarkGray }
        foreach ($s in $svcs) {
            $psi = [System.Diagnostics.ProcessStartInfo]::new()
            $psi.FileName = 'pwsh'
            $psi.ArgumentList.Add('-NoLogo')
            $psi.ArgumentList.Add('-NoProfile')
            $psi.ArgumentList.Add('-Command')
            $psi.ArgumentList.Add($s.Inner)
            $psi.WorkingDirectory = $profileDir
            $psi.UseShellExecute = $false
            $psi.CreateNoWindow = $true
            $psi.RedirectStandardOutput = $true
            $psi.RedirectStandardError = $true
            # Redirect stdin too, so a child (dotnet/npm/whatever) can't inherit + swallow
            # this console's keystrokes — otherwise 'q' / Ctrl+C get grabbed by a child and
            # never reach the key loop below. We never write to it.
            $psi.RedirectStandardInput = $true

            $proc = [System.Diagnostics.Process]::new()
            $proc.StartInfo = $psi

            $dOut = [pscustomobject]@{ Q = $queue; Label = $s.Name; Color = $s.Color; Err = $false }
            $dErr = [pscustomobject]@{ Q = $queue; Label = $s.Name; Color = $s.Color; Err = $true }
            $subscribers += Register-ObjectEvent -InputObject $proc -EventName OutputDataReceived -MessageData $dOut -Action $sink
            $subscribers += Register-ObjectEvent -InputObject $proc -EventName ErrorDataReceived  -MessageData $dErr -Action $sink

            $proc.Start() | Out-Null
            $proc.BeginOutputReadLine()
            $proc.BeginErrorReadLine()
            $s.Proc = $proc
            Write-Host (" started {0,-$labelWidth} pid {1}" -f $s.Name, $proc.Id) -ForegroundColor DarkGray
            Start-Sleep -Milliseconds 400   # small stagger so startup banners don't interleave
        }

        Write-Host ''
        Write-Host '────────────────────────────────────────────────────────────' -ForegroundColor DarkGray
        Write-Host " $($svcs.Count) services up. Combined output follows below." -ForegroundColor Green
        Write-Host ' Press q or Ctrl+C to stop everything (backstop: create a .stop-<profile> file next to the profile).' -ForegroundColor Green
        Write-Host '────────────────────────────────────────────────────────────' -ForegroundColor DarkGray

        # Stop on 'q', Ctrl+C, or a sentinel file. Ctrl+C is claimed via TreatControlCAsInput
        # so it arrives here as a plain key (deterministic teardown) instead of racing
        # PowerShell's default handler; children can't steal it since their stdin is
        # redirected above. The sentinel file is a backstop for hosts where the console
        # can't be read.
        $stopFile = Join-Path $profileDir ".stop-$($resolved.ProfileName)"
        if (Test-Path $stopFile) { Remove-Item $stopFile -Force -ErrorAction SilentlyContinue }
        $interactive = -not [Console]::IsInputRedirected
        if ($interactive) { try { [Console]::TreatControlCAsInput = $true } catch { $interactive = $false } }
        $item = $null
        while ($true) {
            while ($queue.TryDequeue([ref]$item)) {
                Write-Host ($lineFmt -f $item.Label) -ForegroundColor $item.Color -NoNewline
                if ($item.Err) { Write-Host $item.Text -ForegroundColor Red } else { Write-Host $item.Text }
            }
            if ($interactive -and [Console]::KeyAvailable) {
                $key = [Console]::ReadKey($true)
                $isCtrlC = ($key.Key -eq [ConsoleKey]::C) -and (($key.Modifiers -band [ConsoleModifiers]::Control) -ne 0)
                if ($key.Key -eq [ConsoleKey]::Q -or $isCtrlC) {
                    Write-Host ''
                    Write-Host ("Stopping ({0})..." -f $(if ($isCtrlC) { 'Ctrl+C' } else { 'q' })) -ForegroundColor Cyan
                    break
                }
            }
            if (Test-Path $stopFile) {
                Remove-Item $stopFile -Force -ErrorAction SilentlyContinue
                Write-Host ''
                Write-Host "Stopping (.stop-$($resolved.ProfileName))..." -ForegroundColor Cyan
                break
            }
            Start-Sleep -Milliseconds 120
        }
    }
    finally {
        try { [Console]::TreatControlCAsInput = $false } catch {}   # restore normal Ctrl+C for the terminal
        Write-Host ''
        Write-Host 'Shutting down services...' -ForegroundColor Cyan
        foreach ($sub in $subscribers) { Unregister-Event -SourceIdentifier $sub.Name -ErrorAction SilentlyContinue }
        foreach ($s in $svcs) {
            if ($s.Proc -and -not $s.Proc.HasExited) {
                # /T kills the whole tree (hidden pwsh -> the real process it launched -> its children).
                taskkill /PID $s.Proc.Id /T /F 2>$null | Out-Null
            }
        }
        Write-Host 'Done.' -ForegroundColor Cyan
    }
}