From a1459457abbb0c630b9313770e3a09e0a9d60764 Mon Sep 17 00:00:00 2001 From: Florian Verdonck Date: Fri, 11 Sep 2026 09:39:38 +0200 Subject: [PATCH] Add RunCommandCaptureAll to expose exit code, stdout and stderr #93 RunCommandCaptureOutput returns Result and decides for the caller whether the run succeeded. The exit code, the standard output on a non acceptable exit code and the standard error were all lost on the way. Add RunCommandCaptureAll and RunSensitiveCommandCaptureAll which return a CommandOutput record with ExitCode, StandardOutput and StandardError, whatever the exit code was, so the caller can decide what to do. Process.StartAsync redirected standard error but never called BeginErrorReadLine, so stderr was silently dropped whenever output was prefixed, silenced or captured. It is now read, printed like stdout and kept in its own buffer. The end of stream null was appended as an extra empty line to captured output. It is now skipped, so "echo 42" yields "42\n" instead of "42\n\n". --- Fun.Build.Tests/StageContextExtensions.fs | 90 +++++++++++++++++++++- Fun.Build/BuiltinCmds.fs | 91 +++++++++++++++++++++++ Fun.Build/CHANGELOG.md | 4 + Fun.Build/ProcessExtensions.fs | 32 ++++---- Fun.Build/Types.fs | 8 ++ README.md | 5 ++ 6 files changed, 213 insertions(+), 17 deletions(-) diff --git a/Fun.Build.Tests/StageContextExtensions.fs b/Fun.Build.Tests/StageContextExtensions.fs index 9831d44..6766521 100644 --- a/Fun.Build.Tests/StageContextExtensions.fs +++ b/Fun.Build.Tests/StageContextExtensions.fs @@ -112,14 +112,14 @@ let ``RunCommandCaptureOutput should work`` () = } run (fun ctx -> async { let! result = ctx.RunCommandCaptureOutput "echo 42" - Assert.Equal(Ok "42\n\n", result) + Assert.Equal(Ok "42\n", result) }) } stage "" { whenWindows run (fun ctx -> async { let! result = ctx.RunCommandCaptureOutput "powershell echo 42" - Assert.Equal(Ok "42\r\n\r\n", result) + Assert.Equal(Ok "42\r\n", result) }) } runImmediate @@ -136,14 +136,14 @@ let ``RunCommandCaptureOutput in working directory should work`` () = run (fun ctx -> async { let tmpFolder = System.IO.Path.GetTempPath() let! result = ctx.RunCommandCaptureOutput("echo 42", workingDir = tmpFolder) - Assert.Equal(Ok "42\n\n", result) + Assert.Equal(Ok "42\n", result) }) } stage "" { whenWindows run (fun ctx -> async { let! result = ctx.RunCommandCaptureOutput "powershell echo 42" - Assert.Equal(Ok "42\r\n\r\n", result) + Assert.Equal(Ok "42\r\n", result) }) } runImmediate @@ -167,6 +167,88 @@ let ``RunCommandCaptureOutput should return an error if command failed`` () = |> ignore +[] +let ``RunCommandCaptureAll should return exit code, stdout and stderr`` () = + pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureAll "sh -c \"echo out; echo err >&2; exit 3\"" + Assert.Equal(3, result.ExitCode) + Assert.Equal("out\n", result.StandardOutput) + Assert.Equal("err\n", result.StandardError) + }) + } + stage "" { + whenWindows + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureAll "powershell -Command \"echo out; [Console]::Error.WriteLine('err'); exit 3\"" + Assert.Equal(3, result.ExitCode) + Assert.Equal("out\r\n", result.StandardOutput) + Assert.Equal("err\r\n", result.StandardError) + }) + } + runImmediate + } + +[] +let ``RunCommandCaptureAll should not fail the stage on a non zero exit code`` () = + shouldBeCalled (fun call -> + pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureAll "sh -c \"exit 1\"" + Assert.Equal(1, result.ExitCode) + call () + }) + } + stage "" { + whenWindows + run (fun ctx -> async { + let! result = ctx.RunCommandCaptureAll "powershell -Command \"exit 1\"" + Assert.Equal(1, result.ExitCode) + call () + }) + } + runImmediate + } + ) + +[] +let ``RunSensitiveCommandCaptureAll should work`` () = + pipeline "" { + stage "" { + whenAny { + platformOSX + platformLinux + } + run (fun ctx -> async { + let! result = ctx.RunSensitiveCommandCaptureAll $"""echo {"42"}""" + Assert.Equal(0, result.ExitCode) + Assert.Equal("42\n", result.StandardOutput) + Assert.Equal("", result.StandardError) + }) + } + stage "" { + whenWindows + run (fun ctx -> async { + let! result = ctx.RunSensitiveCommandCaptureAll $"""powershell echo {"42"}""" + Assert.Equal(0, result.ExitCode) + Assert.Equal("42\r\n", result.StandardOutput) + Assert.Equal("", result.StandardError) + }) + } + runImmediate + } + + [] let ``Soft cancel should work`` () = let mutable i = 0 diff --git a/Fun.Build/BuiltinCmds.fs b/Fun.Build/BuiltinCmds.fs index b147e02..a7c4913 100644 --- a/Fun.Build/BuiltinCmds.fs +++ b/Fun.Build/BuiltinCmds.fs @@ -40,6 +40,47 @@ module BuiltinCmdsInternal = command + /// Run a command and hand back exit code, standard output and standard error, whatever the exit code was. + member ctx.RunCommandCaptureAllInternal + ( + commandStr: string, + commandLogString: string, + ?step: int, + ?workingDir: string, + ?disablePrintOutput: bool, + ?disablePrintCommand: bool, + ?cancellationToken: CancellationToken + ) : Async = + async { + let disablePrintOutput = defaultArg disablePrintOutput false + let disablePrintCommand = defaultArg disablePrintCommand false + let command = ctx.BuildCommand(commandStr, ?workingDir = workingDir) + let noPrefixForStep = ctx.GetNoPrefixForStep() + let prefix = + if noPrefixForStep then + "" + else + match step with + | Some i -> ctx.BuildStepPrefix i + | None -> ctx.GetNamePath() + + if not noPrefixForStep then AnsiConsole.Markup $"[green]{prefix}[/] " + if not disablePrintCommand then AnsiConsole.WriteLine commandLogString + + let ct = defaultArg cancellationToken CancellationToken.None + + return! + Process.StartAsync( + command, + commandLogString, + prefix, + printOutput = (not disablePrintOutput && not (ctx.GetNoStdRedirectForStep())), + captureOutput = true, + cancellationToken = ct + ) + } + + /// Add command to context member ctx.AddCommandStep(commandStrFn: StageContext -> Async, ?cancellationToken: CancellationToken) = { ctx with @@ -207,6 +248,56 @@ module BuiltinCmds = return Error "Exit code is not indicating as successful." } + /// + /// Run a command string with current context, and return the exit code, standard output and standard error + /// whatever the exit code was. Unlike RunCommandCaptureOutput, this does not decide whether the run succeeded. + /// + /// Command to run + /// Current step rank + /// Working directory for command + member ctx.RunCommandCaptureAll + ( + commandStr: string, + ?step: int, + ?workingDir: string, + ?disablePrintOutput: bool, + ?disablePrintCommand: bool, + ?cancellationToken: CancellationToken + ) : Async = + ctx.RunCommandCaptureAllInternal( + commandStr, + commandStr, + ?step = step, + ?workingDir = workingDir, + ?disablePrintOutput = disablePrintOutput, + ?disablePrintCommand = disablePrintCommand, + ?cancellationToken = cancellationToken + ) + + + /// Same as RunCommandCaptureAll, but encrypt the string for logging + member ctx.RunSensitiveCommandCaptureAll + ( + commandStr: FormattableString, + ?step: int, + ?workingDir: string, + ?disablePrintOutput: bool, + ?disablePrintCommand: bool, + ?cancellationToken: CancellationToken + ) : Async = + let args: obj[] = Array.create commandStr.ArgumentCount "*" + let encryptiedStr = String.Format(commandStr.Format, args) + ctx.RunCommandCaptureAllInternal( + commandStr.ToString(), + encryptiedStr, + ?step = step, + ?workingDir = workingDir, + ?disablePrintOutput = disablePrintOutput, + ?disablePrintCommand = disablePrintCommand, + ?cancellationToken = cancellationToken + ) + + /// Run a command string with current context, and encrypt the string for logging member ctx.RunSensitiveCommand ( diff --git a/Fun.Build/CHANGELOG.md b/Fun.Build/CHANGELOG.md index 1d112ce..d3df5c5 100644 --- a/Fun.Build/CHANGELOG.md +++ b/Fun.Build/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- Add `RunCommandCaptureAll` and `RunSensitiveCommandCaptureAll` which return a `CommandOutput` record with the exit code, standard output and standard error, whatever the exit code was #93 +- Fix: standard error was never read when output is redirected (prefixed, silenced or captured), so it was lost. It is now printed like standard output. +- Fix: captured standard output no longer ends with an extra empty line + ## [1.1.18] - 2026-08-31 - Fix: help without -p runs the default pipeline instead of printing help diff --git a/Fun.Build/ProcessExtensions.fs b/Fun.Build/ProcessExtensions.fs index 52dedfa..e2b4b9e 100644 --- a/Fun.Build/ProcessExtensions.fs +++ b/Fun.Build/ProcessExtensions.fs @@ -132,18 +132,21 @@ type Process with use result = Process.Start startInfo let standardOutputSb = StringBuilder() - - let handleDataReceived (ev: DataReceivedEventArgs) = - if captureOutput then standardOutputSb.AppendLine ev.Data |> ignore - if printOutput && not (String.IsNullOrEmpty ev.Data) then - if noPrefix then - Console.WriteLine(ev.Data) - else - Console.WriteLine(logPrefix + " " + ev.Data) + let standardErrorSb = StringBuilder() + + let handleDataReceived (sb: StringBuilder) (ev: DataReceivedEventArgs) = + // Data is null once the stream is closed + if ev.Data <> null then + if captureOutput then sb.AppendLine ev.Data |> ignore + if printOutput && not (String.IsNullOrEmpty ev.Data) then + if noPrefix then + Console.WriteLine(ev.Data) + else + Console.WriteLine(logPrefix + " " + ev.Data) if shouldRedirectOutput then - result.OutputDataReceived.Add handleDataReceived - result.ErrorDataReceived.Add handleDataReceived + result.OutputDataReceived.Add(handleDataReceived standardOutputSb) + result.ErrorDataReceived.Add(handleDataReceived standardErrorSb) use! cd = Async.OnCancel(fun _ -> @@ -165,12 +168,15 @@ type Process with member _.Dispose() = () } - if shouldRedirectOutput then result.BeginOutputReadLine() + if shouldRedirectOutput then + result.BeginOutputReadLine() + result.BeginErrorReadLine() result.WaitForExit() - return struct {| + return { ExitCode = result.ExitCode StandardOutput = standardOutputSb.ToString() - |} + StandardError = standardErrorSb.ToString() + } } diff --git a/Fun.Build/Types.fs b/Fun.Build/Types.fs index ccb2714..2de1ac4 100644 --- a/Fun.Build/Types.fs +++ b/Fun.Build/Types.fs @@ -14,6 +14,14 @@ type PipelineFailedException = new(msg: string, ex: exn) = { inherit Exception(msg, ex) } +/// Everything a finished command handed back. No judgement is made about whether the exit code is acceptable. +type CommandOutput = { + ExitCode: int + StandardOutput: string + StandardError: string +} + + [] type CmdName = | ShortName of shortName: string diff --git a/README.md b/README.md index 372f68c..0e1d847 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,11 @@ let demo1 = do! ctx.RunCommand "dotnet --version" do! ctx.RunCommand "dotnet --version" }) + // You can also capture the exit code, standard output and standard error and decide yourself what to do with them + run (fun ctx -> async { + let! output = ctx.RunCommandCaptureAll "dotnet --version" + printfn "exit code %d, stdout %s, stderr %s" output.ExitCode output.StandardOutput output.StandardError + }) // You can run async functions run (Async.Sleep 1000) run (fun _ -> Async.Sleep 1000)