diff --git a/source/Shellfish/IInputSource.cs b/source/Shellfish/IInputSource.cs new file mode 100644 index 0000000..b37f547 --- /dev/null +++ b/source/Shellfish/IInputSource.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Octopus.Shellfish; + +public interface IInputSource +{ + IEnumerable GetInput(); +} \ No newline at end of file diff --git a/source/Shellfish/ShellCommand.cs b/source/Shellfish/ShellCommand.cs index 2063daa..32caecd 100644 --- a/source/Shellfish/ShellCommand.cs +++ b/source/Shellfish/ShellCommand.cs @@ -26,6 +26,7 @@ public class ShellCommand List? stdOutTargets; List? stdErrTargets; + IInputSource? stdInSource; public ShellCommand(string executable) { @@ -138,18 +139,24 @@ public ShellCommand WithStdErrTarget(IOutputTarget target) return this; } + public ShellCommand WithStdInSource(IInputSource source) + { + stdInSource = source; + return this; + } + /// /// Launches the process and synchronously waits for it to exit. /// public ShellCommandResult Execute(CancellationToken cancellationToken = default) { using var process = new Process(); - ConfigureProcess(process, out var shouldBeginOutputRead, out var shouldBeginErrorRead); + ConfigureProcess(process, out var shouldBeginOutputRead, out var shouldBeginErrorRead, out var redirectingStdIn); var exitedEvent = AttachProcessExitedManualResetEvent(process, cancellationToken); process.Start(); - BeginIoStreams(process, shouldBeginOutputRead, shouldBeginErrorRead); + BeginIoStreams(process, shouldBeginOutputRead, shouldBeginErrorRead, redirectingStdIn); try { @@ -185,12 +192,12 @@ public ShellCommandResult Execute(CancellationToken cancellationToken = default) public async Task ExecuteAsync(CancellationToken cancellationToken = default) { using var process = new Process(); - ConfigureProcess(process, out var shouldBeginOutputRead, out var shouldBeginErrorRead); + ConfigureProcess(process, out var shouldBeginOutputRead, out var shouldBeginErrorRead, out var redirectingStdIn); var exitedTask = AttachProcessExitedTask(process, cancellationToken); process.Start(); - BeginIoStreams(process, shouldBeginOutputRead, shouldBeginErrorRead); + BeginIoStreams(process, shouldBeginOutputRead, shouldBeginErrorRead, redirectingStdIn); try { @@ -216,7 +223,7 @@ public async Task ExecuteAsync(CancellationToken cancellatio } // sets standard flags on the Process that apply for both Execute and ExecuteAsync - void ConfigureProcess(Process process, out bool shouldBeginOutputRead, out bool shouldBeginErrorRead) + void ConfigureProcess(Process process, out bool shouldBeginOutputRead, out bool shouldBeginErrorRead, out bool redirectingStdIn) { process.StartInfo.FileName = executable; @@ -264,7 +271,7 @@ void ConfigureProcess(Process process, out bool shouldBeginOutputRead, out bool } } - shouldBeginOutputRead = shouldBeginErrorRead = false; + shouldBeginOutputRead = shouldBeginErrorRead = redirectingStdIn = false; if (stdOutTargets is { Count: > 0 }) { process.StartInfo.RedirectStandardOutput = true; @@ -292,15 +299,41 @@ void ConfigureProcess(Process process, out bool shouldBeginOutputRead, out bool foreach (var target in targets) target.WriteLine(e.Data); }; } + + if (stdInSource is not null) + { + process.StartInfo.RedirectStandardInput = true; + redirectingStdIn = true; + } } // Common code for Execute and ExecuteAsync to handle stdin and stdout streaming void BeginIoStreams(Process process, bool shouldBeginOutputRead, - bool shouldBeginErrorRead) + bool shouldBeginErrorRead, + bool redirectingStdIn) { if (shouldBeginOutputRead) process.BeginOutputReadLine(); if (shouldBeginErrorRead) process.BeginErrorReadLine(); + + if (redirectingStdIn) + { + try + { + foreach (var val in stdInSource!.GetInput()) + { + process.StandardInput.Write(val); + } + } + catch (OperationCanceledException) + { + // gracefully handle cancellation of the enumerator + } + finally + { + process.StandardInput.Close(); + } + } } static async Task FinalWaitForExitAsync(Process process, CancellationToken cancellationToken) diff --git a/source/Shellfish/StringInputSource.cs b/source/Shellfish/StringInputSource.cs new file mode 100644 index 0000000..cf9e6c2 --- /dev/null +++ b/source/Shellfish/StringInputSource.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Octopus.Shellfish; + +class StringInputSource(string value) : IInputSource +{ + public IEnumerable GetInput() => [value]; +} + +public static class StringInputSourceExtensions +{ + public static ShellCommand WithStdInSource(this ShellCommand shellCommand, string input) + { + shellCommand.WithStdInSource(new StringInputSource(input)); + return shellCommand; + } +} \ No newline at end of file diff --git a/source/Tests/Plumbing/TempScript.cs b/source/Tests/Plumbing/TempScript.cs new file mode 100644 index 0000000..1ee7385 --- /dev/null +++ b/source/Tests/Plumbing/TempScript.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Tests.Plumbing; + +public static class TempScript +{ + // Some interactions such as stdout or encoding codepages require things that don't work with an inline cmd /c or bash -c command + // This helper writes a script file into the temp directory so we can exercise more complex scenarios + public static Handle Create(string cmd, string sh) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".cmd"); + File.WriteAllText(tempFile, cmd); + return new Handle(tempFile); + } + else + { + var tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".sh"); + File.WriteAllText(tempFile, sh.Replace("\r\n", "\n")); + return new Handle(tempFile); + } + } + + public class Handle(string scriptPath) : IDisposable + { + public string ScriptPath { get; } = scriptPath; + + public void Dispose() + { + try + { + File.Delete(ScriptPath); + } + catch + { + // nothing to do if we can't delete the temp file + } + } + + // Returns the host application which will run the script. Either cmd.exe or bash + public string GetHostExecutable() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "bash"; + } + + // Returns the command line args to get the host application to run the script + // For cmd.exe, returns ["/c", ScriptPath] as it needs /c + // For bash, returns [ScriptPath] as it doesn't need any preamble + public string[] GetCommandArgs() + { + // when running cmd.exe we need /c to tell it to run the script; bash doesn't want any preamble for a script file + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ["/c", ScriptPath] + : [ScriptPath]; + + } + } +} \ No newline at end of file diff --git a/source/Tests/PublicSurfaceArea.TheLibraryOnlyExposesWhatWeWantItToExpose.approved.txt b/source/Tests/PublicSurfaceArea.TheLibraryOnlyExposesWhatWeWantItToExpose.approved.txt index 8ae9b51..f919246 100644 --- a/source/Tests/PublicSurfaceArea.TheLibraryOnlyExposesWhatWeWantItToExpose.approved.txt +++ b/source/Tests/PublicSurfaceArea.TheLibraryOnlyExposesWhatWeWantItToExpose.approved.txt @@ -2,6 +2,7 @@ Octopus.Shellfish.ShellCommandExtensionMethods.WithStdOutTarget Octopus.Shellfish.ShellCommandExtensionMethods.WithStdErrTarget Octopus.Shellfish.ShellCommandExtensionMethods.WithStdOutTarget Octopus.Shellfish.ShellCommandExtensionMethods.WithStdErrTarget +Octopus.Shellfish.IInputSource.GetInput Octopus.Shellfish.IOutputTarget.WriteLine Octopus.Shellfish.ShellCommand.WithWorkingDirectory Octopus.Shellfish.ShellCommand.WithArguments @@ -11,10 +12,12 @@ Octopus.Shellfish.ShellCommand.WithCredentials Octopus.Shellfish.ShellCommand.WithOutputEncoding Octopus.Shellfish.ShellCommand.WithStdOutTarget Octopus.Shellfish.ShellCommand.WithStdErrTarget +Octopus.Shellfish.ShellCommand.WithStdInSource Octopus.Shellfish.ShellCommand.Execute Octopus.Shellfish.ShellCommand.ExecuteAsync Octopus.Shellfish.ShellCommandResult.ExitCode Octopus.Shellfish.ShellExecutionException.Errors Octopus.Shellfish.ShellExecutionException.Message Octopus.Shellfish.ShellExecutor.ExecuteCommand -Octopus.Shellfish.ShellExecutor.ExecuteCommandWithoutWaiting \ No newline at end of file +Octopus.Shellfish.ShellExecutor.ExecuteCommandWithoutWaiting +Octopus.Shellfish.StringInputSourceExtensions.WithStdInSource \ No newline at end of file diff --git a/source/Tests/ShellCommandFixture.StdIn.cs b/source/Tests/ShellCommandFixture.StdIn.cs new file mode 100644 index 0000000..138193a --- /dev/null +++ b/source/Tests/ShellCommandFixture.StdIn.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Octopus.Shellfish; +using Tests.Plumbing; +using Xunit; + +namespace Tests; + +public class ShellCommandFixtureStdIn +{ + [Theory, InlineData(SyncBehaviour.Sync), InlineData(SyncBehaviour.Async)] + public async Task ShouldWork(SyncBehaviour behaviour) + { + using var tempScript = TempScript.Create( + cmd: """ + @echo off + echo Enter First Name: + set /p firstname= + echo Hello %firstname% + """, + sh: """ + echo "Enter First Name:" + read firstname + echo "Hello $firstname" + """); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + var executor = new ShellCommand(tempScript.GetHostExecutable()) + .WithArguments(tempScript.GetCommandArgs()) + .WithStdInSource("Bob") // it's going to ask us for the names, we need to answer back or the process will stall forever; we can preload this + .WithStdOutTarget(stdOut) + .WithStdErrTarget(stdErr); + + var result = behaviour == SyncBehaviour.Async + ? await executor.ExecuteAsync(CancellationToken.None) + : executor.Execute(CancellationToken.None); + + result.ExitCode.Should().Be(0, "the process should have run to completion"); + stdErr.ToString().Should().BeEmpty("no messages should be written to stderr"); + stdOut.ToString().Should().Be("Enter First Name:" + Environment.NewLine + "Hello Bob" + Environment.NewLine); + } + + [Theory, InlineData(SyncBehaviour.Sync), InlineData(SyncBehaviour.Async)] + public async Task MultipleInputItemsShouldWork(SyncBehaviour behaviour) + { + using var tempScript = TempScript.Create( + cmd: """ + @echo off + echo Enter First Name: + set /p firstname= + echo Enter Last Name: + set /p lastname= + echo Hello %firstname% %lastname% + """, + sh: """ + echo "Enter First Name:" + read firstname + echo "Enter Last Name:" + read lastname + echo "Hello $firstname $lastname" + """); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + // it's going to ask us for the names, we need to answer back or the process will stall forever; we can preload this + var stdIn = new TestInputSource(); + + var executor = new ShellCommand(tempScript.GetHostExecutable()) + .WithArguments(tempScript.GetCommandArgs()) + .WithStdInSource(stdIn) + .WithStdOutTarget(stdOut) + .WithStdOutTarget(l => + { + if (l.Contains("First")) stdIn.AppendLine("Bob"); + if (l.Contains("Last")) + { + stdIn.AppendLine("Octopus"); + stdIn.Complete(); + } + }) + .WithStdErrTarget(stdErr); + + var result = behaviour == SyncBehaviour.Async + ? await executor.ExecuteAsync(CancellationToken.None) + : executor.Execute(CancellationToken.None); + + result.ExitCode.Should().Be(0, "the process should have run to completion"); + stdErr.ToString().Should().BeEmpty("no messages should be written to stderr"); + stdOut.ToString().Should().Be("Enter First Name:" + Environment.NewLine + "Enter Last Name:" + Environment.NewLine + "Hello Bob Octopus" + Environment.NewLine); + } + + [Theory, InlineData(SyncBehaviour.Sync), InlineData(SyncBehaviour.Async)] + public async Task ClosingStdInEarly(SyncBehaviour behaviour) + { + using var tempScript = TempScript.Create( + cmd: """ + @echo off + echo Enter First Name: + set /p firstname= + echo Enter Last Name: + set /p lastname= + echo Hello %firstname% %lastname% + """, + sh: """ + echo "Enter First Name:" + read firstname + echo "Enter Last Name:" + read lastname + echo "Hello $firstname $lastname" + """); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + // it's going to ask us for the names, we need to answer back or the process will stall forever; we can preload this + var stdIn = new TestInputSource(); + + var executor = new ShellCommand(tempScript.GetHostExecutable()) + .WithArguments(tempScript.GetCommandArgs()) + .WithStdInSource(stdIn) + .WithStdOutTarget(stdOut) + .WithStdOutTarget(l => + { + if (l.Contains("First")) stdIn.AppendLine("Bob"); + if (l.Contains("Last")) stdIn.Complete(); // shut it down + }) + .WithStdErrTarget(stdErr); + + var result = behaviour == SyncBehaviour.Async + ? await executor.ExecuteAsync(CancellationToken.None) + : executor.Execute(CancellationToken.None); + + result.ExitCode.Should().Be(0, "the process should have run to completion"); + stdErr.ToString().Should().BeEmpty("no messages should be written to stderr"); + // When we close stdin the waiting process receives an EOF; Our trivial shell script interprets this as an empty string + stdOut.ToString().Should().Be("Enter First Name:" + Environment.NewLine + "Enter Last Name:" + Environment.NewLine + "Hello Bob " + Environment.NewLine); + } + + [Theory, InlineData(SyncBehaviour.Sync), InlineData(SyncBehaviour.Async)] + public async Task ShouldBeCancellable(SyncBehaviour behaviour) + { + using var tempScript = TempScript.Create( + cmd: """ + @echo off + echo Enter Name: + set /p name= + echo Hello %name% + """, + sh: """ + echo "Enter Name:" + read name + echo "Hello $name" + """); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + using var cts = new CancellationTokenSource(); + // it's going to ask us for the name first, but we don't give it anything; the script should hang + var stdIn = new TestInputSource(cts.Token); + + var executor = new ShellCommand(tempScript.GetHostExecutable()) + .WithArguments(tempScript.GetCommandArgs()) + .WithStdInSource(stdIn) + .WithStdOutTarget(stdOut) + .WithStdOutTarget(l => + { + // when we receive the first prompt, cancel and kill the process + if (l.Contains("Enter Name:")) cts.Cancel(); + }) + .WithStdErrTarget(stdErr); + + var result = behaviour == SyncBehaviour.Async + ? await executor.ExecuteAsync(cts.Token) + : executor.Execute(cts.Token); + + // Our process was waiting on stdin and exits itself with code 0 when we close stdin, + // but we cannot 100% guarantee it shuts down in time before we proceed to killing it; we could observe -1 too. + // Whenever I've run this locally on windows or linux I always observe 0. + result.ExitCode.Should().BeOneOf([0, -1], "The process should exit cleanly when stdin is closed, but we might kill depending on timing"); + stdErr.ToString().Should().BeEmpty("no messages should be written to stderr"); + stdOut.ToString().Should().Be("Enter Name:" + Environment.NewLine); + } +} + +public class TestInputSource(CancellationToken? cancellationToken = null) : IInputSource +{ + readonly BlockingCollection collection = new(); + + public void AppendLine(string line) + { + collection.Add(line + Environment.NewLine); + } + + public void Complete() + { + collection.CompleteAdding(); + } + + public IEnumerable GetInput() => collection.GetConsumingEnumerable(cancellationToken ?? CancellationToken.None); +} \ No newline at end of file