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)