diff --git a/CliWrap.Immersive.Tests/CliWrap.Immersive.Tests.csproj b/CliWrap.Immersive.Tests/CliWrap.Immersive.Tests.csproj new file mode 100644 index 00000000..36fc8773 --- /dev/null +++ b/CliWrap.Immersive.Tests/CliWrap.Immersive.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + $(TargetFrameworks);net48 + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CliWrap.Immersive.Tests/ExecutionSpecs.cs b/CliWrap.Immersive.Tests/ExecutionSpecs.cs new file mode 100644 index 00000000..02050f34 --- /dev/null +++ b/CliWrap.Immersive.Tests/ExecutionSpecs.cs @@ -0,0 +1,67 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using static CliWrap.Immersive.Spells; +using Dummy = CliWrap.Tests.Dummy; + +namespace CliWrap.Immersive.Tests; + +public class ExecutionSpecs +{ + [Fact(Timeout = 15000)] + public async Task I_can_execute_a_command_with_magic_and_get_the_exit_code() + { + // Arrange + var cmd = Command(Dummy.Program.FilePath); + + // Act + int result = await cmd; + + // Assert + result.Should().Be(0); + } + + [Fact(Timeout = 15000)] + public async Task I_can_execute_a_command_with_magic_and_verify_that_it_succeeded() + { + // Arrange + var cmd = Command(Dummy.Program.FilePath); + + // Act + bool result = await cmd; + + // Assert + result.Should().BeTrue(); + } + + [Fact(Timeout = 15000)] + public async Task I_can_execute_a_command_with_magic_and_get_the_stdout() + { + // Arrange + var cmd = Command(Dummy.Program.FilePath, [ "echo", "Hello stdout" ]); + + // Act + string result = await cmd; + + // Assert + result.Trim().Should().Be("Hello stdout"); + } + + [Fact(Timeout = 15000)] + public async Task I_can_execute_a_command_with_magic_and_get_the_stdout_and_stderr() + { + // Arrange + var cmd = Command( + Dummy.Program.FilePath, + [ "echo", "Hello stdout and stderr", "--target", "all" ] + ); + + // Act + var (exitCode, stdOut, stdErr) = await cmd; + + // Assert + exitCode.Should().Be(0); + stdOut.Trim().Should().Be("Hello stdout and stderr"); + stdErr.Trim().Should().Be("Hello stdout and stderr"); + } +} diff --git a/CliWrap.Immersive.Tests/xunit.runner.json b/CliWrap.Immersive.Tests/xunit.runner.json new file mode 100644 index 00000000..f41def88 --- /dev/null +++ b/CliWrap.Immersive.Tests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "appDomain": "denied", + "methodDisplayOptions": "all", + "methodDisplay": "method" +} \ No newline at end of file diff --git a/CliWrap.Immersive/CliWrap.Immersive.csproj b/CliWrap.Immersive/CliWrap.Immersive.csproj new file mode 100644 index 00000000..8b4a5847 --- /dev/null +++ b/CliWrap.Immersive/CliWrap.Immersive.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.0;netstandard2.1;netcoreapp3.0;net462 + true + + + false + + + + Extension for CliWrap that provides a shell-like experience for executing commands + https://github.com/Tyrrrz/CliWrap/tree/master/CliWrap.Immersive + favicon.png + true + + + + + + + + + + + + + + + + + diff --git a/CliWrap.Immersive/ImmersiveCommandResult.cs b/CliWrap.Immersive/ImmersiveCommandResult.cs new file mode 100644 index 00000000..51d3fa9f --- /dev/null +++ b/CliWrap.Immersive/ImmersiveCommandResult.cs @@ -0,0 +1,45 @@ +using System; +using CliWrap.Buffered; + +namespace CliWrap.Immersive; + +/// +/// Result of a command execution, with buffered text data from standard output and standard error streams. +/// Includes additional operator overloads for convenience in immersive mode. +/// +public partial class ImmersiveCommandResult( + int exitCode, + DateTimeOffset startTime, + DateTimeOffset exitTime, + string standardOutput, + string standardError +) : BufferedCommandResult(exitCode, startTime, exitTime, standardOutput, standardError) +{ + /// + /// Deconstructs the result into its components. + /// + public void Deconstruct(out int exitCode, out string standardOutput, out string standardError) + { + exitCode = ExitCode; + standardOutput = StandardOutput; + standardError = StandardError; + } +} + +public partial class ImmersiveCommandResult +{ + /// + /// Converts the result to an integer value that corresponds to the property. + /// + public static implicit operator int(ImmersiveCommandResult result) => result.ExitCode; + + /// + /// Converts the result to a boolean value that corresponds to the property. + /// + public static implicit operator bool(ImmersiveCommandResult result) => result.IsSuccess; + + /// + /// Converts the result to a string value that corresponds to the property. + /// + public static implicit operator string(ImmersiveCommandResult result) => result.StandardOutput; +} diff --git a/CliWrap.Immersive/MagicExtensions.cs b/CliWrap.Immersive/MagicExtensions.cs new file mode 100644 index 00000000..a4639bdd --- /dev/null +++ b/CliWrap.Immersive/MagicExtensions.cs @@ -0,0 +1,33 @@ +using System.Runtime.CompilerServices; +using CliWrap.Buffered; + +namespace CliWrap.Immersive; + +/// +/// Extensions for types. +/// +public static class MagicExtensions +{ + /// + /// Executes the command with magic. + /// + public static CommandTask ExecuteMagicalAsync(this Command command) => + command + .ExecuteBufferedAsync() + .Select( + r => + new ImmersiveCommandResult( + r.ExitCode, + r.StartTime, + r.ExitTime, + r.StandardOutput, + r.StandardError + ) + ); + + /// + /// Executes the command with buffering and returns the awaiter for the result. + /// + public static TaskAwaiter GetAwaiter(this Command command) => + command.ExecuteMagicalAsync().GetAwaiter(); +} diff --git a/CliWrap.Immersive/Readme.md b/CliWrap.Immersive/Readme.md new file mode 100644 index 00000000..6ec5c575 --- /dev/null +++ b/CliWrap.Immersive/Readme.md @@ -0,0 +1,95 @@ +# CliWrap.Immersive + +[![Version](https://img.shields.io/nuget/v/CliWrap.Immersive.svg)](https://nuget.org/packages/CliWrap.Immersive) +[![Downloads](https://img.shields.io/nuget/dt/CliWrap.Immersive.svg)](https://nuget.org/packages/CliWrap.Immersive) + +**CliWrap.Immersive** is an extension package for **CliWrap** that provides a shell-like experience for executing commands. + +## Install + +- 📦 [NuGet](https://nuget.org/packages/CliWrap.Immersive): `dotnet add package CliWrap.Immersive` + +## Usage + +### Quick overview + +Add `using static CliWrap.Immersive.Spells;` to your file and start writing scripts like this: + +```csharp +using static CliWrap.Immersive.Spells; + +// Create commands using the _() method, execute them simply by awaiting. +// Check for exit code directly in if statements. +if (!await _("git")) +{ + WriteErrorLine("Git is not installed"); + Exit(1); + return; +} + +// Executing a command returns an object which has implicit conversions to: +// - int (exit code) +// - bool (exit code == 0) +// - string (standard output) +string version = await _("git", "--version"); // git version 2.43.0.windows.1 +WriteLine($"Git version: {version}"); + +// Just like with regular CliWrap, arguments are automatically +// escaped to form a well-formed command line string. +// Non-string arguments of many different types can also be passed directly. +await _("git", "clone", "https://github.com/Tyrrrz/CliWrap", "--depth", 0); + +// Resolve environment variables easily with the Environment() method. +var commit = Environment("HEAD_SHA"); + +// Prompt the user for additional input with the ReadLine() method. +// Check for truthy values using the IsTruthy() method. +if (!IsTruthy(commit)) + commit = ReadLine("Enter commit hash"); + +// Just like with regular CliWrap, arguments are automatically +// escaped to form a well-formed command line string. +await _("git", "checkout", commit); + +// Set environment variables using the Environment() method. +// This returns an object that you can dispose to restore the original value. +using (Environment("HEAD_SHA", "deadbeef")) +{ + await _("/bin/sh", "-c", "echo $HEAD_SHA"); // deadbeef + + // You can also run commands in the default system shell directly + // using the Shell() method. + await Shell("echo $HEAD_SHA"); // deadbeef +} + +// Same with the WorkingDirectory() method. +using (WorkingDirectory("/tmp/my-script/")) +{ + // Get the current working directory using the same method. + var cwd = WorkingDirectory(); +} + +// Magic also supports CliWrap's piping syntax. +var commits = new List(); // this will contain commit hashes +await ( + _("git", "log", "--pretty=format:%H") | commits.Add +); +``` + +### Executing commands + +In order to run a command with **CliWrap.Immersive**, use the `_` method with the target file path: + +```csharp +using CliWrap.Immersive; +using static CliWrap.Immersive.Shell; + +await _("dotnet"); +var version = await _("dotnet", "--version"); +``` + +Piping works the same way as it does in regular **CliWrap**: + +```csharp +await ("standard input" | _("dotnet", "run")); +``` \ No newline at end of file diff --git a/CliWrap.Immersive/Spells.cs b/CliWrap.Immersive/Spells.cs new file mode 100644 index 00000000..c6dd39eb --- /dev/null +++ b/CliWrap.Immersive/Spells.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using CliWrap.Immersive.Utils; + +namespace CliWrap.Immersive; + +/// +/// Utility methods for working with the shell environment. +/// +public static class Spells +{ + /// + /// Default standard input pipe used for commands created by methods in this class. + /// + public static PipeSource DefaultStandardInputPipe { get; set; } = + PipeSource.FromStream(Console.OpenStandardInput()); + + /// + /// Default standard output pipe used for commands created by methods in this class. + /// + public static PipeTarget DefaultStandardOutputPipe { get; set; } = + PipeTarget.ToStream(Console.OpenStandardOutput()); + + /// + /// Default standard error pipe used for commands created by methods in this class. + /// + public static PipeTarget DefaultStandardErrorPipe { get; set; } = + PipeTarget.ToStream(Console.OpenStandardError()); + + /// + /// Creates a new command with the specified target file path. + /// + public static Command Command(string targetFilePath) => + Cli.Wrap(targetFilePath) + .WithStandardInputPipe(DefaultStandardInputPipe) + .WithStandardOutputPipe(DefaultStandardOutputPipe) + .WithStandardErrorPipe(DefaultStandardErrorPipe); + + /// + /// Creates a new command with the specified target file path and command-line arguments. + /// + public static Command Command(string targetFilePath, IEnumerable arguments) => + Command(targetFilePath).WithArguments(arguments); + + private static Command Shell(Command command) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Cli.Wrap("cmd.exe") + .WithArguments(new[] { "/c", command.TargetFilePath, command.Arguments }) + : Cli.Wrap("/bin/sh") + .WithArguments(new[] { "-c", command.TargetFilePath, command.Arguments }); + + /// + /// Creates a new command with the specified target file path and command-line arguments, + /// wrapped in the default system shell. + /// + /// + /// The default system shell is determined based on the current operating system: + /// cmd.exe on Windows, /bin/sh on Linux and macOS. + /// + public static Command Shell(string targetFilePath, IEnumerable arguments) => + Shell(Command(targetFilePath).WithArguments(arguments)); + + /// + /// Gets the current working directory. + /// + public static string WorkingDirectory() => Directory.GetCurrentDirectory(); + + /// + /// Changes the current working directory to the specified path. + /// + /// + /// You can dispose the returned object to reset the path back to its previous value. + /// + public static IDisposable WorkingDirectory(string path) + { + var lastPath = WorkingDirectory(); + Directory.SetCurrentDirectory(path); + + return Disposable.Create(() => Directory.SetCurrentDirectory(lastPath)); + } + + /// + /// Gets the value of the specified environment variable. + /// + public static string? Environment(string name) => + System.Environment.GetEnvironmentVariable(name); + + /// + /// Sets the value of the specified environment variable. + /// + /// + /// You can dispose the returned object to reset the environment variable back to its previous value. + /// + public static IDisposable Environment(string name, string? value) + { + var lastValue = System.Environment.GetEnvironmentVariable(name); + System.Environment.SetEnvironmentVariable(name, value); + + return Disposable.Create(() => System.Environment.SetEnvironmentVariable(name, lastValue)); + } + + /// + /// Terminates the current process with the specified exit code. + /// + public static void Exit(int exitCode = 0) => System.Environment.Exit(exitCode); + + /// + /// Prompt the user for input, with an optional message. + /// + public static string? ReadLine(string? message = null) + { + if (!string.IsNullOrWhiteSpace(message)) + Console.Write(message); + + return Console.ReadLine(); + } + + /// + /// Writes the specified text to the standard output stream. + /// + public static void Write(string text) => Console.Write(text); + + /// + /// Writes the specified text to the standard output stream, followed by a line terminator. + /// + public static void WriteLine(string text) => Write(text + System.Environment.NewLine); + + /// + /// Writes the specified text to the standard error stream. + /// + public static void WriteError(string text) => Console.Error.Write(text); + + /// + /// Writes the specified text to the standard error stream, followed by a line terminator. + /// + public static void WriteErrorLine(string text) => WriteError(text + System.Environment.NewLine); + + /// + /// Checks if the specified value is truthy. + /// + public static bool IsTruthy(bool value) => value; + + /// + /// Checks if the specified value is truthy. + /// + public static bool IsTruthy(string? value) => !string.IsNullOrEmpty(value); + + /// + /// Checks if the specified value is truthy. + /// + public static bool IsTruthy(int? value) => value.HasValue && value.Value != default; + + /// + /// Checks if the specified value is truthy. + /// + public static bool IsTruthy(long? value) => value.HasValue && value.Value != default; + + /// + /// Checks if the specified value is truthy. + /// + public static bool IsTruthy(IEnumerable values) => values.Any(); + + /// + /// Checks if the specified value is truthy. + /// + public static bool IsTruthy(object? value) => + value switch + { + null => false, + bool x => IsTruthy(x), + string x => IsTruthy(x), + int x => IsTruthy(x), + long x => IsTruthy(x), + IEnumerable x => IsTruthy(x), + _ => true + }; +} diff --git a/CliWrap.Immersive/Utils/Disposable.cs b/CliWrap.Immersive/Utils/Disposable.cs new file mode 100644 index 00000000..3ea1130b --- /dev/null +++ b/CliWrap.Immersive/Utils/Disposable.cs @@ -0,0 +1,17 @@ +using System; + +namespace CliWrap.Immersive.Utils; + +internal partial class Disposable : IDisposable +{ + private readonly Action _dispose; + + public Disposable(Action dispose) => _dispose = dispose; + + public void Dispose() => _dispose(); +} + +internal partial class Disposable +{ + public static IDisposable Create(Action dispose) => new Disposable(dispose); +} diff --git a/CliWrap.sln b/CliWrap.sln index e4a046e0..178d29e8 100644 --- a/CliWrap.sln +++ b/CliWrap.sln @@ -20,6 +20,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliWrap.Benchmarks", "CliWr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliWrap.Signaler", "CliWrap.Signaler\CliWrap.Signaler.csproj", "{A0E41A11-D314-45C4-890B-831385450DF8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliWrap.Immersive", "CliWrap.Immersive\CliWrap.Immersive.csproj", "{4759BBA0-54AF-438B-B858-FD550CB459D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliWrap.Immersive.Tests", "CliWrap.Immersive.Tests\CliWrap.Immersive.Tests.csproj", "{39060B45-181E-4160-ADCF-6CEC548C2CB8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +50,14 @@ Global {A0E41A11-D314-45C4-890B-831385450DF8}.Debug|Any CPU.Build.0 = Debug|Any CPU {A0E41A11-D314-45C4-890B-831385450DF8}.Release|Any CPU.ActiveCfg = Release|Any CPU {A0E41A11-D314-45C4-890B-831385450DF8}.Release|Any CPU.Build.0 = Release|Any CPU + {4759BBA0-54AF-438B-B858-FD550CB459D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4759BBA0-54AF-438B-B858-FD550CB459D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4759BBA0-54AF-438B-B858-FD550CB459D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4759BBA0-54AF-438B-B858-FD550CB459D5}.Release|Any CPU.Build.0 = Release|Any CPU + {39060B45-181E-4160-ADCF-6CEC548C2CB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39060B45-181E-4160-ADCF-6CEC548C2CB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39060B45-181E-4160-ADCF-6CEC548C2CB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39060B45-181E-4160-ADCF-6CEC548C2CB8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Readme.md b/Readme.md index b2765eee..594f75bb 100644 --- a/Readme.md +++ b/Readme.md @@ -22,6 +22,10 @@ **CliWrap** is a library for interacting with external command-line interfaces. It provides a convenient model for launching processes, redirecting input and output streams, awaiting completion, handling cancellation, and more. +**Extension packages**: + +- [CliWrap.Immersive](CliWrap.Immersive) — provides a shell-like experience for executing commands + ## Terms of use[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/why-so-political.md) By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements: