Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions Fun.Build.Tests/StageContextExtensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -167,6 +167,88 @@ let ``RunCommandCaptureOutput should return an error if command failed`` () =
|> ignore


[<Fact>]
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
}

[<Fact>]
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
}
)

[<Fact>]
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
}


[<Fact>]
let ``Soft cancel should work`` () =
let mutable i = 0
Expand Down
91 changes: 91 additions & 0 deletions Fun.Build/BuiltinCmds.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandOutput> =
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<string>, ?cancellationToken: CancellationToken) =
{ ctx with
Expand Down Expand Up @@ -207,6 +248,56 @@ module BuiltinCmds =
return Error "Exit code is not indicating as successful."
}

/// <summary>
/// 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.
/// </summary>
/// <param name="commandStr">Command to run</param>
/// <param name="step">Current step rank</param>
/// <param name="workingDir">Working directory for command</param>
member ctx.RunCommandCaptureAll
(
commandStr: string,
?step: int,
?workingDir: string,
?disablePrintOutput: bool,
?disablePrintCommand: bool,
?cancellationToken: CancellationToken
) : Async<CommandOutput> =
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<CommandOutput> =
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
(
Expand Down
4 changes: 4 additions & 0 deletions Fun.Build/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 19 additions & 13 deletions Fun.Build/ProcessExtensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _ ->
Expand All @@ -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()
}
}
8 changes: 8 additions & 0 deletions Fun.Build/Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@
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
}


[<Struct; RequireQualifiedAccess>]

Check warning on line 25 in Fun.Build/Types.fs

View workflow job for this annotation

GitHub Actions / build

This attribute cannot be applied to struct. Valid targets are: class
type CmdName =
| ShortName of shortName: string
| LongName of longName: string
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading