├── .assets ├── video-guide-nick-chapsas.jpg └── video-guide-oss-powerups.jpg ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── CliWrap.Benchmarks ├── BasicBenchmarks.cs ├── BufferingBenchmarks.cs ├── CliWrap.Benchmarks.csproj ├── PipeFromStreamBenchmarks.cs ├── PipeToMultipleStreamsBenchmark.cs ├── PipeToStreamBenchmarks.cs ├── Program.cs ├── PullEventStreamBenchmarks.cs ├── PushEventStreamBenchmarks.cs └── Readme.md ├── CliWrap.Signaler ├── CliWrap.Signaler.csproj ├── Program.cs └── Utils │ └── NativeMethods.cs ├── CliWrap.Tests.Dummy ├── CliWrap.Tests.Dummy.csproj ├── Commands │ ├── EchoCommand.cs │ ├── EchoStdInCommand.cs │ ├── EnvironmentCommand.cs │ ├── ExitCommand.cs │ ├── GenerateBinaryCommand.cs │ ├── GenerateTextCommand.cs │ ├── LengthStdInCommand.cs │ ├── Shared │ │ └── OutputTarget.cs │ ├── SleepCommand.cs │ └── WorkingDirectoryCommand.cs └── Program.cs ├── CliWrap.Tests ├── BufferingSpecs.cs ├── CancellationSpecs.cs ├── CliWrap.Tests.csproj ├── ConfigurationSpecs.cs ├── CredentialsSpecs.cs ├── EnvironmentSpecs.cs ├── EventStreamSpecs.cs ├── ExecutionSpecs.cs ├── LineBreakSpecs.cs ├── PathResolutionSpecs.cs ├── PipingSpecs.cs ├── ResourcePolicySpecs.cs ├── Utils │ ├── Extensions │ │ └── AssertionExtensions.cs │ ├── ProcessEx.cs │ ├── TempDir.cs │ ├── TempEnvironmentVariable.cs │ └── TempFile.cs ├── ValidationSpecs.cs └── xunit.runner.json ├── CliWrap.sln ├── CliWrap ├── Buffered │ ├── BufferedCommandExtensions.cs │ └── BufferedCommandResult.cs ├── Builders │ ├── ArgumentsBuilder.cs │ ├── CredentialsBuilder.cs │ ├── EnvironmentVariablesBuilder.cs │ └── ResourcePolicyBuilder.cs ├── Cli.cs ├── CliWrap.csproj ├── Command.Execution.cs ├── Command.PipeOperators.cs ├── Command.cs ├── CommandResult.cs ├── CommandResultValidation.cs ├── CommandTask.cs ├── Credentials.cs ├── EventStream │ ├── CommandEvent.cs │ ├── PullEventStreamCommandExtensions.cs │ └── PushEventStreamCommandExtensions.cs ├── Exceptions │ ├── CliWrapException.cs │ └── CommandExecutionException.cs ├── ICommandConfiguration.cs ├── PipeSource.cs ├── PipeTarget.cs ├── ResourcePolicy.cs ├── Utils │ ├── BufferSizes.cs │ ├── Channel.cs │ ├── Disposable.cs │ ├── EnvironmentEx.cs │ ├── Extensions │ │ ├── AssemblyExtensions.cs │ │ ├── AsyncDisposableExtensions.cs │ │ ├── CancellationTokenExtensions.cs │ │ ├── ExceptionExtensions.cs │ │ ├── StreamExtensions.cs │ │ ├── StringExtensions.cs │ │ └── TaskExtensions.cs │ ├── NativeMethods.cs │ ├── Observable.cs │ ├── ProcessEx.cs │ ├── SimplexStream.cs │ ├── SynchronizedObserver.cs │ └── WindowsSignaler.cs └── key.snk ├── Directory.Build.props ├── License.txt ├── NuGet.config ├── Readme.md └── favicon.png /.assets/video-guide-nick-chapsas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/CliWrap/4d0471ea35c8e30388a312c0cb9c8d737dbe153e/.assets/video-guide-nick-chapsas.jpg -------------------------------------------------------------------------------- /.assets/video-guide-oss-powerups.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/CliWrap/4d0471ea35c8e30388a312c0cb9c8d737dbe153e/.assets/video-guide-oss-powerups.jpg -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report broken functionality. 3 | labels: [bug] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | - Avoid generic or vague titles such as "Something's not working" or "A couple of problems" — be as descriptive as possible. 10 | - Keep your issue focused on one single problem. If you have multiple bug reports, please create a separate issue for each of them. 11 | - Issues should represent **complete and actionable** work items. If you are unsure about something or have a question, please start a [discussion](https://github.com/Tyrrrz/CliWrap/discussions/new) instead. 12 | - Remember that **CliWrap** is an open-source project funded by the community. If you find it useful, **please consider [donating](https://tyrrrz.me/donate) to support its development**. 13 | 14 | ___ 15 | 16 | - type: input 17 | attributes: 18 | label: Version 19 | description: Which version of the package does this bug affect? Make sure you're not using an outdated version. 20 | placeholder: v1.0.0 21 | validations: 22 | required: true 23 | 24 | - type: input 25 | attributes: 26 | label: Platform 27 | description: Which platform do you experience this bug on? 28 | placeholder: .NET 7.0 / Windows 11 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | attributes: 34 | label: Steps to reproduce 35 | description: > 36 | Minimum steps required to reproduce the bug, including prerequisites, code snippets, or other relevant items. 37 | The information provided in this field must be readily actionable, meaning that anyone should be able to reproduce the bug by following these steps. 38 | If the reproduction steps are too complex to fit in this field, please provide a link to a repository instead. 39 | placeholder: | 40 | - Step 1 41 | - Step 2 42 | - Step 3 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | attributes: 48 | label: Details 49 | description: Clear and thorough explanation of the bug, including any additional information you may find relevant. 50 | placeholder: | 51 | - Expected behavior: ... 52 | - Actual behavior: ... 53 | validations: 54 | required: true 55 | 56 | - type: checkboxes 57 | attributes: 58 | label: Checklist 59 | description: Quick list of checks to ensure that everything is in order. 60 | options: 61 | - label: I have looked through existing issues to make sure that this bug has not been reported before 62 | required: true 63 | - label: I have provided a descriptive title for this issue 64 | required: true 65 | - label: I have made sure that this bug is reproducible on the latest version of the package 66 | required: true 67 | - label: I have provided all the information needed to reproduce this bug as efficiently as possible 68 | required: true 69 | - label: I have sponsored this project 70 | required: false 71 | 72 | - type: markdown 73 | attributes: 74 | value: | 75 | If you are struggling to provide actionable reproduction steps, or if something else is preventing you from creating a complete bug report, please start a [discussion](https://github.com/Tyrrrz/CliWrap/discussions/new) instead. 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🗨 Discussions 4 | url: https://github.com/Tyrrrz/CliWrap/discussions/new 5 | about: Ask and answer questions. 6 | - name: 💬 Discord server 7 | url: https://discord.gg/2SUWKFnHSm 8 | about: Chat with the community. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature request 2 | description: Request a new feature. 3 | labels: [enhancement] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | - Avoid generic or vague titles such as "Something's not working" or "A couple of problems" — be as descriptive as possible. 10 | - Keep your issue focused on one single problem. If you have multiple feature requests, please create a separate issue for each of them. 11 | - Issues should represent **complete and actionable** work items. If you are unsure about something or have a question, please start a [discussion](https://github.com/Tyrrrz/CliWrap/discussions/new) instead. 12 | - Remember that **CliWrap** is an open-source project funded by the community. If you find it useful, **please consider [donating](https://tyrrrz.me/donate) to support its development**. 13 | 14 | ___ 15 | 16 | - type: textarea 17 | attributes: 18 | label: Details 19 | description: Clear and thorough explanation of the feature you have in mind. 20 | validations: 21 | required: true 22 | 23 | - type: checkboxes 24 | attributes: 25 | label: Checklist 26 | description: Quick list of checks to ensure that everything is in order. 27 | options: 28 | - label: I have looked through existing issues to make sure that this feature has not been requested before 29 | required: true 30 | - label: I have provided a descriptive title for this issue 31 | required: true 32 | - label: I am aware that even valid feature requests may be rejected if they do not align with the project's goals 33 | required: true 34 | - label: I have sponsored this project 35 | required: false 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | labels: 8 | - enhancement 9 | groups: 10 | actions: 11 | patterns: 12 | - "*" 13 | - package-ecosystem: nuget 14 | directory: "/" 15 | schedule: 16 | interval: monthly 17 | labels: 18 | - enhancement 19 | groups: 20 | nuget: 21 | patterns: 22 | - "*" 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | package-version: 7 | type: string 8 | description: Package version 9 | required: false 10 | deploy: 11 | type: boolean 12 | description: Deploy package 13 | required: false 14 | default: false 15 | push: 16 | branches: 17 | - master 18 | tags: 19 | - "*" 20 | pull_request: 21 | branches: 22 | - master 23 | 24 | jobs: 25 | main: 26 | uses: Tyrrrz/.github/.github/workflows/nuget.yml@master 27 | with: 28 | deploy: ${{ inputs.deploy || github.ref_type == 'tag' }} 29 | package-version: ${{ inputs.package-version || (github.ref_type == 'tag' && github.ref_name) || format('0.0.0-ci-{0}', github.sha) }} 30 | dotnet-version: 9.0.x 31 | secrets: 32 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 33 | NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} 34 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific files 2 | .vs/ 3 | .idea/ 4 | *.suo 5 | *.user 6 | 7 | # Build results 8 | bin/ 9 | obj/ 10 | 11 | # Test results 12 | TestResults/ -------------------------------------------------------------------------------- /CliWrap.Benchmarks/BasicBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using BenchmarkDotNet.Attributes; 3 | using BenchmarkDotNet.Order; 4 | using RunProcessAsTask; 5 | 6 | namespace CliWrap.Benchmarks; 7 | 8 | [MemoryDiagnoser, Orderer(SummaryOrderPolicy.FastestToSlowest)] 9 | public class BasicBenchmarks 10 | { 11 | private const string FilePath = "dotnet"; 12 | private static readonly string Args = Tests.Dummy.Program.FilePath; 13 | 14 | [Benchmark(Baseline = true)] 15 | public async Task CliWrap() 16 | { 17 | var result = await Cli.Wrap(FilePath).WithArguments(Args).ExecuteAsync(); 18 | return result.ExitCode; 19 | } 20 | 21 | [Benchmark] 22 | public async Task RunProcessAsTask() 23 | { 24 | var result = await ProcessEx.RunAsync(FilePath, Args); 25 | return result.ExitCode; 26 | } 27 | 28 | [Benchmark] 29 | public async Task MedallionShell() 30 | { 31 | var result = await Medallion.Shell.Command.Run(FilePath, Args).Task; 32 | return result.ExitCode; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/BufferingBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using BenchmarkDotNet.Attributes; 4 | using BenchmarkDotNet.Order; 5 | using CliWrap.Buffered; 6 | using RunProcessAsTask; 7 | 8 | namespace CliWrap.Benchmarks; 9 | 10 | [MemoryDiagnoser, Orderer(SummaryOrderPolicy.FastestToSlowest)] 11 | public class BufferingBenchmarks 12 | { 13 | private const string FilePath = "dotnet"; 14 | private static readonly string Args = 15 | $"{Tests.Dummy.Program.FilePath} generate text --lines 1000"; 16 | 17 | [Benchmark(Baseline = true)] 18 | public async Task<(string, string)> CliWrap() 19 | { 20 | var result = await Cli.Wrap(FilePath).WithArguments(Args).ExecuteBufferedAsync(); 21 | return (result.StandardOutput, result.StandardError); 22 | } 23 | 24 | [Benchmark] 25 | public async Task<(string, string)> RunProcessAsTask() 26 | { 27 | var result = await ProcessEx.RunAsync(FilePath, Args); 28 | 29 | return ( 30 | string.Join(Environment.NewLine, result.StandardOutput), 31 | string.Join(Environment.NewLine, result.StandardError) 32 | ); 33 | } 34 | 35 | [Benchmark] 36 | public async Task<(string, string)> MedallionShell() 37 | { 38 | var result = await Medallion.Shell.Shell.Default.Run(FilePath, Args.Split(' ')).Task; 39 | return (result.StandardOutput, result.StandardError); 40 | } 41 | 42 | [Benchmark] 43 | public async Task<(string, string)> ProcessX() 44 | { 45 | var (_, stdOutStream, stdErrStream) = Cysharp.Diagnostics.ProcessX.GetDualAsyncEnumerable( 46 | FilePath, 47 | arguments: Args 48 | ); 49 | 50 | var stdOutTask = stdOutStream.ToTask(); 51 | var stdErrTask = stdErrStream.ToTask(); 52 | 53 | await Task.WhenAll(stdOutTask, stdErrTask); 54 | 55 | return ( 56 | string.Join(Environment.NewLine, stdOutTask.Result), 57 | string.Join(Environment.NewLine, stdErrTask.Result) 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/CliWrap.Benchmarks.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/PipeFromStreamBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using BenchmarkDotNet.Attributes; 4 | using BenchmarkDotNet.Order; 5 | 6 | namespace CliWrap.Benchmarks; 7 | 8 | [MemoryDiagnoser, Orderer(SummaryOrderPolicy.FastestToSlowest)] 9 | public class PipeFromStreamBenchmarks 10 | { 11 | private const string FilePath = "dotnet"; 12 | private static readonly string Args = $"{Tests.Dummy.Program.FilePath} echo stdin"; 13 | 14 | [Benchmark(Baseline = true)] 15 | public async Task ExecuteWithCliWrap_PipeToStream() 16 | { 17 | await using var stream = new MemoryStream([1, 2, 3, 4, 5]); 18 | 19 | var command = stream | Cli.Wrap(FilePath).WithArguments(Args); 20 | await command.ExecuteAsync(); 21 | 22 | return stream; 23 | } 24 | 25 | [Benchmark] 26 | public async Task MedallionShell() 27 | { 28 | await using var stream = new MemoryStream([1, 2, 3, 4, 5]); 29 | 30 | var command = Medallion.Shell.Command.Run(FilePath, Args.Split(' ')) < stream; 31 | await command.Task; 32 | 33 | return stream; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/PipeToMultipleStreamsBenchmark.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using BenchmarkDotNet.Attributes; 4 | using BenchmarkDotNet.Order; 5 | 6 | namespace CliWrap.Benchmarks; 7 | 8 | [MemoryDiagnoser, Orderer(SummaryOrderPolicy.FastestToSlowest)] 9 | public class PipeToMultipleStreamsBenchmark 10 | { 11 | private const string FilePath = "dotnet"; 12 | private static readonly string Args = $"{Tests.Dummy.Program.FilePath} generate binary"; 13 | 14 | [Benchmark(Baseline = true)] 15 | public async Task<(Stream, Stream)> CliWrap() 16 | { 17 | await using var stream1 = new MemoryStream(); 18 | await using var stream2 = new MemoryStream(); 19 | 20 | var target = PipeTarget.Merge(PipeTarget.ToStream(stream1), PipeTarget.ToStream(stream2)); 21 | 22 | var command = Cli.Wrap(FilePath).WithArguments(Args) | target; 23 | await command.ExecuteAsync(); 24 | 25 | return (stream1, stream2); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/PipeToStreamBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using BenchmarkDotNet.Attributes; 4 | using BenchmarkDotNet.Order; 5 | 6 | namespace CliWrap.Benchmarks; 7 | 8 | [MemoryDiagnoser, Orderer(SummaryOrderPolicy.FastestToSlowest)] 9 | public class PipeToStreamBenchmarks 10 | { 11 | private const string FilePath = "dotnet"; 12 | private static readonly string Args = $"{Tests.Dummy.Program.FilePath} generate binary"; 13 | 14 | [Benchmark(Baseline = true)] 15 | public async Task CliWrap() 16 | { 17 | await using var stream = new MemoryStream(); 18 | 19 | var command = Cli.Wrap(FilePath).WithArguments(Args) | stream; 20 | await command.ExecuteAsync(); 21 | 22 | return stream; 23 | } 24 | 25 | [Benchmark] 26 | public async Task MedallionShell() 27 | { 28 | await using var stream = new MemoryStream(); 29 | 30 | var command = Medallion.Shell.Command.Run(FilePath, Args.Split(' ')) > stream; 31 | await command.Task; 32 | 33 | return stream; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using BenchmarkDotNet.Running; 3 | 4 | namespace CliWrap.Benchmarks; 5 | 6 | public static class Program 7 | { 8 | public static void Main() => BenchmarkRunner.Run(Assembly.GetExecutingAssembly()); 9 | } 10 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/PullEventStreamBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using BenchmarkDotNet.Attributes; 3 | using BenchmarkDotNet.Order; 4 | using CliWrap.EventStream; 5 | 6 | namespace CliWrap.Benchmarks; 7 | 8 | [MemoryDiagnoser, Orderer(SummaryOrderPolicy.FastestToSlowest)] 9 | public class PullEventStreamBenchmarks 10 | { 11 | private const string FilePath = "dotnet"; 12 | private static readonly string Args = 13 | $"{Tests.Dummy.Program.FilePath} generate text --lines 1000"; 14 | 15 | [Benchmark(Baseline = true)] 16 | public async Task CliWrap() 17 | { 18 | var counter = 0; 19 | 20 | await foreach (var cmdEvent in Cli.Wrap(FilePath).WithArguments(Args).ListenAsync()) 21 | { 22 | switch (cmdEvent) 23 | { 24 | case StandardOutputCommandEvent: 25 | counter++; 26 | break; 27 | case StandardErrorCommandEvent: 28 | counter++; 29 | break; 30 | } 31 | } 32 | 33 | return counter; 34 | } 35 | 36 | [Benchmark] 37 | public async Task ProcessX() 38 | { 39 | var counter = 0; 40 | 41 | var (_, stdOutStream, stdErrStream) = Cysharp.Diagnostics.ProcessX.GetDualAsyncEnumerable( 42 | FilePath, 43 | arguments: Args 44 | ); 45 | 46 | var consumeStdOutTask = Task.Run(async () => 47 | { 48 | await foreach (var _ in stdOutStream) 49 | { 50 | counter++; 51 | } 52 | }); 53 | 54 | var consumeStdErrorTask = Task.Run(async () => 55 | { 56 | await foreach (var _ in stdErrStream) 57 | { 58 | counter++; 59 | } 60 | }); 61 | 62 | await Task.WhenAll(consumeStdOutTask, consumeStdErrorTask); 63 | 64 | return counter; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/PushEventStreamBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.Reactive.Linq; 2 | using System.Threading.Tasks; 3 | using BenchmarkDotNet.Attributes; 4 | using BenchmarkDotNet.Order; 5 | using CliWrap.EventStream; 6 | 7 | namespace CliWrap.Benchmarks; 8 | 9 | [MemoryDiagnoser, Orderer(SummaryOrderPolicy.FastestToSlowest)] 10 | public class PushEventStreamBenchmarks 11 | { 12 | private const string FilePath = "dotnet"; 13 | private static readonly string Args = 14 | $"{Tests.Dummy.Program.FilePath} generate text --lines 1000"; 15 | 16 | [Benchmark(Baseline = true)] 17 | public async Task CliWrap() 18 | { 19 | var counter = 0; 20 | 21 | await Cli.Wrap(FilePath) 22 | .WithArguments(Args) 23 | .Observe() 24 | .ForEachAsync(cmdEvent => 25 | { 26 | switch (cmdEvent) 27 | { 28 | case StandardOutputCommandEvent: 29 | counter++; 30 | break; 31 | case StandardErrorCommandEvent: 32 | counter++; 33 | break; 34 | } 35 | }); 36 | 37 | return counter; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /CliWrap.Benchmarks/Readme.md: -------------------------------------------------------------------------------- 1 | # CliWrap.Benchmarks 2 | 3 | All benchmarks below were ran with the following configuration: 4 | 5 | ```ini 6 | BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1466 (21H1/May2021Update) 7 | 11th Gen Intel Core i5-11600K 3.90GHz, 1 CPU, 12 logical and 6 physical cores 8 | .NET SDK=6.0.100 9 | [Host] : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT 10 | DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT 11 | ``` 12 | 13 | ## Basic benchmarks 14 | 15 | **Description**: run a process, wait for completion, and return the exit code. 16 | 17 | ```ini 18 | | Method | Mean | Error | StdDev | Ratio | Allocated | 19 | |----------------- |---------:|---------:|---------:|------:|----------:| 20 | | RunProcessAsTask | 53.71 ms | 0.221 ms | 0.196 ms | 0.84 | 112 KB | 21 | | Sheller | 60.51 ms | 0.244 ms | 0.229 ms | 0.95 | 125 KB | 22 | | MedallionShell | 63.53 ms | 0.236 ms | 0.209 ms | 0.99 | 118 KB | 23 | | CliWrap | 64.03 ms | 0.399 ms | 0.373 ms | 1.00 | 93 KB | 24 | ``` 25 | 26 | ## Buffering benchmarks 27 | 28 | **Description**: run a process, read standard output and error, wait for completion, and return buffered output and error data. 29 | Target program writes a total of 1 million characters to each stream. 30 | 31 | ```ini 32 | | Method | Mean | Error | StdDev | Ratio | Gen 0 | Gen 1 | Gen 2 | Allocated | 33 | |----------------- |---------:|---------:|---------:|------:|----------:|---------:|---------:|----------:| 34 | | RunProcessAsTask | 73.43 ms | 0.439 ms | 0.389 ms | 0.88 | 714.2857 | 428.5714 | 285.7143 | 4 MB | 35 | | Sheller | 79.70 ms | 0.231 ms | 0.216 ms | 0.96 | 857.1429 | 428.5714 | 142.8571 | 6 MB | 36 | | ProcessX | 83.12 ms | 0.473 ms | 0.442 ms | 1.00 | 714.2857 | 428.5714 | 285.7143 | 5 MB | 37 | | CliWrap | 83.20 ms | 0.382 ms | 0.339 ms | 1.00 | 571.4286 | 428.5714 | 142.8571 | 5 MB | 38 | | MedallionShell | 84.75 ms | 0.325 ms | 0.288 ms | 1.02 | 1000.0000 | 833.3333 | 666.6667 | 6 MB | 39 | ``` 40 | 41 | ## Async event stream benchmarks 42 | 43 | **Description**: run a process as a pull-based event stream and return the number of lines written to each stream. 44 | Target program writes a total of 1 million characters to each stream. 45 | 46 | ```ini 47 | | Method | Mean | Error | StdDev | Ratio | Gen 0 | Allocated | 48 | |--------- |---------:|---------:|---------:|------:|---------:|----------:| 49 | | ProcessX | 83.53 ms | 0.621 ms | 0.550 ms | 0.99 | 333.3333 | 3 MB | 50 | | CliWrap | 84.47 ms | 1.212 ms | 1.075 ms | 1.00 | 500.0000 | 3 MB | 51 | ``` 52 | 53 | ## Observable event stream benchmarks 54 | 55 | **Description**: run a process as a push-based event stream and return the number of lines written to each stream. 56 | Target program writes a total of 1 million characters to each stream. 57 | 58 | ```ini 59 | | Method | Mean | Error | StdDev | Ratio | Gen 0 | Allocated | 60 | |-------- |---------:|---------:|---------:|------:|---------:|----------:| 61 | | CliWrap | 81.94 ms | 0.414 ms | 0.346 ms | 1.00 | 428.5714 | 3 MB | 62 | ``` 63 | 64 | ## Pipe from stream benchmarks 65 | 66 | **Description**: run a process and pipe a stream into standard input. 67 | 68 | ```ini 69 | | Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | 70 | |--------------- |---------:|---------:|---------:|------:|--------:|----------:| 71 | | CliWrap | 64.70 ms | 0.470 ms | 0.440 ms | 1.00 | 0.00 | 93 KB | 72 | | MedallionShell | 64.85 ms | 0.909 ms | 0.806 ms | 1.00 | 0.02 | 168 KB | 73 | ``` 74 | 75 | ## Pipe to stream benchmarks 76 | 77 | **Description**: run a process and pipe the standard output into a memory stream. 78 | Target program writes a total of 1 million bytes to each stream. 79 | 80 | ```ini 81 | | Method | Mean | Error | StdDev | Ratio | Gen 0 | Gen 1 | Gen 2 | Allocated | 82 | |--------------- |---------:|---------:|---------:|------:|---------:|---------:|---------:|----------:| 83 | | CliWrap | 75.52 ms | 0.541 ms | 0.480 ms | 1.00 | 142.8571 | 142.8571 | 142.8571 | 2 MB | 84 | | MedallionShell | 76.56 ms | 0.396 ms | 0.351 ms | 1.01 | 142.8571 | 142.8571 | 142.8571 | 3 MB | 85 | ``` 86 | 87 | **Pipe to multiple streams** 88 | 89 | **Description**: run a process and pipe the standard output into two memory streams. 90 | Target program writes a total of 1 million bytes to each stream. 91 | 92 | ```ini 93 | | Method | Mean | Error | StdDev | Ratio | Gen 0 | Gen 1 | Gen 2 | Allocated | 94 | |-------- |---------:|---------:|---------:|------:|---------:|---------:|---------:|----------:| 95 | | CliWrap | 77.29 ms | 0.515 ms | 0.456 ms | 1.00 | 714.2857 | 571.4286 | 571.4286 | 5 MB | 96 | ``` -------------------------------------------------------------------------------- /CliWrap.Signaler/CliWrap.Signaler.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net35 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /CliWrap.Signaler/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Runtime.InteropServices; 3 | using CliWrap.Signaler.Utils; 4 | 5 | namespace CliWrap.Signaler; 6 | 7 | // Implementation reference: 8 | // https://github.com/madelson/MedallionShell/blob/3fddb89860842ffc836a0d0f69b161f67e4aa7c4/MedallionShell.ProcessSignaler/Signals/Signaler.cs 9 | // MIT License, Michael Adelson 10 | 11 | public static class Program 12 | { 13 | public static int Main(string[] args) 14 | { 15 | var processId = int.Parse(args[0], CultureInfo.InvariantCulture); 16 | var signalId = int.Parse(args[1], CultureInfo.InvariantCulture); 17 | 18 | // Detach from the current console, if it exists. 19 | // Potential error here would mean that we're not attached to any console, so just ignore it. 20 | NativeMethods.Windows.FreeConsole(); 21 | 22 | var isSuccess = 23 | // Attach to the target process's console 24 | NativeMethods.Windows.AttachConsole((uint)processId) 25 | && 26 | // Ignore signals on ourselves so we can return a proper exit code 27 | NativeMethods.Windows.SetConsoleCtrlHandler(null, true) 28 | && 29 | // Send the signal to the console 30 | NativeMethods.Windows.GenerateConsoleCtrlEvent((uint)signalId, 0); 31 | 32 | return isSuccess ? 0 : Marshal.GetLastWin32Error(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /CliWrap.Signaler/Utils/NativeMethods.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace CliWrap.Signaler.Utils; 4 | 5 | internal static class NativeMethods 6 | { 7 | public static class Windows 8 | { 9 | [DllImport("kernel32.dll", SetLastError = true)] 10 | public static extern bool FreeConsole(); 11 | 12 | [DllImport("kernel32.dll", SetLastError = true)] 13 | public static extern bool AttachConsole(uint dwProcessId); 14 | 15 | public delegate bool ConsoleCtrlDelegate(uint dwCtrlEvent); 16 | 17 | [DllImport("kernel32.dll", SetLastError = true)] 18 | public static extern bool SetConsoleCtrlHandler( 19 | ConsoleCtrlDelegate? handlerRoutine, 20 | bool add 21 | ); 22 | 23 | [DllImport("kernel32.dll", SetLastError = true)] 24 | public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/CliWrap.Tests.Dummy.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net9.0 6 | $(TargetFrameworks);net48 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/EchoCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using CliFx; 4 | using CliFx.Attributes; 5 | using CliFx.Infrastructure; 6 | using CliWrap.Tests.Dummy.Commands.Shared; 7 | 8 | namespace CliWrap.Tests.Dummy.Commands; 9 | 10 | [Command("echo")] 11 | public class EchoCommand : ICommand 12 | { 13 | [CommandParameter(0)] 14 | public required IReadOnlyList Items { get; init; } 15 | 16 | [CommandOption("target")] 17 | public OutputTarget Target { get; init; } = OutputTarget.StdOut; 18 | 19 | [CommandOption("separator")] 20 | public string Separator { get; init; } = " "; 21 | 22 | public async ValueTask ExecuteAsync(IConsole console) 23 | { 24 | foreach (var writer in console.GetWriters(Target)) 25 | await writer.WriteLineAsync(string.Join(Separator, Items)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/EchoStdInCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Threading.Tasks; 4 | using CliFx; 5 | using CliFx.Attributes; 6 | using CliFx.Infrastructure; 7 | using CliWrap.Tests.Dummy.Commands.Shared; 8 | 9 | namespace CliWrap.Tests.Dummy.Commands; 10 | 11 | [Command("echo stdin")] 12 | public class EchoStdInCommand : ICommand 13 | { 14 | [CommandOption("target")] 15 | public OutputTarget Target { get; init; } = OutputTarget.StdOut; 16 | 17 | [CommandOption("length")] 18 | public long Length { get; init; } = long.MaxValue; 19 | 20 | public async ValueTask ExecuteAsync(IConsole console) 21 | { 22 | using var buffer = MemoryPool.Shared.Rent(81920); 23 | 24 | var totalBytesRead = 0L; 25 | while (totalBytesRead < Length) 26 | { 27 | var bytesWanted = (int)Math.Min(buffer.Memory.Length, Length - totalBytesRead); 28 | 29 | var bytesRead = await console.Input.BaseStream.ReadAsync(buffer.Memory[..bytesWanted]); 30 | if (bytesRead <= 0) 31 | break; 32 | 33 | foreach (var writer in console.GetWriters(Target)) 34 | await writer.BaseStream.WriteAsync(buffer.Memory[..bytesRead]); 35 | 36 | totalBytesRead += bytesRead; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/EnvironmentCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using CliFx; 5 | using CliFx.Attributes; 6 | using CliFx.Infrastructure; 7 | 8 | namespace CliWrap.Tests.Dummy.Commands; 9 | 10 | [Command("env")] 11 | public class EnvironmentCommand : ICommand 12 | { 13 | [CommandParameter(0)] 14 | public IReadOnlyList Names { get; init; } = []; 15 | 16 | public async ValueTask ExecuteAsync(IConsole console) 17 | { 18 | foreach (var name in Names) 19 | await console.Output.WriteLineAsync(Environment.GetEnvironmentVariable(name)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/ExitCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx; 3 | using CliFx.Attributes; 4 | using CliFx.Exceptions; 5 | using CliFx.Infrastructure; 6 | 7 | namespace CliWrap.Tests.Dummy.Commands; 8 | 9 | [Command("exit")] 10 | public class ExitCommand : ICommand 11 | { 12 | [CommandParameter(0)] 13 | public int ExitCode { get; init; } 14 | 15 | public ValueTask ExecuteAsync(IConsole console) 16 | { 17 | if (ExitCode != 0) 18 | throw new CommandException($"Exit code set to {ExitCode}", ExitCode); 19 | 20 | return default; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/GenerateBinaryCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Threading.Tasks; 4 | using CliFx; 5 | using CliFx.Attributes; 6 | using CliFx.Infrastructure; 7 | using CliWrap.Tests.Dummy.Commands.Shared; 8 | 9 | namespace CliWrap.Tests.Dummy.Commands; 10 | 11 | [Command("generate binary")] 12 | public class GenerateBinaryCommand : ICommand 13 | { 14 | // Tests rely on the random seed being fixed 15 | private readonly Random _random = new(1234567); 16 | 17 | [CommandOption("target")] 18 | public OutputTarget Target { get; init; } = OutputTarget.StdOut; 19 | 20 | [CommandOption("length")] 21 | public long Length { get; init; } = 100_000; 22 | 23 | [CommandOption("buffer")] 24 | public int BufferSize { get; init; } = 1024; 25 | 26 | public async ValueTask ExecuteAsync(IConsole console) 27 | { 28 | using var buffer = MemoryPool.Shared.Rent(BufferSize); 29 | 30 | var totalBytesGenerated = 0L; 31 | while (totalBytesGenerated < Length) 32 | { 33 | _random.NextBytes(buffer.Memory.Span); 34 | 35 | var bytesWanted = (int)Math.Min(buffer.Memory.Length, Length - totalBytesGenerated); 36 | 37 | foreach (var writer in console.GetWriters(Target)) 38 | await writer.BaseStream.WriteAsync(buffer.Memory[..bytesWanted]); 39 | 40 | totalBytesGenerated += bytesWanted; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/GenerateTextCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using CliFx; 6 | using CliFx.Attributes; 7 | using CliFx.Infrastructure; 8 | using CliWrap.Tests.Dummy.Commands.Shared; 9 | 10 | namespace CliWrap.Tests.Dummy.Commands; 11 | 12 | [Command("generate text")] 13 | public class GenerateTextCommand : ICommand 14 | { 15 | // Tests rely on the random seed being fixed 16 | private readonly Random _random = new(1234567); 17 | private readonly char[] _allowedChars = Enumerable.Range(32, 94).Select(i => (char)i).ToArray(); 18 | 19 | [CommandOption("target")] 20 | public OutputTarget Target { get; init; } = OutputTarget.StdOut; 21 | 22 | [CommandOption("length")] 23 | public int Length { get; init; } = 100_000; 24 | 25 | [CommandOption("lines")] 26 | public int LinesCount { get; init; } = 1; 27 | 28 | public async ValueTask ExecuteAsync(IConsole console) 29 | { 30 | for (var line = 0; line < LinesCount; line++) 31 | { 32 | var buffer = new StringBuilder(Length); 33 | 34 | for (var i = 0; i < Length; i++) 35 | { 36 | buffer.Append(_allowedChars[_random.Next(0, _allowedChars.Length)]); 37 | } 38 | 39 | foreach (var writer in console.GetWriters(Target)) 40 | await writer.WriteLineAsync(buffer.ToString()); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/LengthStdInCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | using System.Globalization; 3 | using System.Threading.Tasks; 4 | using CliFx; 5 | using CliFx.Attributes; 6 | using CliFx.Infrastructure; 7 | 8 | namespace CliWrap.Tests.Dummy.Commands; 9 | 10 | [Command("length stdin")] 11 | public class LengthStdInCommand : ICommand 12 | { 13 | public async ValueTask ExecuteAsync(IConsole console) 14 | { 15 | using var buffer = MemoryPool.Shared.Rent(81920); 16 | 17 | var totalBytesRead = 0L; 18 | while (true) 19 | { 20 | var bytesRead = await console.Input.BaseStream.ReadAsync(buffer.Memory); 21 | if (bytesRead <= 0) 22 | break; 23 | 24 | totalBytesRead += bytesRead; 25 | } 26 | 27 | await console.Output.WriteLineAsync(totalBytesRead.ToString(CultureInfo.InvariantCulture)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/Shared/OutputTarget.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CliFx.Infrastructure; 4 | 5 | namespace CliWrap.Tests.Dummy.Commands.Shared; 6 | 7 | [Flags] 8 | public enum OutputTarget 9 | { 10 | StdOut = 1, 11 | StdErr = 2, 12 | All = StdOut | StdErr, 13 | } 14 | 15 | internal static class OutputTargetExtensions 16 | { 17 | public static IEnumerable GetWriters(this IConsole console, OutputTarget target) 18 | { 19 | if (target.HasFlag(OutputTarget.StdOut)) 20 | yield return console.Output; 21 | 22 | if (target.HasFlag(OutputTarget.StdErr)) 23 | yield return console.Error; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/SleepCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using CliFx; 4 | using CliFx.Attributes; 5 | using CliFx.Infrastructure; 6 | 7 | namespace CliWrap.Tests.Dummy.Commands; 8 | 9 | [Command("sleep")] 10 | public class SleepCommand : ICommand 11 | { 12 | [CommandParameter(0)] 13 | public TimeSpan Duration { get; init; } = TimeSpan.FromSeconds(1); 14 | 15 | public async ValueTask ExecuteAsync(IConsole console) 16 | { 17 | var cancellationToken = console.RegisterCancellationHandler(); 18 | 19 | try 20 | { 21 | await console.Output.WriteLineAsync($"Sleeping for {Duration}..."); 22 | await Task.Delay(Duration, cancellationToken); 23 | } 24 | catch (OperationCanceledException) 25 | { 26 | await console.Output.WriteLineAsync("Canceled."); 27 | return; 28 | } 29 | 30 | await console.Output.WriteLineAsync("Done."); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Commands/WorkingDirectoryCommand.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using CliFx; 4 | using CliFx.Attributes; 5 | using CliFx.Infrastructure; 6 | 7 | namespace CliWrap.Tests.Dummy.Commands; 8 | 9 | [Command("cwd")] 10 | public class WorkingDirectoryCommand : ICommand 11 | { 12 | public async ValueTask ExecuteAsync(IConsole console) => 13 | await console.Output.WriteLineAsync(Directory.GetCurrentDirectory()); 14 | } 15 | -------------------------------------------------------------------------------- /CliWrap.Tests.Dummy/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | using System.Runtime.InteropServices; 5 | using System.Threading.Tasks; 6 | using CliFx; 7 | 8 | namespace CliWrap.Tests.Dummy; 9 | 10 | public static class Program 11 | { 12 | // Path to the apphost 13 | public static string FilePath { get; } = 14 | Path.ChangeExtension( 15 | Assembly.GetExecutingAssembly().Location, 16 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null 17 | ); 18 | 19 | public static async Task Main(string[] args) 20 | { 21 | // Make sure color codes are not produced because we rely on the output in tests 22 | Environment.SetEnvironmentVariable( 23 | "DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", 24 | "false" 25 | ); 26 | 27 | return await new CliApplicationBuilder() 28 | .AddCommandsFromThisAssembly() 29 | .Build() 30 | .RunAsync(args); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /CliWrap.Tests/BufferingSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliWrap.Buffered; 3 | using FluentAssertions; 4 | using Xunit; 5 | 6 | namespace CliWrap.Tests; 7 | 8 | public class BufferingSpecs 9 | { 10 | [Fact(Timeout = 15000)] 11 | public async Task I_can_execute_a_command_with_buffering_and_get_the_stdout() 12 | { 13 | // Arrange 14 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 15 | .WithArguments(["echo", "Hello stdout", "--target", "stdout"]); 16 | 17 | // Act 18 | var result = await cmd.ExecuteBufferedAsync(); 19 | 20 | // Assert 21 | result.StandardOutput.Trim().Should().Be("Hello stdout"); 22 | result.StandardError.Should().BeEmpty(); 23 | } 24 | 25 | [Fact(Timeout = 15000)] 26 | public async Task I_can_execute_a_command_with_buffering_and_get_the_stderr() 27 | { 28 | // Arrange 29 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 30 | .WithArguments(["echo", "Hello stderr", "--target", "stderr"]); 31 | 32 | // Act 33 | var result = await cmd.ExecuteBufferedAsync(); 34 | 35 | // Assert 36 | result.StandardOutput.Should().BeEmpty(); 37 | result.StandardError.Trim().Should().Be("Hello stderr"); 38 | } 39 | 40 | [Fact(Timeout = 15000)] 41 | public async Task I_can_execute_a_command_with_buffering_and_get_the_stdout_and_stderr() 42 | { 43 | // Arrange 44 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 45 | .WithArguments(["echo", "Hello stdout and stderr", "--target", "all"]); 46 | 47 | // Act 48 | var result = await cmd.ExecuteBufferedAsync(); 49 | 50 | // Assert 51 | result.StandardOutput.Trim().Should().Be("Hello stdout and stderr"); 52 | result.StandardError.Trim().Should().Be("Hello stdout and stderr"); 53 | } 54 | 55 | [Fact(Timeout = 15000)] 56 | public async Task I_can_execute_a_command_with_buffering_and_not_hang_on_large_stdout_and_stderr() 57 | { 58 | // Arrange 59 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 60 | .WithArguments(["generate text", "--target", "all", "--length", "100000"]); 61 | 62 | // Act 63 | var result = await cmd.ExecuteBufferedAsync(); 64 | 65 | // Assert 66 | result.StandardOutput.Should().NotBeNullOrWhiteSpace(); 67 | result.StandardError.Should().NotBeNullOrWhiteSpace(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /CliWrap.Tests/CancellationSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive.Linq; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using CliWrap.Buffered; 7 | using CliWrap.EventStream; 8 | using CliWrap.Tests.Utils; 9 | using FluentAssertions; 10 | using Xunit; 11 | 12 | namespace CliWrap.Tests; 13 | 14 | public class CancellationSpecs 15 | { 16 | [Fact(Timeout = 15000)] 17 | public async Task I_can_execute_a_command_and_cancel_it_immediately() 18 | { 19 | // Arrange 20 | using var cts = new CancellationTokenSource(); 21 | await cts.CancelAsync(); 22 | 23 | var stdOutBuffer = new StringBuilder(); 24 | 25 | var cmd = 26 | Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]) | stdOutBuffer; 27 | 28 | // Act 29 | var task = cmd.ExecuteAsync(cts.Token); 30 | 31 | // Assert 32 | var ex = await Assert.ThrowsAnyAsync(() => task); 33 | ex.CancellationToken.Should().Be(cts.Token); 34 | 35 | ProcessEx.IsRunning(task.ProcessId).Should().BeFalse(); 36 | stdOutBuffer.ToString().Should().NotContain("Done."); 37 | } 38 | 39 | [Fact(Timeout = 15000)] 40 | public async Task I_can_execute_a_command_and_cancel_it_after_a_delay() 41 | { 42 | // Arrange 43 | using var cts = new CancellationTokenSource(); 44 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 45 | 46 | var stdOutBuffer = new StringBuilder(); 47 | 48 | var cmd = 49 | Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]) | stdOutBuffer; 50 | 51 | // Act 52 | var task = cmd.ExecuteAsync(cts.Token); 53 | 54 | // Assert 55 | var ex = await Assert.ThrowsAnyAsync(() => task); 56 | ex.CancellationToken.Should().Be(cts.Token); 57 | 58 | ProcessEx.IsRunning(task.ProcessId).Should().BeFalse(); 59 | stdOutBuffer.ToString().Should().NotContain("Done."); 60 | } 61 | 62 | [Fact(Timeout = 15000)] 63 | public async Task I_can_execute_a_command_and_cancel_it_gracefully_after_a_delay() 64 | { 65 | // Arrange 66 | using var cts = new CancellationTokenSource(); 67 | var stdOutBuffer = new StringBuilder(); 68 | 69 | var cmd = 70 | Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]) 71 | | PipeTarget.Merge( 72 | PipeTarget.ToDelegate(line => 73 | { 74 | // We need to send the cancellation request right after the process has registered 75 | // a handler for the interrupt signal, otherwise the default handler will trigger 76 | // and just kill the process. 77 | if (line.Contains("Sleeping for", StringComparison.OrdinalIgnoreCase)) 78 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 79 | }), 80 | PipeTarget.ToStringBuilder(stdOutBuffer) 81 | ); 82 | 83 | // Act 84 | var task = cmd.ExecuteAsync(CancellationToken.None, cts.Token); 85 | 86 | // Assert 87 | var ex = await Assert.ThrowsAnyAsync(() => task); 88 | ex.CancellationToken.Should().Be(cts.Token); 89 | 90 | ProcessEx.IsRunning(task.ProcessId).Should().BeFalse(); 91 | stdOutBuffer.ToString().Should().Contain("Canceled.").And.NotContain("Done."); 92 | } 93 | 94 | [Fact(Timeout = 15000)] 95 | public async Task I_can_execute_a_command_with_buffering_and_cancel_it_immediately() 96 | { 97 | // Arrange 98 | using var cts = new CancellationTokenSource(); 99 | await cts.CancelAsync(); 100 | 101 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 102 | 103 | // Act & assert 104 | var ex = await Assert.ThrowsAnyAsync( 105 | async () => await cmd.ExecuteBufferedAsync(cts.Token) 106 | ); 107 | 108 | ex.CancellationToken.Should().Be(cts.Token); 109 | } 110 | 111 | [Fact(Timeout = 15000)] 112 | public async Task I_can_execute_a_command_with_buffering_and_cancel_it_after_a_delay() 113 | { 114 | // Arrange 115 | using var cts = new CancellationTokenSource(); 116 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 117 | 118 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 119 | 120 | // Act & assert 121 | var ex = await Assert.ThrowsAnyAsync( 122 | async () => await cmd.ExecuteBufferedAsync(cts.Token) 123 | ); 124 | 125 | ex.CancellationToken.Should().Be(cts.Token); 126 | } 127 | 128 | [Fact(Timeout = 15000)] 129 | public async Task I_can_execute_a_command_with_buffering_and_cancel_it_gracefully_after_a_delay() 130 | { 131 | // Arrange 132 | using var cts = new CancellationTokenSource(); 133 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 134 | 135 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 136 | 137 | // Act & assert 138 | var ex = await Assert.ThrowsAnyAsync( 139 | async () => 140 | await cmd.ExecuteBufferedAsync( 141 | Encoding.Default, 142 | Encoding.Default, 143 | CancellationToken.None, 144 | cts.Token 145 | ) 146 | ); 147 | 148 | ex.CancellationToken.Should().Be(cts.Token); 149 | } 150 | 151 | [Fact(Timeout = 15000)] 152 | public async Task I_can_execute_a_command_as_a_pull_based_event_stream_and_cancel_it_immediately() 153 | { 154 | // Arrange 155 | using var cts = new CancellationTokenSource(); 156 | await cts.CancelAsync(); 157 | 158 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 159 | 160 | // Act & assert 161 | var ex = await Assert.ThrowsAnyAsync(async () => 162 | { 163 | await foreach (var cmdEvent in cmd.ListenAsync(cts.Token)) 164 | { 165 | if (cmdEvent is StandardOutputCommandEvent stdOutEvent) 166 | stdOutEvent.Text.Should().NotContain("Done."); 167 | } 168 | }); 169 | 170 | ex.CancellationToken.Should().Be(cts.Token); 171 | } 172 | 173 | [Fact(Timeout = 15000)] 174 | public async Task I_can_execute_a_command_as_a_pull_based_event_stream_and_cancel_it_after_a_delay() 175 | { 176 | // Arrange 177 | using var cts = new CancellationTokenSource(); 178 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 179 | 180 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 181 | 182 | // Act & assert 183 | var ex = await Assert.ThrowsAnyAsync(async () => 184 | { 185 | await foreach (var cmdEvent in cmd.ListenAsync(cts.Token)) 186 | { 187 | if (cmdEvent is StandardOutputCommandEvent stdOutEvent) 188 | stdOutEvent.Text.Should().NotContain("Done."); 189 | } 190 | }); 191 | 192 | ex.CancellationToken.Should().Be(cts.Token); 193 | } 194 | 195 | [Fact(Timeout = 15000)] 196 | public async Task I_can_execute_a_command_as_a_pull_based_event_stream_and_cancel_it_gracefully_after_a_delay() 197 | { 198 | // Arrange 199 | using var cts = new CancellationTokenSource(); 200 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 201 | 202 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 203 | 204 | // Act & assert 205 | var ex = await Assert.ThrowsAnyAsync(async () => 206 | { 207 | await foreach ( 208 | var cmdEvent in cmd.ListenAsync( 209 | Encoding.Default, 210 | Encoding.Default, 211 | CancellationToken.None, 212 | cts.Token 213 | ) 214 | ) 215 | { 216 | if (cmdEvent is StandardOutputCommandEvent stdOutEvent) 217 | stdOutEvent.Text.Should().NotContain("Done."); 218 | } 219 | }); 220 | 221 | ex.CancellationToken.Should().Be(cts.Token); 222 | } 223 | 224 | [Fact(Timeout = 15000)] 225 | public async Task I_can_execute_a_command_as_a_push_based_event_stream_and_cancel_it_immediately() 226 | { 227 | // Arrange 228 | using var cts = new CancellationTokenSource(); 229 | await cts.CancelAsync(); 230 | 231 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 232 | 233 | // Act & assert 234 | var ex = await Assert.ThrowsAnyAsync( 235 | async () => 236 | await cmd.Observe(cts.Token) 237 | .ForEachAsync( 238 | cmdEvent => 239 | { 240 | if (cmdEvent is StandardOutputCommandEvent stdOutEvent) 241 | stdOutEvent.Text.Should().NotContain("Done."); 242 | }, 243 | CancellationToken.None 244 | ) 245 | ); 246 | 247 | ex.CancellationToken.Should().Be(cts.Token); 248 | } 249 | 250 | [Fact(Timeout = 15000)] 251 | public async Task I_can_execute_a_command_as_a_push_based_event_stream_and_cancel_it_after_a_delay() 252 | { 253 | // Arrange 254 | using var cts = new CancellationTokenSource(); 255 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 256 | 257 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 258 | 259 | // Act & assert 260 | var ex = await Assert.ThrowsAnyAsync( 261 | async () => 262 | await cmd.Observe(cts.Token) 263 | .ForEachAsync( 264 | cmdEvent => 265 | { 266 | if (cmdEvent is StandardOutputCommandEvent stdOutEvent) 267 | stdOutEvent.Text.Should().NotContain("Done."); 268 | }, 269 | CancellationToken.None 270 | ) 271 | ); 272 | 273 | ex.CancellationToken.Should().Be(cts.Token); 274 | } 275 | 276 | [Fact(Timeout = 15000)] 277 | public async Task I_can_execute_a_command_as_a_push_based_event_stream_and_cancel_it_gracefully_after_a_delay() 278 | { 279 | // Arrange 280 | using var cts = new CancellationTokenSource(); 281 | cts.CancelAfter(TimeSpan.FromSeconds(0.2)); 282 | 283 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["sleep", "00:00:20"]); 284 | 285 | // Act & assert 286 | var ex = await Assert.ThrowsAnyAsync( 287 | async () => 288 | await cmd.Observe( 289 | Encoding.Default, 290 | Encoding.Default, 291 | CancellationToken.None, 292 | cts.Token 293 | ) 294 | .ForEachAsync( 295 | cmdEvent => 296 | { 297 | if (cmdEvent is StandardOutputCommandEvent stdOutEvent) 298 | stdOutEvent.Text.Should().NotContain("Done."); 299 | }, 300 | CancellationToken.None 301 | ) 302 | ); 303 | 304 | ex.CancellationToken.Should().Be(cts.Token); 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /CliWrap.Tests/CliWrap.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | $(TargetFrameworks);net48 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /CliWrap.Tests/ConfigurationSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using CliWrap.Builders; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace CliWrap.Tests; 9 | 10 | public class ConfigurationSpecs 11 | { 12 | [Fact] 13 | public void I_can_create_a_command_with_the_default_configuration() 14 | { 15 | // Act 16 | var cmd = Cli.Wrap("foo"); 17 | 18 | // Assert 19 | cmd.TargetFilePath.Should().Be("foo"); 20 | cmd.Arguments.Should().BeEmpty(); 21 | cmd.WorkingDirPath.Should().Be(Directory.GetCurrentDirectory()); 22 | cmd.ResourcePolicy.Should().Be(ResourcePolicy.Default); 23 | cmd.Credentials.Should().BeEquivalentTo(Credentials.Default); 24 | cmd.EnvironmentVariables.Should().BeEmpty(); 25 | cmd.Validation.Should().Be(CommandResultValidation.ZeroExitCode); 26 | cmd.StandardInputPipe.Should().Be(PipeSource.Null); 27 | cmd.StandardOutputPipe.Should().Be(PipeTarget.Null); 28 | cmd.StandardErrorPipe.Should().Be(PipeTarget.Null); 29 | } 30 | 31 | [Fact] 32 | public void I_can_configure_the_target_file() 33 | { 34 | // Arrange 35 | var original = Cli.Wrap("foo"); 36 | 37 | // Act 38 | var modified = original.WithTargetFile("bar"); 39 | 40 | // Assert 41 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.TargetFilePath)); 42 | original.TargetFilePath.Should().NotBe(modified.TargetFilePath); 43 | modified.TargetFilePath.Should().Be("bar"); 44 | } 45 | 46 | [Fact] 47 | public void I_can_configure_the_command_line_arguments() 48 | { 49 | // Arrange 50 | var original = Cli.Wrap("foo").WithArguments("xxx"); 51 | 52 | // Act 53 | var modified = original.WithArguments("qqq ppp"); 54 | 55 | // Assert 56 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.Arguments)); 57 | original.Arguments.Should().NotBe(modified.Arguments); 58 | modified.Arguments.Should().Be("qqq ppp"); 59 | } 60 | 61 | [Fact] 62 | public void I_can_configure_the_command_line_arguments_by_passing_an_array() 63 | { 64 | // Arrange 65 | var original = Cli.Wrap("foo").WithArguments("xxx"); 66 | 67 | // Act 68 | var modified = original.WithArguments(["-a", "foo bar"]); 69 | 70 | // Assert 71 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.Arguments)); 72 | original.Arguments.Should().NotBe(modified.Arguments); 73 | modified.Arguments.Should().Be("-a \"foo bar\""); 74 | } 75 | 76 | [Fact] 77 | public void I_can_configure_the_command_line_arguments_using_a_builder() 78 | { 79 | // Arrange 80 | var original = Cli.Wrap("foo").WithArguments("xxx"); 81 | 82 | // Act 83 | var modified = original.WithArguments(b => 84 | b.Add("-a") 85 | .Add("foo bar") 86 | .Add("\"foo\\\\bar\"") 87 | .Add(3.14) 88 | .Add(["foo", "bar"]) 89 | .Add([-5, 89.13]) 90 | ); 91 | 92 | // Assert 93 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.Arguments)); 94 | original.Arguments.Should().NotBe(modified.Arguments); 95 | modified 96 | .Arguments.Should() 97 | .Be("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -5 89.13"); 98 | } 99 | 100 | [Fact] 101 | public void I_can_configure_the_working_directory() 102 | { 103 | // Arrange 104 | var original = Cli.Wrap("foo").WithWorkingDirectory("xxx"); 105 | 106 | // Act 107 | var modified = original.WithWorkingDirectory("new"); 108 | 109 | // Assert 110 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.WorkingDirPath)); 111 | original.WorkingDirPath.Should().NotBe(modified.WorkingDirPath); 112 | modified.WorkingDirPath.Should().Be("new"); 113 | } 114 | 115 | [Fact] 116 | public void I_can_configure_the_resource_policy() 117 | { 118 | // Arrange 119 | var original = Cli.Wrap("foo").WithResourcePolicy(ResourcePolicy.Default); 120 | 121 | // Act 122 | var modified = original.WithResourcePolicy( 123 | new ResourcePolicy(ProcessPriorityClass.High, 0x1, 1024, 2048) 124 | ); 125 | 126 | // Assert 127 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.ResourcePolicy)); 128 | original.ResourcePolicy.Should().NotBe(modified.ResourcePolicy); 129 | modified 130 | .ResourcePolicy.Should() 131 | .BeEquivalentTo(new ResourcePolicy(ProcessPriorityClass.High, 0x1, 1024, 2048)); 132 | } 133 | 134 | [Fact] 135 | public void I_can_configure_the_resource_policy_using_a_builder() 136 | { 137 | // Arrange 138 | var original = Cli.Wrap("foo").WithResourcePolicy(ResourcePolicy.Default); 139 | 140 | // Act 141 | var modified = original.WithResourcePolicy(b => 142 | b.SetPriority(ProcessPriorityClass.High) 143 | .SetAffinity(0x1) 144 | .SetMinWorkingSet(1024) 145 | .SetMaxWorkingSet(2048) 146 | ); 147 | 148 | // Assert 149 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.ResourcePolicy)); 150 | original.ResourcePolicy.Should().NotBe(modified.ResourcePolicy); 151 | modified 152 | .ResourcePolicy.Should() 153 | .BeEquivalentTo(new ResourcePolicy(ProcessPriorityClass.High, 0x1, 1024, 2048)); 154 | } 155 | 156 | [Fact] 157 | public void I_can_configure_the_user_credentials() 158 | { 159 | // Arrange 160 | var original = Cli.Wrap("foo").WithCredentials(new Credentials("xxx", "xxx", "xxx")); 161 | 162 | // Act 163 | var modified = original.WithCredentials( 164 | new Credentials("domain", "username", "password", true) 165 | ); 166 | 167 | // Assert 168 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.Credentials)); 169 | original.Credentials.Should().NotBe(modified.Credentials); 170 | modified 171 | .Credentials.Should() 172 | .BeEquivalentTo(new Credentials("domain", "username", "password", true)); 173 | } 174 | 175 | [Fact] 176 | public void I_can_configure_the_user_credentials_using_a_builder() 177 | { 178 | // Arrange 179 | var original = Cli.Wrap("foo").WithCredentials(new Credentials("xxx", "xxx", "xxx")); 180 | 181 | // Act 182 | var modified = original.WithCredentials(c => 183 | c.SetDomain("domain").SetUserName("username").SetPassword("password").LoadUserProfile() 184 | ); 185 | 186 | // Assert 187 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.Credentials)); 188 | original.Credentials.Should().NotBe(modified.Credentials); 189 | modified 190 | .Credentials.Should() 191 | .BeEquivalentTo(new Credentials("domain", "username", "password", true)); 192 | } 193 | 194 | [Fact] 195 | public void I_can_configure_the_environment_variables() 196 | { 197 | // Arrange 198 | var original = Cli.Wrap("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx")); 199 | 200 | // Act 201 | var modified = original.WithEnvironmentVariables( 202 | new Dictionary { ["name"] = "value", ["key"] = "door" } 203 | ); 204 | 205 | // Assert 206 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.EnvironmentVariables)); 207 | original.EnvironmentVariables.Should().NotBeEquivalentTo(modified.EnvironmentVariables); 208 | modified 209 | .EnvironmentVariables.Should() 210 | .BeEquivalentTo( 211 | new Dictionary { ["name"] = "value", ["key"] = "door" } 212 | ); 213 | } 214 | 215 | [Fact] 216 | public void I_can_configure_the_environment_variables_using_a_builder() 217 | { 218 | // Arrange 219 | var original = Cli.Wrap("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx")); 220 | 221 | // Act 222 | var modified = original.WithEnvironmentVariables(b => 223 | b.Set("name", "value") 224 | .Set("key", "door") 225 | .Set(new Dictionary { ["zzz"] = "yyy", ["aaa"] = "bbb" }) 226 | ); 227 | 228 | // Assert 229 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.EnvironmentVariables)); 230 | original.EnvironmentVariables.Should().NotBeEquivalentTo(modified.EnvironmentVariables); 231 | modified 232 | .EnvironmentVariables.Should() 233 | .BeEquivalentTo( 234 | new Dictionary 235 | { 236 | ["name"] = "value", 237 | ["key"] = "door", 238 | ["zzz"] = "yyy", 239 | ["aaa"] = "bbb", 240 | } 241 | ); 242 | } 243 | 244 | [Fact] 245 | public void I_can_configure_the_result_validation_strategy() 246 | { 247 | // Arrange 248 | var original = Cli.Wrap("foo").WithValidation(CommandResultValidation.ZeroExitCode); 249 | 250 | // Act 251 | var modified = original.WithValidation(CommandResultValidation.None); 252 | 253 | // Assert 254 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.Validation)); 255 | original.Validation.Should().NotBe(modified.Validation); 256 | modified.Validation.Should().Be(CommandResultValidation.None); 257 | } 258 | 259 | [Fact] 260 | public void I_can_configure_the_stdin_pipe() 261 | { 262 | // Arrange 263 | var original = Cli.Wrap("foo").WithStandardInputPipe(PipeSource.Null); 264 | 265 | // Act 266 | var modified = original.WithStandardInputPipe(PipeSource.FromString("new")); 267 | 268 | // Assert 269 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.StandardInputPipe)); 270 | original.StandardInputPipe.Should().NotBeSameAs(modified.StandardInputPipe); 271 | } 272 | 273 | [Fact] 274 | public void I_can_configure_the_stdout_pipe() 275 | { 276 | // Arrange 277 | var original = Cli.Wrap("foo").WithStandardOutputPipe(PipeTarget.Null); 278 | 279 | // Act 280 | var modified = original.WithStandardOutputPipe(PipeTarget.ToStream(Stream.Null)); 281 | 282 | // Assert 283 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.StandardOutputPipe)); 284 | original.StandardOutputPipe.Should().NotBeSameAs(modified.StandardOutputPipe); 285 | } 286 | 287 | [Fact] 288 | public void I_can_configure_the_stderr_pipe() 289 | { 290 | // Arrange 291 | var original = Cli.Wrap("foo").WithStandardErrorPipe(PipeTarget.Null); 292 | 293 | // Act 294 | var modified = original.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null)); 295 | 296 | // Assert 297 | original.Should().BeEquivalentTo(modified, o => o.Excluding(c => c.StandardErrorPipe)); 298 | original.StandardErrorPipe.Should().NotBeSameAs(modified.StandardErrorPipe); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /CliWrap.Tests/CredentialsSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Runtime.InteropServices; 4 | using System.Threading.Tasks; 5 | using Xunit; 6 | 7 | namespace CliWrap.Tests; 8 | 9 | public class CredentialsSpecs 10 | { 11 | [SkippableFact(Timeout = 15000)] 12 | public async Task I_can_execute_a_command_as_a_different_user() 13 | { 14 | Skip.IfNot( 15 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows), 16 | "Starting a process as another user is only supported on Windows." 17 | ); 18 | 19 | // We can't really test the happy path, but we can at least verify 20 | // that the credentials have been passed by getting an exception. 21 | 22 | // Arrange 23 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 24 | .WithCredentials(c => 25 | c.SetUserName("user123").SetPassword("pass123").LoadUserProfile() 26 | ); 27 | 28 | // Act & assert 29 | await Assert.ThrowsAsync(() => cmd.ExecuteAsync()); 30 | } 31 | 32 | [SkippableFact(Timeout = 15000)] 33 | public async Task I_can_execute_a_command_as_a_different_user_under_the_specified_domain() 34 | { 35 | Skip.IfNot( 36 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows), 37 | "Starting a process as another user is only supported on Windows." 38 | ); 39 | 40 | // We can't really test the happy path, but we can at least verify 41 | // that the credentials have been passed by getting an exception. 42 | 43 | // Arrange 44 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 45 | .WithCredentials(c => 46 | c.SetDomain("domain123") 47 | .SetUserName("user123") 48 | .SetPassword("pass123") 49 | .LoadUserProfile() 50 | ); 51 | 52 | // Act & assert 53 | await Assert.ThrowsAsync(() => cmd.ExecuteAsync()); 54 | } 55 | 56 | [SkippableFact(Timeout = 15000)] 57 | public async Task I_can_try_to_execute_a_command_as_a_different_user_and_get_an_error_if_the_operating_system_does_not_support_it() 58 | { 59 | Skip.If( 60 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows), 61 | "Starting a process as another user is fully supported on Windows." 62 | ); 63 | 64 | // Arrange 65 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 66 | .WithCredentials(c => c.SetUserName("user123").SetPassword("pass123")); 67 | 68 | // Act & assert 69 | await Assert.ThrowsAsync(() => cmd.ExecuteAsync()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /CliWrap.Tests/EnvironmentSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using CliWrap.Buffered; 5 | using CliWrap.Tests.Utils; 6 | using CliWrap.Tests.Utils.Extensions; 7 | using FluentAssertions; 8 | using Xunit; 9 | 10 | namespace CliWrap.Tests; 11 | 12 | public class EnvironmentSpecs 13 | { 14 | [Fact(Timeout = 15000)] 15 | public async Task I_can_execute_a_command_with_a_custom_working_directory() 16 | { 17 | // Arrange 18 | using var dir = TempDir.Create(); 19 | 20 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 21 | .WithArguments("cwd") 22 | .WithWorkingDirectory(dir.Path); 23 | 24 | // Act 25 | var result = await cmd.ExecuteBufferedAsync(); 26 | 27 | // Assert 28 | result.StandardOutput.Trim().Should().Be(dir.Path); 29 | } 30 | 31 | [Fact(Timeout = 15000)] 32 | public async Task I_can_execute_a_command_with_additional_environment_variables() 33 | { 34 | // Arrange 35 | var env = new Dictionary { ["foo"] = "bar", ["hello"] = "world" }; 36 | 37 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 38 | .WithArguments(["env", "foo", "hello"]) 39 | .WithEnvironmentVariables(env); 40 | 41 | // Act 42 | var result = await cmd.ExecuteBufferedAsync(); 43 | 44 | // Assert 45 | result.StandardOutput.Should().ConsistOfLines("bar", "world"); 46 | } 47 | 48 | [Fact(Timeout = 15000)] 49 | public async Task I_can_execute_a_command_with_some_environment_variables_overwritten() 50 | { 51 | // Arrange 52 | var key = Guid.NewGuid(); 53 | var variableToKeep = $"CLIWRAP_TEST_KEEP_{key}"; 54 | var variableToOverwrite = $"CLIWRAP_TEST_OVERWRITE_{key}"; 55 | var variableToUnset = $"CLIWRAP_TEST_UNSET_{key}"; 56 | 57 | using (TempEnvironmentVariable.Set(variableToKeep, "keep")) // will be left unchanged 58 | using (TempEnvironmentVariable.Set(variableToOverwrite, "overwrite")) // will be overwritten 59 | using (TempEnvironmentVariable.Set(variableToUnset, "unset")) // will be unset 60 | { 61 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 62 | .WithArguments(["env", variableToKeep, variableToOverwrite, variableToUnset]) 63 | .WithEnvironmentVariables(e => 64 | e.Set(variableToOverwrite, "overwritten").Set(variableToUnset, null) 65 | ); 66 | 67 | // Act 68 | var result = await cmd.ExecuteBufferedAsync(); 69 | 70 | // Assert 71 | result.StandardOutput.Should().ConsistOfLines("keep", "overwritten"); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /CliWrap.Tests/EventStreamSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Reactive.Linq; 4 | using System.Threading.Tasks; 5 | using CliWrap.EventStream; 6 | using FluentAssertions; 7 | using Xunit; 8 | 9 | namespace CliWrap.Tests; 10 | 11 | public class EventStreamSpecs 12 | { 13 | [Fact(Timeout = 15000)] 14 | public async Task I_can_execute_a_command_as_a_pull_based_event_stream() 15 | { 16 | // Arrange 17 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 18 | .WithArguments(["generate text", "--target", "all", "--lines", "100"]); 19 | 20 | // Act 21 | var events = new List(); 22 | await foreach (var cmdEvent in cmd.ListenAsync()) 23 | events.Add(cmdEvent); 24 | 25 | // Assert 26 | events.OfType().Should().ContainSingle(); 27 | events.OfType().Single().ProcessId.Should().NotBe(0); 28 | events.OfType().Should().HaveCount(100); 29 | events.OfType().Should().HaveCount(100); 30 | events.OfType().Should().ContainSingle(); 31 | events.OfType().Single().ExitCode.Should().Be(0); 32 | } 33 | 34 | [Fact(Timeout = 15000)] 35 | public async Task I_can_execute_a_command_as_a_push_based_event_stream() 36 | { 37 | // Arrange 38 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 39 | .WithArguments(["generate text", "--target", "all", "--lines", "100"]); 40 | 41 | // Act 42 | var events = await cmd.Observe().ToArray(); 43 | 44 | // Assert 45 | events.OfType().Should().ContainSingle(); 46 | events.OfType().Single().ProcessId.Should().NotBe(0); 47 | events.OfType().Should().HaveCount(100); 48 | events.OfType().Should().HaveCount(100); 49 | events.OfType().Should().ContainSingle(); 50 | events.OfType().Single().ExitCode.Should().Be(0); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /CliWrap.Tests/ExecutionSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Threading.Tasks; 4 | using FluentAssertions; 5 | using Xunit; 6 | 7 | namespace CliWrap.Tests; 8 | 9 | public class ExecutionSpecs 10 | { 11 | [Fact(Timeout = 15000)] 12 | public async Task I_can_execute_a_command_and_get_the_exit_code_and_execution_time() 13 | { 14 | // Arrange 15 | var cmd = Cli.Wrap(Dummy.Program.FilePath); 16 | 17 | // Act 18 | var result = await cmd.ExecuteAsync(); 19 | 20 | // Assert 21 | result.ExitCode.Should().Be(0); 22 | result.IsSuccess.Should().BeTrue(); 23 | result.RunTime.Should().BeGreaterThan(TimeSpan.Zero); 24 | } 25 | 26 | [Fact(Timeout = 15000)] 27 | public async Task I_can_execute_a_command_and_get_the_associated_process_ID() 28 | { 29 | // Arrange 30 | var cmd = Cli.Wrap(Dummy.Program.FilePath); 31 | 32 | // Act 33 | var task = cmd.ExecuteAsync(); 34 | 35 | // Assert 36 | task.ProcessId.Should().NotBe(0); 37 | 38 | await task; 39 | } 40 | 41 | [Fact(Timeout = 15000)] 42 | public async Task I_can_execute_a_command_with_a_configured_awaiter() 43 | { 44 | // Arrange 45 | var cmd = Cli.Wrap(Dummy.Program.FilePath); 46 | 47 | // Act & assert 48 | await cmd.ExecuteAsync().ConfigureAwait(false); 49 | } 50 | 51 | [Fact(Timeout = 15000)] 52 | public async Task I_can_execute_a_command_with_manually_configured_process_settings() 53 | { 54 | // Arrange 55 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithValidation(CommandResultValidation.None); 56 | 57 | // Act 58 | var result = await cmd.ExecuteAsync( 59 | startInfo => startInfo.Arguments = "exit 13", 60 | process => process.PriorityBoostEnabled = false 61 | ); 62 | 63 | // Assert 64 | result.ExitCode.Should().Be(13); 65 | } 66 | 67 | [Fact(Timeout = 15000)] 68 | public async Task I_can_execute_a_command_and_not_hang_on_large_stdout_and_stderr() 69 | { 70 | // Arrange 71 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 72 | .WithArguments(["generate binary", "--target", "all", "--length", "100000"]); 73 | 74 | // Act & assert 75 | await cmd.ExecuteAsync(); 76 | } 77 | 78 | [Fact] 79 | public void I_can_try_to_execute_a_command_and_get_an_error_if_the_target_file_does_not_exist() 80 | { 81 | // Arrange 82 | var cmd = Cli.Wrap("I_do_not_exist.exe"); 83 | 84 | // Act & assert 85 | 86 | // Should throw synchronously 87 | // https://github.com/Tyrrrz/CliWrap/issues/139 88 | Assert.ThrowsAny( 89 | () => 90 | // xUnit tells us to use ThrowsAnyAsync(...) instead for async methods, 91 | // but we're actually interested in the sync portion of this method. 92 | // So cast the result to object to avoid the warning. 93 | (object)cmd.ExecuteAsync() 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /CliWrap.Tests/LineBreakSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using FluentAssertions; 4 | using Xunit; 5 | 6 | namespace CliWrap.Tests; 7 | 8 | public class LineBreakSpecs 9 | { 10 | [Fact(Timeout = 15000)] 11 | public async Task I_can_execute_a_command_and_split_the_stdout_by_newline() 12 | { 13 | // Arrange 14 | const string data = "Foo\nBar\nBaz"; 15 | 16 | var stdOutLines = new List(); 17 | 18 | var cmd = 19 | data | Cli.Wrap(Dummy.Program.FilePath).WithArguments("echo stdin") | stdOutLines.Add; 20 | 21 | // Act 22 | await cmd.ExecuteAsync(); 23 | 24 | // Assert 25 | stdOutLines.Should().Equal("Foo", "Bar", "Baz"); 26 | } 27 | 28 | [Fact(Timeout = 15000)] 29 | public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return() 30 | { 31 | // Arrange 32 | const string data = "Foo\rBar\rBaz"; 33 | 34 | var stdOutLines = new List(); 35 | 36 | var cmd = 37 | data | Cli.Wrap(Dummy.Program.FilePath).WithArguments("echo stdin") | stdOutLines.Add; 38 | 39 | // Act 40 | await cmd.ExecuteAsync(); 41 | 42 | // Assert 43 | stdOutLines.Should().Equal("Foo", "Bar", "Baz"); 44 | } 45 | 46 | [Fact(Timeout = 15000)] 47 | public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return_followed_by_newline() 48 | { 49 | // Arrange 50 | const string data = "Foo\r\nBar\r\nBaz"; 51 | 52 | var stdOutLines = new List(); 53 | 54 | var cmd = 55 | data | Cli.Wrap(Dummy.Program.FilePath).WithArguments("echo stdin") | stdOutLines.Add; 56 | 57 | // Act 58 | await cmd.ExecuteAsync(); 59 | 60 | // Assert 61 | stdOutLines.Should().Equal("Foo", "Bar", "Baz"); 62 | } 63 | 64 | [Fact(Timeout = 15000)] 65 | public async Task I_can_execute_a_command_and_split_the_stdout_by_newline_while_including_empty_lines() 66 | { 67 | // Arrange 68 | const string data = "Foo\r\rBar\n\nBaz"; 69 | 70 | var stdOutLines = new List(); 71 | 72 | var cmd = 73 | data | Cli.Wrap(Dummy.Program.FilePath).WithArguments("echo stdin") | stdOutLines.Add; 74 | 75 | // Act 76 | await cmd.ExecuteAsync(); 77 | 78 | // Assert 79 | stdOutLines.Should().Equal("Foo", "", "Bar", "", "Baz"); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /CliWrap.Tests/PathResolutionSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Runtime.InteropServices; 3 | using System.Threading.Tasks; 4 | using CliWrap.Buffered; 5 | using CliWrap.Tests.Utils; 6 | using FluentAssertions; 7 | using Xunit; 8 | 9 | namespace CliWrap.Tests; 10 | 11 | public class PathResolutionSpecs 12 | { 13 | [Fact(Timeout = 15000)] 14 | public async Task I_can_execute_a_command_on_an_executable_using_its_short_name() 15 | { 16 | // Arrange 17 | var cmd = Cli.Wrap("dotnet").WithArguments("--version"); 18 | 19 | // Act 20 | var result = await cmd.ExecuteBufferedAsync(); 21 | 22 | // Assert 23 | result.IsSuccess.Should().BeTrue(); 24 | result.StandardOutput.Trim().Should().MatchRegex(@"^\d+\.\d+\.\d+$"); 25 | } 26 | 27 | [SkippableFact(Timeout = 15000)] 28 | public async Task I_can_execute_a_command_on_a_script_using_its_short_name() 29 | { 30 | Skip.IfNot( 31 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows), 32 | "Path resolution for scripts is only required on Windows." 33 | ); 34 | 35 | // Arrange 36 | using var dir = TempDir.Create(); 37 | File.WriteAllText(Path.Combine(dir.Path, "test-script.cmd"), "@echo hello"); 38 | 39 | using (TempEnvironmentVariable.ExtendPath(dir.Path)) 40 | { 41 | var cmd = Cli.Wrap("test-script"); 42 | 43 | // Act 44 | var result = await cmd.ExecuteBufferedAsync(); 45 | 46 | // Assert 47 | result.IsSuccess.Should().BeTrue(); 48 | result.StandardOutput.Trim().Should().Be("hello"); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CliWrap.Tests/ResourcePolicySpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Runtime.InteropServices; 4 | using System.Threading.Tasks; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace CliWrap.Tests; 9 | 10 | public class ResourcePolicySpecs 11 | { 12 | [SkippableFact(Timeout = 15000)] 13 | public async Task I_can_execute_a_command_with_a_custom_process_priority() 14 | { 15 | // Process priority is supported on other platforms, but setting it requires elevated permissions, 16 | // which we cannot guarantee in a CI environment. Therefore, we only test this on Windows. 17 | Skip.IfNot( 18 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows), 19 | "Starting a process with a custom priority is only supported on Windows." 20 | ); 21 | 22 | // Arrange 23 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 24 | .WithResourcePolicy(p => p.SetPriority(ProcessPriorityClass.High)); 25 | 26 | // Act 27 | var result = await cmd.ExecuteAsync(); 28 | 29 | // Assert 30 | result.ExitCode.Should().Be(0); 31 | } 32 | 33 | [SkippableFact(Timeout = 15000)] 34 | public async Task I_can_execute_a_command_with_a_custom_core_affinity() 35 | { 36 | Skip.IfNot( 37 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 38 | || RuntimeInformation.IsOSPlatform(OSPlatform.Linux), 39 | "Starting a process with a custom core affinity is only supported on Windows and Linux." 40 | ); 41 | 42 | // Arrange 43 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithResourcePolicy(p => p.SetAffinity(0b1010)); // Cores 1 and 3 44 | 45 | // Act 46 | var result = await cmd.ExecuteAsync(); 47 | 48 | // Assert 49 | result.ExitCode.Should().Be(0); 50 | } 51 | 52 | [SkippableFact(Timeout = 15000)] 53 | public async Task I_can_execute_a_command_with_a_custom_working_set_limit() 54 | { 55 | Skip.IfNot( 56 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows), 57 | "Starting a process with a custom working set limit is only supported on Windows." 58 | ); 59 | 60 | // Arrange 61 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 62 | .WithResourcePolicy(p => 63 | p.SetMinWorkingSet(1024 * 1024) // 1 MB 64 | .SetMaxWorkingSet(1024 * 1024 * 10) // 10 MB 65 | ); 66 | 67 | // Act 68 | var result = await cmd.ExecuteAsync(); 69 | 70 | // Assert 71 | result.ExitCode.Should().Be(0); 72 | } 73 | 74 | [SkippableFact(Timeout = 15000)] 75 | public async Task I_can_try_to_execute_a_command_with_a_custom_resource_policy_and_get_an_error_if_the_operating_system_does_not_support_it() 76 | { 77 | Skip.If( 78 | RuntimeInformation.IsOSPlatform(OSPlatform.Windows), 79 | "Starting a process with a custom resource policy is fully supported on Windows." 80 | ); 81 | 82 | // Arrange 83 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 84 | .WithResourcePolicy(p => p.SetMinWorkingSet(1024 * 1024)); 85 | 86 | // Act & assert 87 | await Assert.ThrowsAsync(() => cmd.ExecuteAsync()); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /CliWrap.Tests/Utils/Extensions/AssertionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using FluentAssertions; 4 | using FluentAssertions.Primitives; 5 | 6 | namespace CliWrap.Tests.Utils.Extensions; 7 | 8 | internal static class AssertionExtensions 9 | { 10 | public static void ConsistOfLines( 11 | this StringAssertions assertions, 12 | params IEnumerable lines 13 | ) => 14 | assertions 15 | .Subject.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) 16 | .Should() 17 | .Equal(lines); 18 | } 19 | -------------------------------------------------------------------------------- /CliWrap.Tests/Utils/ProcessEx.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace CliWrap.Tests.Utils; 4 | 5 | internal static class ProcessEx 6 | { 7 | public static bool IsRunning(int processId) 8 | { 9 | try 10 | { 11 | using var process = Process.GetProcessById(processId); 12 | return !process.HasExited; 13 | } 14 | catch 15 | { 16 | return false; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CliWrap.Tests/Utils/TempDir.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | using PathEx = System.IO.Path; 5 | 6 | namespace CliWrap.Tests.Utils; 7 | 8 | internal partial class TempDir(string path) : IDisposable 9 | { 10 | public string Path { get; } = path; 11 | 12 | public void Dispose() 13 | { 14 | try 15 | { 16 | Directory.Delete(Path, true); 17 | } 18 | catch (DirectoryNotFoundException) { } 19 | } 20 | } 21 | 22 | internal partial class TempDir 23 | { 24 | public static TempDir Create() 25 | { 26 | var dirPath = PathEx.Combine( 27 | PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 28 | ?? Directory.GetCurrentDirectory(), 29 | "Temp", 30 | Guid.NewGuid().ToString() 31 | ); 32 | 33 | Directory.CreateDirectory(dirPath); 34 | 35 | return new TempDir(dirPath); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /CliWrap.Tests/Utils/TempEnvironmentVariable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reactive.Disposables; 4 | 5 | namespace CliWrap.Tests.Utils; 6 | 7 | internal static class TempEnvironmentVariable 8 | { 9 | public static IDisposable Set(string name, string? value) 10 | { 11 | var lastValue = Environment.GetEnvironmentVariable(name); 12 | Environment.SetEnvironmentVariable(name, value); 13 | 14 | return Disposable.Create(() => Environment.SetEnvironmentVariable(name, lastValue)); 15 | } 16 | 17 | public static IDisposable ExtendPath(string path) => 18 | Set("PATH", Environment.GetEnvironmentVariable("PATH") + Path.PathSeparator + path); 19 | } 20 | -------------------------------------------------------------------------------- /CliWrap.Tests/Utils/TempFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | using PathEx = System.IO.Path; 5 | 6 | namespace CliWrap.Tests.Utils; 7 | 8 | internal partial class TempFile(string path) : IDisposable 9 | { 10 | public string Path { get; } = path; 11 | 12 | public void Dispose() 13 | { 14 | try 15 | { 16 | File.Delete(Path); 17 | } 18 | catch (FileNotFoundException) { } 19 | } 20 | } 21 | 22 | internal partial class TempFile 23 | { 24 | public static TempFile Create() 25 | { 26 | var dirPath = PathEx.Combine( 27 | PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 28 | ?? Directory.GetCurrentDirectory(), 29 | "Temp" 30 | ); 31 | 32 | Directory.CreateDirectory(dirPath); 33 | 34 | var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp"); 35 | 36 | return new TempFile(filePath); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /CliWrap.Tests/ValidationSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliWrap.Buffered; 3 | using CliWrap.Exceptions; 4 | using FluentAssertions; 5 | using Xunit; 6 | using Xunit.Abstractions; 7 | 8 | namespace CliWrap.Tests; 9 | 10 | public class ValidationSpecs(ITestOutputHelper testOutput) 11 | { 12 | [Fact(Timeout = 15000)] 13 | public async Task I_can_try_to_execute_a_command_and_get_an_error_if_it_returns_a_non_zero_exit_code() 14 | { 15 | // Arrange 16 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["exit", "1"]); 17 | 18 | // Act & assert 19 | var ex = await Assert.ThrowsAsync( 20 | async () => await cmd.ExecuteAsync() 21 | ); 22 | 23 | ex.ExitCode.Should().Be(1); 24 | ex.Command.Should().BeEquivalentTo(cmd); 25 | 26 | testOutput.WriteLine(ex.ToString()); 27 | } 28 | 29 | [Fact(Timeout = 15000)] 30 | public async Task I_can_try_to_execute_a_command_with_buffering_and_get_a_detailed_error_if_it_returns_a_non_zero_exit_code() 31 | { 32 | // Arrange 33 | var cmd = Cli.Wrap(Dummy.Program.FilePath).WithArguments(["exit", "1"]); 34 | 35 | // Act & assert 36 | var ex = await Assert.ThrowsAsync( 37 | async () => await cmd.ExecuteBufferedAsync() 38 | ); 39 | 40 | ex.Message.Should().Contain("Exit code set to 1"); // expected stderr 41 | ex.ExitCode.Should().Be(1); 42 | ex.Command.Should().BeEquivalentTo(cmd); 43 | 44 | testOutput.WriteLine(ex.ToString()); 45 | } 46 | 47 | [Fact(Timeout = 15000)] 48 | public async Task I_can_execute_a_command_without_validating_the_exit_code() 49 | { 50 | // Arrange 51 | var cmd = Cli.Wrap(Dummy.Program.FilePath) 52 | .WithArguments(["exit", "1"]) 53 | .WithValidation(CommandResultValidation.None); 54 | 55 | // Act 56 | var result = await cmd.ExecuteAsync(); 57 | 58 | // Assert 59 | result.ExitCode.Should().Be(1); 60 | result.IsSuccess.Should().BeFalse(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CliWrap.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "appDomain": "denied", 4 | "methodDisplayOptions": "all", 5 | "methodDisplay": "method" 6 | } -------------------------------------------------------------------------------- /CliWrap.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio 15 3 | VisualStudioVersion = 15.0.27130.2024 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{43514CCF-887D-43B9-81D0-A9930FE73772}" 6 | ProjectSection(SolutionItems) = preProject 7 | License.txt = License.txt 8 | Readme.md = Readme.md 9 | Directory.Build.props = Directory.Build.props 10 | EndProjectSection 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliWrap", "CliWrap\CliWrap.csproj", "{03ECCB7F-144F-4742-BF68-7F84026DA775}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliWrap.Tests", "CliWrap.Tests\CliWrap.Tests.csproj", "{890D9A75-4B00-4D89-AAF8-936726B45410}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliWrap.Tests.Dummy", "CliWrap.Tests.Dummy\CliWrap.Tests.Dummy.csproj", "{666CF814-2BA5-44E5-8C44-12A08E3C6D61}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliWrap.Benchmarks", "CliWrap.Benchmarks\CliWrap.Benchmarks.csproj", "{22636DB4-8A4D-4540-95AF-59C848459948}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliWrap.Signaler", "CliWrap.Signaler\CliWrap.Signaler.csproj", "{A0E41A11-D314-45C4-890B-831385450DF8}" 21 | EndProject 22 | Global 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Release|Any CPU = Release|Any CPU 26 | EndGlobalSection 27 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 28 | {03ECCB7F-144F-4742-BF68-7F84026DA775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {03ECCB7F-144F-4742-BF68-7F84026DA775}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {03ECCB7F-144F-4742-BF68-7F84026DA775}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {03ECCB7F-144F-4742-BF68-7F84026DA775}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {890D9A75-4B00-4D89-AAF8-936726B45410}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {890D9A75-4B00-4D89-AAF8-936726B45410}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {890D9A75-4B00-4D89-AAF8-936726B45410}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {890D9A75-4B00-4D89-AAF8-936726B45410}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {666CF814-2BA5-44E5-8C44-12A08E3C6D61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {666CF814-2BA5-44E5-8C44-12A08E3C6D61}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {666CF814-2BA5-44E5-8C44-12A08E3C6D61}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {666CF814-2BA5-44E5-8C44-12A08E3C6D61}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {22636DB4-8A4D-4540-95AF-59C848459948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {22636DB4-8A4D-4540-95AF-59C848459948}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {22636DB4-8A4D-4540-95AF-59C848459948}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {22636DB4-8A4D-4540-95AF-59C848459948}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {A0E41A11-D314-45C4-890B-831385450DF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {A0E41A11-D314-45C4-890B-831385450DF8}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {A0E41A11-D314-45C4-890B-831385450DF8}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {A0E41A11-D314-45C4-890B-831385450DF8}.Release|Any CPU.Build.0 = Release|Any CPU 48 | EndGlobalSection 49 | GlobalSection(SolutionProperties) = preSolution 50 | HideSolutionNode = FALSE 51 | EndGlobalSection 52 | GlobalSection(ExtensibilityGlobals) = postSolution 53 | SolutionGuid = {E62AAC63-51EC-45A1-A500-7B073424CA8C} 54 | EndGlobalSection 55 | EndGlobal 56 | -------------------------------------------------------------------------------- /CliWrap/Buffered/BufferedCommandExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading; 4 | using CliWrap.Exceptions; 5 | 6 | namespace CliWrap.Buffered; 7 | 8 | /// 9 | /// Buffered execution model. 10 | /// 11 | public static class BufferedCommandExtensions 12 | { 13 | /// 14 | /// Executes the command asynchronously with buffering. 15 | /// Data written to the standard output and standard error streams is decoded as text 16 | /// and returned as part of the result object. 17 | /// 18 | /// 19 | /// This method can be awaited. 20 | /// 21 | // TODO: (breaking change) use optional parameters and remove the other overload 22 | public static CommandTask ExecuteBufferedAsync( 23 | this Command command, 24 | Encoding standardOutputEncoding, 25 | Encoding standardErrorEncoding, 26 | CancellationToken forcefulCancellationToken, 27 | CancellationToken gracefulCancellationToken 28 | ) 29 | { 30 | var stdOutBuffer = new StringBuilder(); 31 | var stdErrBuffer = new StringBuilder(); 32 | 33 | var stdOutPipe = PipeTarget.Merge( 34 | command.StandardOutputPipe, 35 | PipeTarget.ToStringBuilder(stdOutBuffer, standardOutputEncoding) 36 | ); 37 | 38 | var stdErrPipe = PipeTarget.Merge( 39 | command.StandardErrorPipe, 40 | PipeTarget.ToStringBuilder(stdErrBuffer, standardErrorEncoding) 41 | ); 42 | 43 | var commandWithPipes = command 44 | .WithStandardOutputPipe(stdOutPipe) 45 | .WithStandardErrorPipe(stdErrPipe); 46 | 47 | return commandWithPipes 48 | .ExecuteAsync(forcefulCancellationToken, gracefulCancellationToken) 49 | .Bind(async task => 50 | { 51 | try 52 | { 53 | var result = await task; 54 | 55 | return new BufferedCommandResult( 56 | result.ExitCode, 57 | result.StartTime, 58 | result.ExitTime, 59 | stdOutBuffer.ToString(), 60 | stdErrBuffer.ToString() 61 | ); 62 | } 63 | catch (CommandExecutionException ex) 64 | { 65 | throw new CommandExecutionException( 66 | ex.Command, 67 | ex.ExitCode, 68 | $""" 69 | Command execution failed, see the inner exception for details. 70 | 71 | Standard error: 72 | {stdErrBuffer.ToString().Trim()} 73 | """, 74 | ex 75 | ); 76 | } 77 | }); 78 | } 79 | 80 | /// 81 | /// Executes the command asynchronously with buffering. 82 | /// Data written to the standard output and standard error streams is decoded as text 83 | /// and returned as part of the result object. 84 | /// 85 | /// 86 | /// This method can be awaited. 87 | /// 88 | public static CommandTask ExecuteBufferedAsync( 89 | this Command command, 90 | Encoding standardOutputEncoding, 91 | Encoding standardErrorEncoding, 92 | CancellationToken cancellationToken = default 93 | ) 94 | { 95 | return command.ExecuteBufferedAsync( 96 | standardOutputEncoding, 97 | standardErrorEncoding, 98 | cancellationToken, 99 | CancellationToken.None 100 | ); 101 | } 102 | 103 | /// 104 | /// Executes the command asynchronously with buffering. 105 | /// Data written to the standard output and standard error streams is decoded as text 106 | /// and returned as part of the result object. 107 | /// 108 | /// 109 | /// This method can be awaited. 110 | /// 111 | public static CommandTask ExecuteBufferedAsync( 112 | this Command command, 113 | Encoding encoding, 114 | CancellationToken cancellationToken = default 115 | ) 116 | { 117 | return command.ExecuteBufferedAsync(encoding, encoding, cancellationToken); 118 | } 119 | 120 | /// 121 | /// Executes the command asynchronously with buffering. 122 | /// Data written to the standard output and standard error streams is decoded as text 123 | /// and returned as part of the result object. 124 | /// Uses for decoding. 125 | /// 126 | /// 127 | /// This method can be awaited. 128 | /// 129 | public static CommandTask ExecuteBufferedAsync( 130 | this Command command, 131 | CancellationToken cancellationToken = default 132 | ) 133 | { 134 | return command.ExecuteBufferedAsync(Encoding.Default, cancellationToken); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /CliWrap/Buffered/BufferedCommandResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliWrap.Buffered; 4 | 5 | /// 6 | /// Result of a command execution, with buffered text data from standard output and standard error streams. 7 | /// 8 | public class BufferedCommandResult( 9 | int exitCode, 10 | DateTimeOffset startTime, 11 | DateTimeOffset exitTime, 12 | string standardOutput, 13 | string standardError 14 | ) : CommandResult(exitCode, startTime, exitTime) 15 | { 16 | /// 17 | /// Standard output data produced by the underlying process. 18 | /// 19 | public string StandardOutput { get; } = standardOutput; 20 | 21 | /// 22 | /// Standard error data produced by the underlying process. 23 | /// 24 | public string StandardError { get; } = standardError; 25 | } 26 | -------------------------------------------------------------------------------- /CliWrap/Builders/ArgumentsBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Text; 6 | 7 | namespace CliWrap.Builders; 8 | 9 | /// 10 | /// Builder that helps format command-line arguments into a string. 11 | /// 12 | public partial class ArgumentsBuilder 13 | { 14 | private static readonly IFormatProvider DefaultFormatProvider = CultureInfo.InvariantCulture; 15 | 16 | private readonly StringBuilder _buffer = new(); 17 | 18 | /// 19 | /// Adds the specified value to the list of arguments. 20 | /// 21 | public ArgumentsBuilder Add(string value, bool escape) 22 | { 23 | if (_buffer.Length > 0) 24 | _buffer.Append(' '); 25 | 26 | _buffer.Append(escape ? Escape(value) : value); 27 | 28 | return this; 29 | } 30 | 31 | /// 32 | /// Adds the specified value to the list of arguments. 33 | /// 34 | // TODO: (breaking change) remove in favor of optional parameter 35 | public ArgumentsBuilder Add(string value) => Add(value, true); 36 | 37 | /// 38 | /// Adds the specified values to the list of arguments. 39 | /// 40 | public ArgumentsBuilder Add(IEnumerable values, bool escape) 41 | { 42 | foreach (var value in values) 43 | Add(value, escape); 44 | 45 | return this; 46 | } 47 | 48 | /// 49 | /// Adds the specified values to the list of arguments. 50 | /// 51 | // TODO: (breaking change) remove in favor of optional parameter 52 | public ArgumentsBuilder Add(IEnumerable values) => Add(values, true); 53 | 54 | /// 55 | /// Adds the specified value to the list of arguments. 56 | /// 57 | public ArgumentsBuilder Add( 58 | IFormattable value, 59 | IFormatProvider formatProvider, 60 | bool escape = true 61 | ) => Add(value.ToString(null, formatProvider), escape); 62 | 63 | /// 64 | /// Adds the specified value to the list of arguments. 65 | /// 66 | // TODO: (breaking change) remove in favor of the other overloads 67 | public ArgumentsBuilder Add(IFormattable value, CultureInfo cultureInfo, bool escape) => 68 | Add(value, (IFormatProvider)cultureInfo, escape); 69 | 70 | /// 71 | /// Adds the specified value to the list of arguments. 72 | /// 73 | // TODO: (breaking change) remove in favor of the other overloads 74 | public ArgumentsBuilder Add(IFormattable value, CultureInfo cultureInfo) => 75 | Add(value, cultureInfo, true); 76 | 77 | /// 78 | /// Adds the specified value to the list of arguments. 79 | /// The value is converted to string using invariant culture. 80 | /// 81 | public ArgumentsBuilder Add(IFormattable value, bool escape) => 82 | Add(value, DefaultFormatProvider, escape); 83 | 84 | /// 85 | /// Adds the specified value to the list of arguments. 86 | /// The value is converted to string using invariant culture. 87 | /// 88 | // TODO: (breaking change) remove in favor of optional parameter 89 | public ArgumentsBuilder Add(IFormattable value) => Add(value, true); 90 | 91 | /// 92 | /// Adds the specified values to the list of arguments. 93 | /// 94 | public ArgumentsBuilder Add( 95 | IEnumerable values, 96 | IFormatProvider formatProvider, 97 | bool escape = true 98 | ) 99 | { 100 | foreach (var value in values) 101 | Add(value, formatProvider, escape); 102 | 103 | return this; 104 | } 105 | 106 | /// 107 | /// Adds the specified values to the list of arguments. 108 | /// 109 | // TODO: (breaking change) remove in favor of the other overloads 110 | public ArgumentsBuilder Add( 111 | IEnumerable values, 112 | CultureInfo cultureInfo, 113 | bool escape 114 | ) => Add(values, (IFormatProvider)cultureInfo, escape); 115 | 116 | /// 117 | /// Adds the specified values to the list of arguments. 118 | /// 119 | // TODO: (breaking change) remove in favor of the other overloads 120 | public ArgumentsBuilder Add(IEnumerable values, CultureInfo cultureInfo) => 121 | Add(values, cultureInfo, true); 122 | 123 | /// 124 | /// Adds the specified values to the list of arguments. 125 | /// The values are converted to string using invariant culture. 126 | /// 127 | public ArgumentsBuilder Add(IEnumerable values, bool escape) => 128 | Add(values, DefaultFormatProvider, escape); 129 | 130 | /// 131 | /// Adds the specified values to the list of arguments. 132 | /// The values are converted to string using invariant culture. 133 | /// 134 | // TODO: (breaking change) remove in favor of optional parameter 135 | public ArgumentsBuilder Add(IEnumerable values) => Add(values, true); 136 | 137 | /// 138 | /// Builds the resulting arguments string. 139 | /// 140 | public string Build() => _buffer.ToString(); 141 | } 142 | 143 | public partial class ArgumentsBuilder 144 | { 145 | /// 146 | /// Escapes special characters (spaces, slashes, and quotes) in the specified string, ensuring that the output 147 | /// is correctly interpreted as a single argument when passed to a command-line application. 148 | /// 149 | /// 150 | /// In most cases, you should not need to use this method, as already escapes 151 | /// arguments automatically. This method is provided for advanced scenarios where you need to escape arguments 152 | /// manually. 153 | /// 154 | public static string Escape(string argument) 155 | { 156 | // Implementation reference: 157 | // https://github.com/dotnet/runtime/blob/9a50493f9f1125fda5e2212b9d6718bc7cdbc5c0/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs#L10-L79 158 | // MIT License, .NET Foundation 159 | 160 | // Short circuit if the argument is clean and doesn't need escaping 161 | if (argument.Length > 0 && argument.All(c => !char.IsWhiteSpace(c) && c != '"')) 162 | return argument; 163 | 164 | var buffer = new StringBuilder(); 165 | 166 | buffer.Append('"'); 167 | 168 | for (var i = 0; i < argument.Length; ) 169 | { 170 | var c = argument[i++]; 171 | 172 | if (c == '\\') 173 | { 174 | var backslashCount = 1; 175 | while (i < argument.Length && argument[i] == '\\') 176 | { 177 | backslashCount++; 178 | i++; 179 | } 180 | 181 | if (i == argument.Length) 182 | { 183 | buffer.Append('\\', backslashCount * 2); 184 | } 185 | else if (argument[i] == '"') 186 | { 187 | buffer.Append('\\', backslashCount * 2 + 1).Append('"'); 188 | 189 | i++; 190 | } 191 | else 192 | { 193 | buffer.Append('\\', backslashCount); 194 | } 195 | } 196 | else if (c == '"') 197 | { 198 | buffer.Append('\\').Append('"'); 199 | } 200 | else 201 | { 202 | buffer.Append(c); 203 | } 204 | } 205 | 206 | buffer.Append('"'); 207 | 208 | return buffer.ToString(); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /CliWrap/Builders/CredentialsBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace CliWrap.Builders; 4 | 5 | /// 6 | /// Builder that helps configure user credentials. 7 | /// 8 | public class CredentialsBuilder 9 | { 10 | private string? _domain; 11 | private string? _userName; 12 | private string? _password; 13 | private bool _loadUserProfile; 14 | 15 | /// 16 | /// Sets the Active Directory domain used when starting the process. 17 | /// 18 | /// 19 | /// For information on platform support, see attributes on . 20 | /// 21 | public CredentialsBuilder SetDomain(string? domain) 22 | { 23 | _domain = domain; 24 | return this; 25 | } 26 | 27 | /// 28 | /// Sets the username used when starting the process. 29 | /// 30 | /// 31 | /// For information on platform support, see attributes on . 32 | /// 33 | public CredentialsBuilder SetUserName(string? userName) 34 | { 35 | _userName = userName; 36 | return this; 37 | } 38 | 39 | /// 40 | /// Sets the password used when starting the process. 41 | /// 42 | /// 43 | /// For information on platform support, see attributes on . 44 | /// 45 | public CredentialsBuilder SetPassword(string? password) 46 | { 47 | _password = password; 48 | return this; 49 | } 50 | 51 | /// 52 | /// Instructs whether to load the user profile when starting the process. 53 | /// 54 | /// 55 | /// For information on platform support, see attributes on . 56 | /// 57 | public CredentialsBuilder LoadUserProfile(bool loadUserProfile = true) 58 | { 59 | _loadUserProfile = loadUserProfile; 60 | return this; 61 | } 62 | 63 | /// 64 | /// Builds the resulting credentials. 65 | /// 66 | public Credentials Build() => new(_domain, _userName, _password, _loadUserProfile); 67 | } 68 | -------------------------------------------------------------------------------- /CliWrap/Builders/EnvironmentVariablesBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace CliWrap.Builders; 5 | 6 | /// 7 | /// Builder that helps configure environment variables. 8 | /// 9 | public class EnvironmentVariablesBuilder 10 | { 11 | private readonly Dictionary _envVars = new(StringComparer.Ordinal); 12 | 13 | /// 14 | /// Sets an environment variable with the specified name to the specified value. 15 | /// 16 | public EnvironmentVariablesBuilder Set(string name, string? value) 17 | { 18 | _envVars[name] = value; 19 | return this; 20 | } 21 | 22 | /// 23 | /// Sets multiple environment variables from the specified sequence of key-value pairs. 24 | /// 25 | public EnvironmentVariablesBuilder Set(IEnumerable> variables) 26 | { 27 | foreach (var (name, value) in variables) 28 | Set(name, value); 29 | 30 | return this; 31 | } 32 | 33 | /// 34 | /// Sets multiple environment variables from the specified dictionary. 35 | /// 36 | public EnvironmentVariablesBuilder Set(IReadOnlyDictionary variables) => 37 | Set((IEnumerable>)variables); 38 | 39 | /// 40 | /// Builds the resulting environment variables. 41 | /// 42 | public IReadOnlyDictionary Build() => 43 | // Create a new dictionary instance to prevent the builder from modifying it 44 | new Dictionary(_envVars, _envVars.Comparer); 45 | } 46 | -------------------------------------------------------------------------------- /CliWrap/Builders/ResourcePolicyBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace CliWrap.Builders; 4 | 5 | /// 6 | /// Builder that helps configure resource policy. 7 | /// 8 | public class ResourcePolicyBuilder 9 | { 10 | private ProcessPriorityClass? _priority; 11 | private nint? _affinity; 12 | private nint? _minWorkingSet; 13 | private nint? _maxWorkingSet; 14 | 15 | /// 16 | /// Sets the priority class of the process. 17 | /// 18 | /// 19 | /// For information on platform support, see attributes on . 20 | /// 21 | public ResourcePolicyBuilder SetPriority(ProcessPriorityClass? priority) 22 | { 23 | _priority = priority; 24 | return this; 25 | } 26 | 27 | /// 28 | /// Sets the processor core affinity mask of the process. 29 | /// For example, to set the affinity to cores 1 and 3 out of 4, pass 0b1010. 30 | /// 31 | /// 32 | /// For information on platform support, see attributes on . 33 | /// 34 | public ResourcePolicyBuilder SetAffinity(nint? affinity) 35 | { 36 | _affinity = affinity; 37 | return this; 38 | } 39 | 40 | /// 41 | /// Sets the minimum working set size of the process. 42 | /// 43 | /// 44 | /// For information on platform support, see attributes on . 45 | /// 46 | public ResourcePolicyBuilder SetMinWorkingSet(nint? minWorkingSet) 47 | { 48 | _minWorkingSet = minWorkingSet; 49 | return this; 50 | } 51 | 52 | /// 53 | /// Sets the maximum working set size of the process. 54 | /// 55 | /// 56 | /// For information on platform support, see attributes on . 57 | /// 58 | public ResourcePolicyBuilder SetMaxWorkingSet(nint? maxWorkingSet) 59 | { 60 | _maxWorkingSet = maxWorkingSet; 61 | return this; 62 | } 63 | 64 | /// 65 | /// Builds the resulting resource policy. 66 | /// 67 | public ResourcePolicy Build() => new(_priority, _affinity, _minWorkingSet, _maxWorkingSet); 68 | } 69 | -------------------------------------------------------------------------------- /CliWrap/Cli.cs: -------------------------------------------------------------------------------- 1 | namespace CliWrap; 2 | 3 | // The only reason this entry point exists is because it looks cool to have 4 | // an expression that matches the library name -- Cli.Wrap(). 5 | 6 | /// 7 | /// Main entry point for creating new commands. 8 | /// 9 | public static class Cli 10 | { 11 | /// 12 | /// Creates a new command that targets the specified command-line executable, batch file, or script. 13 | /// 14 | public static Command Wrap(string targetFilePath) => new(targetFilePath); 15 | } 16 | -------------------------------------------------------------------------------- /CliWrap/CliWrap.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0;netstandard2.1;net8.0;net9.0 5 | true 6 | true 7 | true 8 | 9 | 10 | 11 | favicon.png 12 | true 13 | 14 | 15 | 16 | true 17 | key.snk 18 | false 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | TargetFramework 43 | true 44 | false 45 | false 46 | false 47 | 48 | 49 | 50 | 51 | 52 | 53 | Signaler.exe 54 | Never 55 | false 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /CliWrap/Command.PipeOperators.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.Contracts; 3 | using System.IO; 4 | using System.Text; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace CliWrap; 9 | 10 | public partial class Command 11 | { 12 | /// 13 | /// Creates a new command that pipes its standard output to the specified target. 14 | /// 15 | [Pure] 16 | public static Command operator |(Command source, PipeTarget target) => 17 | source.WithStandardOutputPipe(target); 18 | 19 | /// 20 | /// Creates a new command that pipes its standard output to the specified stream. 21 | /// 22 | [Pure] 23 | public static Command operator |(Command source, Stream target) => 24 | source | PipeTarget.ToStream(target); 25 | 26 | /// 27 | /// Creates a new command that pipes its standard output to the specified string builder. 28 | /// Uses for decoding. 29 | /// 30 | [Pure] 31 | public static Command operator |(Command source, StringBuilder target) => 32 | source | PipeTarget.ToStringBuilder(target); 33 | 34 | /// 35 | /// Creates a new command that pipes its standard output line-by-line to the specified 36 | /// asynchronous delegate. 37 | /// Uses for decoding. 38 | /// 39 | [Pure] 40 | public static Command operator |( 41 | Command source, 42 | Func target 43 | ) => source | PipeTarget.ToDelegate(target); 44 | 45 | /// 46 | /// Creates a new command that pipes its standard output line-by-line to the specified 47 | /// asynchronous delegate. 48 | /// Uses for decoding. 49 | /// 50 | [Pure] 51 | public static Command operator |(Command source, Func target) => 52 | source | PipeTarget.ToDelegate(target); 53 | 54 | /// 55 | /// Creates a new command that pipes its standard output line-by-line to the specified 56 | /// synchronous delegate. 57 | /// Uses for decoding. 58 | /// 59 | [Pure] 60 | public static Command operator |(Command source, Action target) => 61 | source | PipeTarget.ToDelegate(target); 62 | 63 | /// 64 | /// Creates a new command that pipes its standard output and standard error to the 65 | /// specified targets. 66 | /// 67 | [Pure] 68 | public static Command operator |( 69 | Command source, 70 | (PipeTarget stdOut, PipeTarget stdErr) targets 71 | ) => source.WithStandardOutputPipe(targets.stdOut).WithStandardErrorPipe(targets.stdErr); 72 | 73 | /// 74 | /// Creates a new command that pipes its standard output and standard error to the 75 | /// specified streams. 76 | /// 77 | [Pure] 78 | public static Command operator |(Command source, (Stream stdOut, Stream stdErr) targets) => 79 | source | (PipeTarget.ToStream(targets.stdOut), PipeTarget.ToStream(targets.stdErr)); 80 | 81 | /// 82 | /// Creates a new command that pipes its standard output and standard error to the 83 | /// specified string builders. 84 | /// Uses for decoding. 85 | /// 86 | [Pure] 87 | public static Command operator |( 88 | Command source, 89 | (StringBuilder stdOut, StringBuilder stdErr) targets 90 | ) => 91 | source 92 | | (PipeTarget.ToStringBuilder(targets.stdOut), PipeTarget.ToStringBuilder(targets.stdErr)); 93 | 94 | /// 95 | /// Creates a new command that pipes its standard output and standard error line-by-line 96 | /// to the specified asynchronous delegates. 97 | /// Uses for decoding. 98 | /// 99 | [Pure] 100 | public static Command operator |( 101 | Command source, 102 | ( 103 | Func stdOut, 104 | Func stdErr 105 | ) targets 106 | ) => source | (PipeTarget.ToDelegate(targets.stdOut), PipeTarget.ToDelegate(targets.stdErr)); 107 | 108 | /// 109 | /// Creates a new command that pipes its standard output and standard error line-by-line 110 | /// to the specified asynchronous delegates. 111 | /// Uses for decoding. 112 | /// 113 | [Pure] 114 | public static Command operator |( 115 | Command source, 116 | (Func stdOut, Func stdErr) targets 117 | ) => source | (PipeTarget.ToDelegate(targets.stdOut), PipeTarget.ToDelegate(targets.stdErr)); 118 | 119 | /// 120 | /// Creates a new command that pipes its standard output and standard error line-by-line 121 | /// to the specified synchronous delegates. 122 | /// Uses for decoding. 123 | /// 124 | [Pure] 125 | public static Command operator |( 126 | Command source, 127 | (Action stdOut, Action stdErr) targets 128 | ) => source | (PipeTarget.ToDelegate(targets.stdOut), PipeTarget.ToDelegate(targets.stdErr)); 129 | 130 | /// 131 | /// Creates a new command that pipes its standard input from the specified source. 132 | /// 133 | [Pure] 134 | public static Command operator |(PipeSource source, Command target) => 135 | target.WithStandardInputPipe(source); 136 | 137 | /// 138 | /// Creates a new command that pipes its standard input from the specified stream. 139 | /// 140 | [Pure] 141 | public static Command operator |(Stream source, Command target) => 142 | PipeSource.FromStream(source) | target; 143 | 144 | /// 145 | /// Creates a new command that pipes its standard input from the specified memory buffer. 146 | /// 147 | [Pure] 148 | public static Command operator |(ReadOnlyMemory source, Command target) => 149 | PipeSource.FromBytes(source) | target; 150 | 151 | /// 152 | /// Creates a new command that pipes its standard input from the specified byte array. 153 | /// 154 | [Pure] 155 | public static Command operator |(byte[] source, Command target) => 156 | PipeSource.FromBytes(source) | target; 157 | 158 | /// 159 | /// Creates a new command that pipes its standard input from the specified string. 160 | /// Uses for encoding. 161 | /// 162 | [Pure] 163 | public static Command operator |(string source, Command target) => 164 | PipeSource.FromString(source) | target; 165 | 166 | /// 167 | /// Creates a new command that pipes its standard input from the standard output of the 168 | /// specified command. 169 | /// 170 | [Pure] 171 | public static Command operator |(Command source, Command target) => 172 | PipeSource.FromCommand(source) | target; 173 | } 174 | -------------------------------------------------------------------------------- /CliWrap/Command.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Diagnostics.Contracts; 5 | using System.IO; 6 | using CliWrap.Builders; 7 | 8 | namespace CliWrap; 9 | 10 | /// 11 | /// Instructions for running a process. 12 | /// 13 | public partial class Command( 14 | string targetFilePath, 15 | string arguments, 16 | string workingDirPath, 17 | ResourcePolicy resourcePolicy, 18 | Credentials credentials, 19 | IReadOnlyDictionary environmentVariables, 20 | CommandResultValidation validation, 21 | PipeSource standardInputPipe, 22 | PipeTarget standardOutputPipe, 23 | PipeTarget standardErrorPipe 24 | ) : ICommandConfiguration 25 | { 26 | /// 27 | /// Initializes an instance of . 28 | /// 29 | public Command(string targetFilePath) 30 | : this( 31 | targetFilePath, 32 | string.Empty, 33 | Directory.GetCurrentDirectory(), 34 | ResourcePolicy.Default, 35 | Credentials.Default, 36 | new Dictionary(), 37 | CommandResultValidation.ZeroExitCode, 38 | PipeSource.Null, 39 | PipeTarget.Null, 40 | PipeTarget.Null 41 | ) { } 42 | 43 | /// 44 | public string TargetFilePath { get; } = targetFilePath; 45 | 46 | /// 47 | public string Arguments { get; } = arguments; 48 | 49 | /// 50 | public string WorkingDirPath { get; } = workingDirPath; 51 | 52 | /// 53 | public ResourcePolicy ResourcePolicy { get; } = resourcePolicy; 54 | 55 | /// 56 | public Credentials Credentials { get; } = credentials; 57 | 58 | /// 59 | public IReadOnlyDictionary EnvironmentVariables { get; } = 60 | environmentVariables; 61 | 62 | /// 63 | public CommandResultValidation Validation { get; } = validation; 64 | 65 | /// 66 | public PipeSource StandardInputPipe { get; } = standardInputPipe; 67 | 68 | /// 69 | public PipeTarget StandardOutputPipe { get; } = standardOutputPipe; 70 | 71 | /// 72 | public PipeTarget StandardErrorPipe { get; } = standardErrorPipe; 73 | 74 | /// 75 | /// Creates a copy of this command, setting the target file path to the specified value. 76 | /// 77 | [Pure] 78 | public Command WithTargetFile(string targetFilePath) => 79 | new( 80 | targetFilePath, 81 | Arguments, 82 | WorkingDirPath, 83 | ResourcePolicy, 84 | Credentials, 85 | EnvironmentVariables, 86 | Validation, 87 | StandardInputPipe, 88 | StandardOutputPipe, 89 | StandardErrorPipe 90 | ); 91 | 92 | /// 93 | /// Creates a copy of this command, setting the arguments to the specified value. 94 | /// 95 | /// 96 | /// Avoid using this overload, as it requires the arguments to be escaped manually. 97 | /// Formatting errors may lead to unexpected bugs and security vulnerabilities. 98 | /// 99 | [Pure] 100 | public Command WithArguments(string arguments) => 101 | new( 102 | TargetFilePath, 103 | arguments, 104 | WorkingDirPath, 105 | ResourcePolicy, 106 | Credentials, 107 | EnvironmentVariables, 108 | Validation, 109 | StandardInputPipe, 110 | StandardOutputPipe, 111 | StandardErrorPipe 112 | ); 113 | 114 | /// 115 | /// Creates a copy of this command, setting the arguments to the value 116 | /// obtained by formatting the specified enumeration. 117 | /// 118 | [Pure] 119 | public Command WithArguments(IEnumerable arguments, bool escape) => 120 | WithArguments(args => args.Add(arguments, escape)); 121 | 122 | /// 123 | /// Creates a copy of this command, setting the arguments to the value 124 | /// obtained by formatting the specified enumeration. 125 | /// 126 | // TODO: (breaking change) remove in favor of optional parameter 127 | [Pure] 128 | public Command WithArguments(IEnumerable arguments) => WithArguments(arguments, true); 129 | 130 | /// 131 | /// Creates a copy of this command, setting the arguments to the value 132 | /// configured by the specified delegate. 133 | /// 134 | [Pure] 135 | public Command WithArguments(Action configure) 136 | { 137 | var builder = new ArgumentsBuilder(); 138 | configure(builder); 139 | 140 | return WithArguments(builder.Build()); 141 | } 142 | 143 | /// 144 | /// Creates a copy of this command, setting the working directory path to the specified value. 145 | /// 146 | [Pure] 147 | public Command WithWorkingDirectory(string workingDirPath) => 148 | new( 149 | TargetFilePath, 150 | Arguments, 151 | workingDirPath, 152 | ResourcePolicy, 153 | Credentials, 154 | EnvironmentVariables, 155 | Validation, 156 | StandardInputPipe, 157 | StandardOutputPipe, 158 | StandardErrorPipe 159 | ); 160 | 161 | /// 162 | /// Creates a copy of this command, setting the resource policy to the specified value. 163 | /// 164 | [Pure] 165 | public Command WithResourcePolicy(ResourcePolicy resourcePolicy) => 166 | new( 167 | TargetFilePath, 168 | Arguments, 169 | WorkingDirPath, 170 | resourcePolicy, 171 | Credentials, 172 | EnvironmentVariables, 173 | Validation, 174 | StandardInputPipe, 175 | StandardOutputPipe, 176 | StandardErrorPipe 177 | ); 178 | 179 | /// 180 | /// Creates a copy of this command, setting the resource policy to the value 181 | /// configured by the specified delegate. 182 | /// 183 | [Pure] 184 | public Command WithResourcePolicy(Action configure) 185 | { 186 | var builder = new ResourcePolicyBuilder(); 187 | configure(builder); 188 | 189 | return WithResourcePolicy(builder.Build()); 190 | } 191 | 192 | /// 193 | /// Creates a copy of this command, setting the user credentials to the specified value. 194 | /// 195 | [Pure] 196 | public Command WithCredentials(Credentials credentials) => 197 | new( 198 | TargetFilePath, 199 | Arguments, 200 | WorkingDirPath, 201 | ResourcePolicy, 202 | credentials, 203 | EnvironmentVariables, 204 | Validation, 205 | StandardInputPipe, 206 | StandardOutputPipe, 207 | StandardErrorPipe 208 | ); 209 | 210 | /// 211 | /// Creates a copy of this command, setting the user credentials to the value 212 | /// configured by the specified delegate. 213 | /// 214 | [Pure] 215 | public Command WithCredentials(Action configure) 216 | { 217 | var builder = new CredentialsBuilder(); 218 | configure(builder); 219 | 220 | return WithCredentials(builder.Build()); 221 | } 222 | 223 | /// 224 | /// Creates a copy of this command, setting the environment variables to the specified value. 225 | /// 226 | [Pure] 227 | public Command WithEnvironmentVariables( 228 | IReadOnlyDictionary environmentVariables 229 | ) => 230 | new( 231 | TargetFilePath, 232 | Arguments, 233 | WorkingDirPath, 234 | ResourcePolicy, 235 | Credentials, 236 | environmentVariables, 237 | Validation, 238 | StandardInputPipe, 239 | StandardOutputPipe, 240 | StandardErrorPipe 241 | ); 242 | 243 | /// 244 | /// Creates a copy of this command, setting the environment variables to the value 245 | /// configured by the specified delegate. 246 | /// 247 | [Pure] 248 | public Command WithEnvironmentVariables(Action configure) 249 | { 250 | var builder = new EnvironmentVariablesBuilder(); 251 | configure(builder); 252 | 253 | return WithEnvironmentVariables(builder.Build()); 254 | } 255 | 256 | /// 257 | /// Creates a copy of this command, setting the validation options to the specified value. 258 | /// 259 | [Pure] 260 | public Command WithValidation(CommandResultValidation validation) => 261 | new( 262 | TargetFilePath, 263 | Arguments, 264 | WorkingDirPath, 265 | ResourcePolicy, 266 | Credentials, 267 | EnvironmentVariables, 268 | validation, 269 | StandardInputPipe, 270 | StandardOutputPipe, 271 | StandardErrorPipe 272 | ); 273 | 274 | /// 275 | /// Creates a copy of this command, setting the standard input pipe to the specified source. 276 | /// 277 | [Pure] 278 | public Command WithStandardInputPipe(PipeSource source) => 279 | new( 280 | TargetFilePath, 281 | Arguments, 282 | WorkingDirPath, 283 | ResourcePolicy, 284 | Credentials, 285 | EnvironmentVariables, 286 | Validation, 287 | source, 288 | StandardOutputPipe, 289 | StandardErrorPipe 290 | ); 291 | 292 | /// 293 | /// Creates a copy of this command, setting the standard output pipe to the specified target. 294 | /// 295 | [Pure] 296 | public Command WithStandardOutputPipe(PipeTarget target) => 297 | new( 298 | TargetFilePath, 299 | Arguments, 300 | WorkingDirPath, 301 | ResourcePolicy, 302 | Credentials, 303 | EnvironmentVariables, 304 | Validation, 305 | StandardInputPipe, 306 | target, 307 | StandardErrorPipe 308 | ); 309 | 310 | /// 311 | /// Creates a copy of this command, setting the standard error pipe to the specified target. 312 | /// 313 | [Pure] 314 | public Command WithStandardErrorPipe(PipeTarget target) => 315 | new( 316 | TargetFilePath, 317 | Arguments, 318 | WorkingDirPath, 319 | ResourcePolicy, 320 | Credentials, 321 | EnvironmentVariables, 322 | Validation, 323 | StandardInputPipe, 324 | StandardOutputPipe, 325 | target 326 | ); 327 | 328 | /// 329 | [ExcludeFromCodeCoverage] 330 | public override string ToString() => $"{TargetFilePath} {Arguments}"; 331 | } 332 | -------------------------------------------------------------------------------- /CliWrap/CommandResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliWrap; 4 | 5 | /// 6 | /// Result of a command execution. 7 | /// 8 | public class CommandResult(int exitCode, DateTimeOffset startTime, DateTimeOffset exitTime) 9 | { 10 | /// 11 | /// Exit code set by the underlying process. 12 | /// 13 | public int ExitCode { get; } = exitCode; 14 | 15 | /// 16 | /// Whether the command execution was successful (i.e. exit code is zero). 17 | /// 18 | public bool IsSuccess => ExitCode == 0; 19 | 20 | /// 21 | /// Time at which the command started executing. 22 | /// 23 | public DateTimeOffset StartTime { get; } = startTime; 24 | 25 | /// 26 | /// Time at which the command finished executing. 27 | /// 28 | public DateTimeOffset ExitTime { get; } = exitTime; 29 | 30 | /// 31 | /// Total duration of the command execution. 32 | /// 33 | public TimeSpan RunTime => ExitTime - StartTime; 34 | } 35 | -------------------------------------------------------------------------------- /CliWrap/CommandResultValidation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliWrap; 4 | 5 | /// 6 | /// Strategy used for validating the result of a command execution. 7 | /// 8 | [Flags] 9 | public enum CommandResultValidation 10 | { 11 | /// 12 | /// No validation. 13 | /// 14 | None = 0b0, 15 | 16 | /// 17 | /// Ensure that the command returned a zero exit code. 18 | /// 19 | ZeroExitCode = 0b1, 20 | } 21 | 22 | internal static class CommandResultValidationExtensions 23 | { 24 | public static bool IsZeroExitCodeValidationEnabled(this CommandResultValidation validation) => 25 | (validation & CommandResultValidation.ZeroExitCode) != 0; 26 | } 27 | -------------------------------------------------------------------------------- /CliWrap/CommandTask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.CompilerServices; 3 | using System.Threading.Tasks; 4 | using CliWrap.Utils.Extensions; 5 | 6 | namespace CliWrap; 7 | 8 | /// 9 | /// Represents an asynchronous execution of a command. 10 | /// 11 | public partial class CommandTask(Task task, int processId) : IDisposable 12 | { 13 | /// 14 | /// Underlying task. 15 | /// 16 | public Task Task { get; } = task; 17 | 18 | /// 19 | /// Underlying process ID. 20 | /// 21 | public int ProcessId { get; } = processId; 22 | 23 | internal CommandTask Bind(Func, Task> transform) => 24 | new(transform(Task), ProcessId); 25 | 26 | /// 27 | /// Lazily maps the result of the task using the specified transform. 28 | /// 29 | // TODO: (breaking change) this should not be publicly exposed 30 | public CommandTask Select(Func transform) => 31 | Bind(task => task.Select(transform)); 32 | 33 | /// 34 | /// Gets the awaiter of the underlying task. 35 | /// Used to enable await expressions on this object. 36 | /// 37 | public TaskAwaiter GetAwaiter() => Task.GetAwaiter(); 38 | 39 | /// 40 | /// Configures an awaiter used to await this task. 41 | /// 42 | public ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext) => 43 | Task.ConfigureAwait(continueOnCapturedContext); 44 | 45 | /// 46 | public void Dispose() => Task.Dispose(); 47 | } 48 | 49 | public partial class CommandTask 50 | { 51 | /// 52 | /// Converts the command task into a regular task. 53 | /// 54 | public static implicit operator Task(CommandTask commandTask) => 55 | commandTask.Task; 56 | } 57 | -------------------------------------------------------------------------------- /CliWrap/Credentials.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace CliWrap; 5 | 6 | /// 7 | /// User credentials used for starting a process. 8 | /// 9 | /// 10 | /// For information on platform support, see attributes on , 11 | /// , and 12 | /// . 13 | /// 14 | public partial class Credentials( 15 | string? domain = null, 16 | string? userName = null, 17 | string? password = null, 18 | bool loadUserProfile = false 19 | ) 20 | { 21 | /// 22 | /// Initializes an instance of . 23 | /// 24 | // TODO: (breaking change) remove in favor of the other overload 25 | [ExcludeFromCodeCoverage] 26 | public Credentials(string? domain, string? username, string? password) 27 | : this(domain, username, password, false) { } 28 | 29 | /// 30 | /// Active Directory domain used for starting the process. 31 | /// 32 | /// 33 | /// Only supported on Windows. 34 | /// 35 | public string? Domain { get; } = domain; 36 | 37 | /// 38 | /// Username used for starting the process. 39 | /// 40 | public string? UserName { get; } = userName; 41 | 42 | /// 43 | /// Password used for starting the process. 44 | /// 45 | /// 46 | /// Only supported on Windows. 47 | /// 48 | public string? Password { get; } = password; 49 | 50 | /// 51 | /// Whether to load the user profile when starting the process. 52 | /// 53 | /// 54 | /// Only supported on Windows. 55 | /// 56 | public bool LoadUserProfile { get; } = loadUserProfile; 57 | } 58 | 59 | public partial class Credentials 60 | { 61 | /// 62 | /// Empty credentials. 63 | /// 64 | public static Credentials Default { get; } = new(); 65 | } 66 | -------------------------------------------------------------------------------- /CliWrap/EventStream/CommandEvent.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace CliWrap.EventStream; 4 | 5 | /// 6 | /// 7 | /// Abstract event produced by a command. 8 | /// Use pattern matching to handle specific instances of this type. 9 | /// 10 | /// 11 | /// Can be either one of the following: 12 | /// 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// 18 | /// 19 | /// 20 | public abstract class CommandEvent; 21 | 22 | /// 23 | /// Event triggered when the command starts executing. 24 | /// May only appear once in the event stream. 25 | /// 26 | public class StartedCommandEvent(int processId) : CommandEvent 27 | { 28 | /// 29 | /// Underlying process ID. 30 | /// 31 | public int ProcessId { get; } = processId; 32 | 33 | /// 34 | [ExcludeFromCodeCoverage] 35 | public override string ToString() => $"Process ID: {ProcessId}"; 36 | } 37 | 38 | /// 39 | /// Event triggered when the underlying process writes a line of text to the standard output stream. 40 | /// 41 | public class StandardOutputCommandEvent(string text) : CommandEvent 42 | { 43 | /// 44 | /// Line of text written to the standard output stream. 45 | /// 46 | public string Text { get; } = text; 47 | 48 | /// 49 | [ExcludeFromCodeCoverage] 50 | public override string ToString() => Text; 51 | } 52 | 53 | /// 54 | /// Event triggered when the underlying process writes a line of text to the standard error stream. 55 | /// 56 | public class StandardErrorCommandEvent(string text) : CommandEvent 57 | { 58 | /// 59 | /// Line of text written to the standard error stream. 60 | /// 61 | public string Text { get; } = text; 62 | 63 | /// 64 | [ExcludeFromCodeCoverage] 65 | public override string ToString() => Text; 66 | } 67 | 68 | /// 69 | /// Event triggered when the command finishes executing. 70 | /// May only appear once in the event stream. 71 | /// 72 | public class ExitedCommandEvent(int exitCode) : CommandEvent 73 | { 74 | /// 75 | /// Exit code set by the underlying process. 76 | /// 77 | public int ExitCode { get; } = exitCode; 78 | 79 | /// 80 | [ExcludeFromCodeCoverage] 81 | public override string ToString() => $"Exit code: {ExitCode}"; 82 | } 83 | -------------------------------------------------------------------------------- /CliWrap/EventStream/PullEventStreamCommandExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.CompilerServices; 4 | using System.Text; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using CliWrap.Utils; 8 | 9 | namespace CliWrap.EventStream; 10 | 11 | /// 12 | /// Event stream execution model. 13 | /// 14 | // TODO: (breaking change) split the partial class into two separate classes, one for each execution model 15 | public static partial class EventStreamCommandExtensions 16 | { 17 | /// 18 | /// Executes the command as a pull-based event stream. 19 | /// 20 | /// 21 | /// Use pattern matching to handle specific instances of . 22 | /// 23 | // TODO: (breaking change) use optional parameters and remove the other overload 24 | public static async IAsyncEnumerable ListenAsync( 25 | this Command command, 26 | Encoding standardOutputEncoding, 27 | Encoding standardErrorEncoding, 28 | [EnumeratorCancellation] CancellationToken forcefulCancellationToken, 29 | CancellationToken gracefulCancellationToken 30 | ) 31 | { 32 | using var channel = new Channel(); 33 | 34 | var stdOutPipe = PipeTarget.Merge( 35 | command.StandardOutputPipe, 36 | PipeTarget.ToDelegate( 37 | async (line, innerCancellationToken) => 38 | { 39 | // ReSharper disable once AccessToDisposedClosure 40 | await channel 41 | .PublishAsync(new StandardOutputCommandEvent(line), innerCancellationToken) 42 | .ConfigureAwait(false); 43 | }, 44 | standardOutputEncoding 45 | ) 46 | ); 47 | 48 | var stdErrPipe = PipeTarget.Merge( 49 | command.StandardErrorPipe, 50 | PipeTarget.ToDelegate( 51 | async (line, innerCancellationToken) => 52 | { 53 | // ReSharper disable once AccessToDisposedClosure 54 | await channel 55 | .PublishAsync(new StandardErrorCommandEvent(line), innerCancellationToken) 56 | .ConfigureAwait(false); 57 | }, 58 | standardErrorEncoding 59 | ) 60 | ); 61 | 62 | var commandWithPipes = command 63 | .WithStandardOutputPipe(stdOutPipe) 64 | .WithStandardErrorPipe(stdErrPipe); 65 | 66 | var commandTask = commandWithPipes.ExecuteAsync( 67 | forcefulCancellationToken, 68 | gracefulCancellationToken 69 | ); 70 | 71 | yield return new StartedCommandEvent(commandTask.ProcessId); 72 | 73 | // Close the channel once the command completes, so that ReceiveAsync() can finish 74 | _ = commandTask.Task.ContinueWith( 75 | async _ => 76 | // ReSharper disable once AccessToDisposedClosure 77 | await channel 78 | .ReportCompletionAsync(forcefulCancellationToken) 79 | .ConfigureAwait(false), 80 | // Run the continuation even if the parent task failed 81 | TaskContinuationOptions.None 82 | ); 83 | 84 | await foreach ( 85 | var cmdEvent in channel.ReceiveAsync(forcefulCancellationToken).ConfigureAwait(false) 86 | ) 87 | { 88 | yield return cmdEvent; 89 | } 90 | 91 | var exitCode = await commandTask.Select(r => r.ExitCode).ConfigureAwait(false); 92 | yield return new ExitedCommandEvent(exitCode); 93 | } 94 | 95 | /// 96 | /// Executes the command as a pull-based event stream. 97 | /// 98 | /// 99 | /// Use pattern matching to handle specific instances of . 100 | /// 101 | public static IAsyncEnumerable ListenAsync( 102 | this Command command, 103 | Encoding standardOutputEncoding, 104 | Encoding standardErrorEncoding, 105 | CancellationToken cancellationToken = default 106 | ) 107 | { 108 | return command.ListenAsync( 109 | standardOutputEncoding, 110 | standardErrorEncoding, 111 | cancellationToken, 112 | CancellationToken.None 113 | ); 114 | } 115 | 116 | /// 117 | /// Executes the command as a pull-based event stream. 118 | /// 119 | /// 120 | /// Use pattern matching to handle specific instances of . 121 | /// 122 | public static IAsyncEnumerable ListenAsync( 123 | this Command command, 124 | Encoding encoding, 125 | CancellationToken cancellationToken = default 126 | ) 127 | { 128 | return command.ListenAsync(encoding, encoding, cancellationToken); 129 | } 130 | 131 | /// 132 | /// Executes the command as a pull-based event stream. 133 | /// Uses for decoding. 134 | /// 135 | /// 136 | /// Use pattern matching to handle specific instances of . 137 | /// 138 | public static IAsyncEnumerable ListenAsync( 139 | this Command command, 140 | CancellationToken cancellationToken = default 141 | ) 142 | { 143 | return command.ListenAsync(Encoding.Default, cancellationToken); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /CliWrap/EventStream/PushEventStreamCommandExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using CliWrap.Utils; 6 | using CliWrap.Utils.Extensions; 7 | 8 | namespace CliWrap.EventStream; 9 | 10 | /// 11 | /// Event stream execution model. 12 | /// 13 | // TODO: (breaking change) split the partial class into two separate classes, one for each execution model 14 | public static partial class EventStreamCommandExtensions 15 | { 16 | /// 17 | /// Executes the command as a push-based event stream. 18 | /// 19 | /// 20 | /// Use pattern matching to handle specific instances of . 21 | /// 22 | // TODO: (breaking change) use optional parameters and remove the other overload 23 | public static IObservable Observe( 24 | this Command command, 25 | Encoding standardOutputEncoding, 26 | Encoding standardErrorEncoding, 27 | CancellationToken forcefulCancellationToken, 28 | CancellationToken gracefulCancellationToken 29 | ) 30 | { 31 | return Observable.CreateSynchronized(observer => 32 | { 33 | var stdOutPipe = PipeTarget.Merge( 34 | command.StandardOutputPipe, 35 | PipeTarget.ToDelegate( 36 | line => observer.OnNext(new StandardOutputCommandEvent(line)), 37 | standardOutputEncoding 38 | ) 39 | ); 40 | 41 | var stdErrPipe = PipeTarget.Merge( 42 | command.StandardErrorPipe, 43 | PipeTarget.ToDelegate( 44 | line => observer.OnNext(new StandardErrorCommandEvent(line)), 45 | standardErrorEncoding 46 | ) 47 | ); 48 | 49 | var commandWithPipes = command 50 | .WithStandardOutputPipe(stdOutPipe) 51 | .WithStandardErrorPipe(stdErrPipe); 52 | 53 | var commandTask = commandWithPipes.ExecuteAsync( 54 | forcefulCancellationToken, 55 | gracefulCancellationToken 56 | ); 57 | 58 | observer.OnNext(new StartedCommandEvent(commandTask.ProcessId)); 59 | 60 | // Don't pass cancellation token to the continuation because we need it to 61 | // trigger regardless of how the task completed. 62 | _ = commandTask.Task.ContinueWith( 63 | t => 64 | { 65 | // Canceled tasks don't have exceptions 66 | if (t.IsCanceled) 67 | { 68 | observer.OnError(new TaskCanceledException(t)); 69 | } 70 | else if (t.Exception is not null) 71 | { 72 | observer.OnError(t.Exception.TryGetSingle() ?? t.Exception); 73 | } 74 | else 75 | { 76 | observer.OnNext(new ExitedCommandEvent(t.Result.ExitCode)); 77 | observer.OnCompleted(); 78 | } 79 | }, 80 | TaskContinuationOptions.None 81 | ); 82 | 83 | return Disposable.Null; 84 | }); 85 | } 86 | 87 | /// 88 | /// Executes the command as a push-based event stream. 89 | /// 90 | /// 91 | /// Use pattern matching to handle specific instances of . 92 | /// 93 | public static IObservable Observe( 94 | this Command command, 95 | Encoding standardOutputEncoding, 96 | Encoding standardErrorEncoding, 97 | CancellationToken cancellationToken = default 98 | ) 99 | { 100 | return command.Observe( 101 | standardOutputEncoding, 102 | standardErrorEncoding, 103 | cancellationToken, 104 | CancellationToken.None 105 | ); 106 | } 107 | 108 | /// 109 | /// Executes the command as a push-based event stream. 110 | /// 111 | /// 112 | /// Use pattern matching to handle specific instances of . 113 | /// 114 | public static IObservable Observe( 115 | this Command command, 116 | Encoding encoding, 117 | CancellationToken cancellationToken = default 118 | ) 119 | { 120 | return command.Observe(encoding, encoding, cancellationToken); 121 | } 122 | 123 | /// 124 | /// Executes the command as a push-based event stream. 125 | /// Uses for decoding. 126 | /// 127 | /// 128 | /// Use pattern matching to handle specific instances of . 129 | /// 130 | public static IObservable Observe( 131 | this Command command, 132 | CancellationToken cancellationToken = default 133 | ) 134 | { 135 | return command.Observe(Encoding.Default, cancellationToken); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /CliWrap/Exceptions/CliWrapException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace CliWrap.Exceptions; 5 | 6 | /// 7 | /// Parent class for exceptions thrown by . 8 | /// 9 | public abstract class CliWrapException(string message, Exception? innerException = null) 10 | : Exception(message, innerException) 11 | { 12 | /// 13 | /// Initializes an instance of . 14 | /// 15 | // TODO: (breaking change) remove in favor of an optional parameter in the constructor above 16 | [ExcludeFromCodeCoverage] 17 | protected CliWrapException(string message) 18 | : this(message, null) { } 19 | } 20 | -------------------------------------------------------------------------------- /CliWrap/Exceptions/CommandExecutionException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace CliWrap.Exceptions; 5 | 6 | /// 7 | /// Exception thrown when the command fails to execute correctly. 8 | /// 9 | public class CommandExecutionException( 10 | ICommandConfiguration command, 11 | int exitCode, 12 | string message, 13 | Exception? innerException = null 14 | ) : CliWrapException(message, innerException) 15 | { 16 | /// 17 | /// Initializes an instance of . 18 | /// 19 | // TODO: (breaking change) remove in favor of an optional parameter in the constructor above 20 | [ExcludeFromCodeCoverage] 21 | public CommandExecutionException(ICommandConfiguration command, int exitCode, string message) 22 | : this(command, exitCode, message, null) { } 23 | 24 | /// 25 | /// Command that triggered the exception. 26 | /// 27 | public ICommandConfiguration Command { get; } = command; 28 | 29 | /// 30 | /// Exit code returned by the process. 31 | /// 32 | public int ExitCode { get; } = exitCode; 33 | } 34 | -------------------------------------------------------------------------------- /CliWrap/ICommandConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace CliWrap; 4 | 5 | /// 6 | /// Instructions for running a process. 7 | /// 8 | public interface ICommandConfiguration 9 | { 10 | /// 11 | /// File path of the executable, batch file, or script, that this command runs. 12 | /// 13 | string TargetFilePath { get; } 14 | 15 | /// 16 | /// Command-line arguments passed to the underlying process. 17 | /// 18 | string Arguments { get; } 19 | 20 | /// 21 | /// Working directory path set for the underlying process. 22 | /// 23 | string WorkingDirPath { get; } 24 | 25 | /// 26 | /// Resource policy set for the underlying process. 27 | /// 28 | ResourcePolicy ResourcePolicy { get; } 29 | 30 | /// 31 | /// User credentials set for the underlying process. 32 | /// 33 | Credentials Credentials { get; } 34 | 35 | /// 36 | /// Environment variables set for the underlying process. 37 | /// 38 | IReadOnlyDictionary EnvironmentVariables { get; } 39 | 40 | /// 41 | /// Strategy for validating the result of the execution. 42 | /// 43 | CommandResultValidation Validation { get; } 44 | 45 | /// 46 | /// Pipe source for the standard input stream of the underlying process. 47 | /// 48 | PipeSource StandardInputPipe { get; } 49 | 50 | /// 51 | /// Pipe target for the standard output stream of the underlying process. 52 | /// 53 | PipeTarget StandardOutputPipe { get; } 54 | 55 | /// 56 | /// Pipe target for the standard error stream of the underlying process. 57 | /// 58 | PipeTarget StandardErrorPipe { get; } 59 | } 60 | -------------------------------------------------------------------------------- /CliWrap/PipeSource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.IO; 4 | using System.Text; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using CliWrap.Utils.Extensions; 8 | 9 | namespace CliWrap; 10 | 11 | /// 12 | /// Represents a pipe for the process's standard input stream. 13 | /// 14 | public abstract partial class PipeSource 15 | { 16 | /// 17 | /// Reads the binary content pushed into the pipe and writes it to the destination stream. 18 | /// Destination stream represents the process's standard input stream. 19 | /// 20 | public abstract Task CopyToAsync( 21 | Stream destination, 22 | CancellationToken cancellationToken = default 23 | ); 24 | } 25 | 26 | public partial class PipeSource 27 | { 28 | private class AnonymousPipeSource(Func copyToAsync) 29 | : PipeSource 30 | { 31 | public override async Task CopyToAsync( 32 | Stream destination, 33 | CancellationToken cancellationToken = default 34 | ) => await copyToAsync(destination, cancellationToken).ConfigureAwait(false); 35 | } 36 | } 37 | 38 | public partial class PipeSource 39 | { 40 | /// 41 | /// Pipe source that does not provide any data. 42 | /// Functionally equivalent to a null device. 43 | /// 44 | public static PipeSource Null { get; } = 45 | Create( 46 | (_, cancellationToken) => 47 | !cancellationToken.IsCancellationRequested 48 | ? Task.CompletedTask 49 | : Task.FromCanceled(cancellationToken) 50 | ); 51 | 52 | /// 53 | /// Creates an anonymous pipe source with the method 54 | /// implemented by the specified asynchronous delegate. 55 | /// 56 | public static PipeSource Create(Func handlePipeAsync) => 57 | new AnonymousPipeSource(handlePipeAsync); 58 | 59 | /// 60 | /// Creates an anonymous pipe source with the method 61 | /// implemented by the specified synchronous delegate. 62 | /// 63 | public static PipeSource Create(Action handlePipe) => 64 | Create( 65 | (destination, _) => 66 | { 67 | handlePipe(destination); 68 | return Task.CompletedTask; 69 | } 70 | ); 71 | 72 | /// 73 | /// Creates a pipe source that reads from the specified stream. 74 | /// 75 | public static PipeSource FromStream(Stream stream, bool autoFlush) => 76 | Create( 77 | async (destination, cancellationToken) => 78 | await stream 79 | .CopyToAsync(destination, autoFlush, cancellationToken) 80 | .ConfigureAwait(false) 81 | ); 82 | 83 | /// 84 | /// Creates a pipe source that reads from the specified stream. 85 | /// 86 | // TODO: (breaking change) remove in favor of optional parameter 87 | public static PipeSource FromStream(Stream stream) => FromStream(stream, true); 88 | 89 | /// 90 | /// Creates a pipe source that reads from the specified file. 91 | /// 92 | public static PipeSource FromFile(string filePath) => 93 | Create( 94 | async (destination, cancellationToken) => 95 | { 96 | var source = File.OpenRead(filePath); 97 | await using (source.ToAsyncDisposable()) 98 | await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); 99 | } 100 | ); 101 | 102 | /// 103 | /// Creates a pipe source that reads from the specified memory buffer. 104 | /// 105 | public static PipeSource FromBytes(ReadOnlyMemory data) => 106 | Create( 107 | async (destination, cancellationToken) => 108 | await destination.WriteAsync(data, cancellationToken).ConfigureAwait(false) 109 | ); 110 | 111 | /// 112 | /// Creates a pipe source that reads from the specified byte array. 113 | /// 114 | public static PipeSource FromBytes(byte[] data) => FromBytes((ReadOnlyMemory)data); 115 | 116 | /// 117 | [Obsolete("Use FromBytes(ReadOnlyMemory) instead"), ExcludeFromCodeCoverage] 118 | public static PipeSource FromMemory(ReadOnlyMemory data) => FromBytes(data); 119 | 120 | /// 121 | /// Creates a pipe source that reads from the specified string. 122 | /// 123 | public static PipeSource FromString(string str, Encoding encoding) => 124 | FromBytes(encoding.GetBytes(str)); 125 | 126 | /// 127 | /// Creates a pipe source that reads from the specified string. 128 | /// Uses for encoding. 129 | /// 130 | public static PipeSource FromString(string str) => FromString(str, Console.InputEncoding); 131 | 132 | /// 133 | /// Creates a pipe source that reads from the standard output of the specified command. 134 | /// 135 | public static PipeSource FromCommand( 136 | Command command, 137 | Func copyStreamAsync 138 | ) => 139 | // cmdA | | cmdB 140 | Create( 141 | // Destination -> cmdB's standard input 142 | async (destination, destinationCancellationToken) => 143 | await command 144 | .WithStandardOutputPipe( 145 | PipeTarget.Create( 146 | // Source -> cmdA's standard output 147 | async (source, sourceCancellationToken) => 148 | await copyStreamAsync(source, destination, sourceCancellationToken) 149 | .ConfigureAwait(false) 150 | ) 151 | ) 152 | .ExecuteAsync(destinationCancellationToken) 153 | .ConfigureAwait(false) 154 | ); 155 | 156 | /// 157 | /// Creates a pipe source that reads from the standard output of the specified command. 158 | /// 159 | public static PipeSource FromCommand(Command command) => 160 | FromCommand( 161 | command, 162 | async (source, destination, cancellationToken) => 163 | await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false) 164 | ); 165 | } 166 | -------------------------------------------------------------------------------- /CliWrap/PipeTarget.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using CliWrap.Utils; 10 | using CliWrap.Utils.Extensions; 11 | 12 | namespace CliWrap; 13 | 14 | /// 15 | /// Represents a pipe for the process's standard output or standard error stream. 16 | /// 17 | public abstract partial class PipeTarget 18 | { 19 | /// 20 | /// Reads the binary content from the origin stream and pushes it into the pipe. 21 | /// Origin stream represents the process's standard output or standard error stream. 22 | /// 23 | public abstract Task CopyFromAsync( 24 | Stream origin, 25 | CancellationToken cancellationToken = default 26 | ); 27 | } 28 | 29 | public partial class PipeTarget 30 | { 31 | private class AnonymousPipeTarget(Func copyFromAsync) 32 | : PipeTarget 33 | { 34 | public override async Task CopyFromAsync( 35 | Stream origin, 36 | CancellationToken cancellationToken = default 37 | ) => await copyFromAsync(origin, cancellationToken).ConfigureAwait(false); 38 | } 39 | 40 | private class AggregatePipeTarget(IReadOnlyList targets) : PipeTarget 41 | { 42 | public IReadOnlyList Targets { get; } = targets; 43 | 44 | public override async Task CopyFromAsync( 45 | Stream origin, 46 | CancellationToken cancellationToken = default 47 | ) 48 | { 49 | // Cancellation to abort the pipe if any of the underlying targets fail 50 | using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 51 | 52 | // Create a separate sub-stream for each target 53 | var targetSubStreams = new Dictionary(); 54 | foreach (var target in Targets) 55 | targetSubStreams[target] = new SimplexStream(); 56 | 57 | try 58 | { 59 | // Start piping in the background 60 | var readingTask = Task.WhenAll( 61 | targetSubStreams.Select(async targetSubStream => 62 | { 63 | var (target, subStream) = targetSubStream; 64 | 65 | try 66 | { 67 | // ReSharper disable once AccessToDisposedClosure 68 | await target.CopyFromAsync(subStream, cts.Token).ConfigureAwait(false); 69 | } 70 | catch 71 | { 72 | // Abort the operation if any of the targets fail 73 | // ReSharper disable once AccessToDisposedClosure 74 | await cts.CancelAsync(); 75 | 76 | throw; 77 | } 78 | }) 79 | ); 80 | 81 | try 82 | { 83 | // Read from the master stream and replicate the data to each sub-stream 84 | using var buffer = MemoryPool.Shared.Rent(BufferSizes.Stream); 85 | while (true) 86 | { 87 | var bytesRead = await origin 88 | .ReadAsync(buffer.Memory, cts.Token) 89 | .ConfigureAwait(false); 90 | 91 | if (bytesRead <= 0) 92 | break; 93 | 94 | foreach (var (_, subStream) in targetSubStreams) 95 | await subStream 96 | .WriteAsync(buffer.Memory[..bytesRead], cts.Token) 97 | .ConfigureAwait(false); 98 | } 99 | 100 | // Report that transmission is complete 101 | foreach (var (_, subStream) in targetSubStreams) 102 | await subStream.ReportCompletionAsync(cts.Token).ConfigureAwait(false); 103 | } 104 | finally 105 | { 106 | // Wait for all targets to finish and propagate potential exceptions 107 | await readingTask.ConfigureAwait(false); 108 | } 109 | } 110 | finally 111 | { 112 | foreach (var (_, subStream) in targetSubStreams) 113 | await subStream.ToAsyncDisposable().DisposeAsync().ConfigureAwait(false); 114 | } 115 | } 116 | } 117 | } 118 | 119 | public partial class PipeTarget 120 | { 121 | /// 122 | /// Pipe target that discards all data. 123 | /// Functionally equivalent to a null device. 124 | /// 125 | /// 126 | /// Using this target results in the corresponding stream (standard output or standard error) 127 | /// not being opened for the underlying process at all. 128 | /// In the vast majority of cases, this behavior should be functionally equivalent to piping 129 | /// to a null stream, but without the performance overhead of consuming and discarding unneeded data. 130 | /// This may be undesirable in certain situations, in which case it's recommended to pipe to a 131 | /// null stream explicitly using with . 132 | /// 133 | public static PipeTarget Null { get; } = 134 | Create( 135 | (_, cancellationToken) => 136 | !cancellationToken.IsCancellationRequested 137 | ? Task.CompletedTask 138 | : Task.FromCanceled(cancellationToken) 139 | ); 140 | 141 | /// 142 | /// Creates an anonymous pipe target with the method 143 | /// implemented by the specified asynchronous delegate. 144 | /// 145 | public static PipeTarget Create(Func handlePipeAsync) => 146 | new AnonymousPipeTarget(handlePipeAsync); 147 | 148 | /// 149 | /// Creates an anonymous pipe target with the method 150 | /// implemented by the specified synchronous delegate. 151 | /// 152 | public static PipeTarget Create(Action handlePipe) => 153 | Create( 154 | (origin, _) => 155 | { 156 | handlePipe(origin); 157 | return Task.CompletedTask; 158 | } 159 | ); 160 | 161 | /// 162 | /// Creates a pipe target that writes to the specified stream. 163 | /// 164 | public static PipeTarget ToStream(Stream stream, bool autoFlush) => 165 | Create( 166 | async (origin, cancellationToken) => 167 | await origin.CopyToAsync(stream, autoFlush, cancellationToken).ConfigureAwait(false) 168 | ); 169 | 170 | /// 171 | /// Creates a pipe target that writes to the specified stream. 172 | /// 173 | // TODO: (breaking change) remove in favor of optional parameter 174 | public static PipeTarget ToStream(Stream stream) => ToStream(stream, true); 175 | 176 | /// 177 | /// Creates a pipe target that writes to the specified file. 178 | /// 179 | public static PipeTarget ToFile(string filePath) => 180 | Create( 181 | async (origin, cancellationToken) => 182 | { 183 | var target = File.Create(filePath); 184 | await using (target.ToAsyncDisposable()) 185 | await origin.CopyToAsync(target, cancellationToken).ConfigureAwait(false); 186 | } 187 | ); 188 | 189 | /// 190 | /// Creates a pipe target that writes to the specified string builder. 191 | /// 192 | public static PipeTarget ToStringBuilder(StringBuilder stringBuilder, Encoding encoding) => 193 | Create( 194 | async (origin, cancellationToken) => 195 | { 196 | using var reader = new StreamReader( 197 | origin, 198 | encoding, 199 | false, 200 | BufferSizes.StreamReader, 201 | true 202 | ); 203 | using var buffer = MemoryPool.Shared.Rent(BufferSizes.StreamReader); 204 | 205 | while (true) 206 | { 207 | var charsRead = await reader 208 | .ReadAsync(buffer.Memory, cancellationToken) 209 | .ConfigureAwait(false); 210 | if (charsRead <= 0) 211 | break; 212 | 213 | stringBuilder.Append(buffer.Memory[..charsRead]); 214 | } 215 | } 216 | ); 217 | 218 | /// 219 | /// Creates a pipe target that writes to the specified string builder. 220 | /// Uses for decoding. 221 | /// 222 | public static PipeTarget ToStringBuilder(StringBuilder stringBuilder) => 223 | ToStringBuilder(stringBuilder, Encoding.Default); 224 | 225 | /// 226 | /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. 227 | /// 228 | public static PipeTarget ToDelegate( 229 | Func handleLineAsync, 230 | Encoding encoding 231 | ) => 232 | Create( 233 | async (origin, cancellationToken) => 234 | { 235 | using var reader = new StreamReader( 236 | origin, 237 | encoding, 238 | false, 239 | BufferSizes.StreamReader, 240 | true 241 | ); 242 | 243 | await foreach ( 244 | var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false) 245 | ) 246 | { 247 | await handleLineAsync(line, cancellationToken).ConfigureAwait(false); 248 | } 249 | } 250 | ); 251 | 252 | /// 253 | /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. 254 | /// Uses for decoding. 255 | /// 256 | public static PipeTarget ToDelegate(Func handleLineAsync) => 257 | ToDelegate(handleLineAsync, Encoding.Default); 258 | 259 | /// 260 | /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. 261 | /// 262 | public static PipeTarget ToDelegate(Func handleLineAsync, Encoding encoding) => 263 | ToDelegate(async (line, _) => await handleLineAsync(line).ConfigureAwait(false), encoding); 264 | 265 | /// 266 | /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. 267 | /// Uses for decoding. 268 | /// 269 | public static PipeTarget ToDelegate(Func handleLineAsync) => 270 | ToDelegate(handleLineAsync, Encoding.Default); 271 | 272 | /// 273 | /// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream. 274 | /// 275 | public static PipeTarget ToDelegate(Action handleLine, Encoding encoding) => 276 | ToDelegate( 277 | line => 278 | { 279 | handleLine(line); 280 | return Task.CompletedTask; 281 | }, 282 | encoding 283 | ); 284 | 285 | /// 286 | /// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream. 287 | /// Uses for decoding. 288 | /// 289 | public static PipeTarget ToDelegate(Action handleLine) => 290 | ToDelegate(handleLine, Encoding.Default); 291 | 292 | /// 293 | /// Creates a pipe target that replicates data over multiple inner targets. 294 | /// 295 | public static PipeTarget Merge(params IEnumerable targets) 296 | { 297 | // This function needs to take output as a parameter because it's recursive 298 | static void FlattenTargets(IEnumerable targets, ICollection output) 299 | { 300 | foreach (var target in targets) 301 | { 302 | if (target is AggregatePipeTarget mergedTarget) 303 | { 304 | FlattenTargets(mergedTarget.Targets, output); 305 | } 306 | else 307 | { 308 | output.Add(target); 309 | } 310 | } 311 | } 312 | 313 | static IReadOnlyList OptimizeTargets(IEnumerable targets) 314 | { 315 | var result = new List(); 316 | 317 | // Unwrap merged targets 318 | FlattenTargets(targets, result); 319 | 320 | // Filter out no-op 321 | result.RemoveAll(t => t == Null); 322 | 323 | return result; 324 | } 325 | 326 | // Optimize targets to avoid unnecessary work 327 | var optimizedTargets = OptimizeTargets(targets); 328 | 329 | // Avoid merging if there's only one target 330 | if (optimizedTargets.Count == 1) 331 | return optimizedTargets.Single(); 332 | 333 | // Avoid merging if there are no targets 334 | if (optimizedTargets.Count == 0) 335 | return Null; 336 | 337 | return new AggregatePipeTarget(optimizedTargets); 338 | } 339 | 340 | /// 341 | /// Creates a pipe target that replicates data over multiple inner targets. 342 | /// 343 | // TODO: (breaking change) remove the other overload 344 | public static PipeTarget Merge(params PipeTarget[] targets) => 345 | Merge((IEnumerable)targets); 346 | } 347 | -------------------------------------------------------------------------------- /CliWrap/ResourcePolicy.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace CliWrap; 4 | 5 | /// 6 | /// Resource policy assigned to a process. 7 | /// 8 | /// 9 | /// For information on platform support, see attributes on , 10 | /// , and 11 | /// . 12 | /// 13 | public partial class ResourcePolicy( 14 | ProcessPriorityClass? priority = null, 15 | nint? affinity = null, 16 | nint? minWorkingSet = null, 17 | nint? maxWorkingSet = null 18 | ) 19 | { 20 | /// 21 | /// Priority class of the process. 22 | /// 23 | public ProcessPriorityClass? Priority { get; } = priority; 24 | 25 | /// 26 | /// Processor core affinity mask of the process. 27 | /// 28 | public nint? Affinity { get; } = affinity; 29 | 30 | /// 31 | /// Minimum working set size of the process. 32 | /// 33 | public nint? MinWorkingSet { get; } = minWorkingSet; 34 | 35 | /// 36 | /// Maximum working set size of the process. 37 | /// 38 | public nint? MaxWorkingSet { get; } = maxWorkingSet; 39 | } 40 | 41 | public partial class ResourcePolicy 42 | { 43 | /// 44 | /// Default resource policy. 45 | /// 46 | public static ResourcePolicy Default { get; } = new(); 47 | } 48 | -------------------------------------------------------------------------------- /CliWrap/Utils/BufferSizes.cs: -------------------------------------------------------------------------------- 1 | namespace CliWrap.Utils; 2 | 3 | internal static class BufferSizes 4 | { 5 | public const int Stream = 81920; 6 | public const int StreamReader = 1024; 7 | } 8 | -------------------------------------------------------------------------------- /CliWrap/Utils/Channel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.CompilerServices; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace CliWrap.Utils; 8 | 9 | internal class Channel : IDisposable 10 | { 11 | private readonly SemaphoreSlim _writeLock = new(1, 1); 12 | private readonly SemaphoreSlim _readLock = new(0, 1); 13 | 14 | private bool _isItemAvailable; 15 | private T _item = default!; 16 | 17 | public async Task PublishAsync(T item, CancellationToken cancellationToken = default) 18 | { 19 | await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); 20 | 21 | _item = item; 22 | _isItemAvailable = true; 23 | 24 | _readLock.Release(); 25 | } 26 | 27 | public async IAsyncEnumerable ReceiveAsync( 28 | [EnumeratorCancellation] CancellationToken cancellationToken = default 29 | ) 30 | { 31 | while (true) 32 | { 33 | await _readLock.WaitAsync(cancellationToken).ConfigureAwait(false); 34 | 35 | if (_isItemAvailable) 36 | { 37 | yield return _item; 38 | _isItemAvailable = false; 39 | } 40 | // If the read lock was released but the item is not available, 41 | // then the channel has been closed. 42 | else 43 | { 44 | break; 45 | } 46 | 47 | _writeLock.Release(); 48 | } 49 | } 50 | 51 | public async Task ReportCompletionAsync(CancellationToken cancellationToken = default) 52 | { 53 | await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); 54 | 55 | _item = default!; 56 | _isItemAvailable = false; 57 | 58 | _readLock.Release(); 59 | } 60 | 61 | public void Dispose() 62 | { 63 | _writeLock.Dispose(); 64 | _readLock.Dispose(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /CliWrap/Utils/Disposable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliWrap.Utils; 4 | 5 | internal class Disposable(Action dispose) : IDisposable 6 | { 7 | public static IDisposable Null { get; } = Create(() => { }); 8 | 9 | public static IDisposable Create(Action dispose) => new Disposable(dispose); 10 | 11 | public void Dispose() => dispose(); 12 | } 13 | -------------------------------------------------------------------------------- /CliWrap/Utils/EnvironmentEx.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | namespace CliWrap.Utils; 5 | 6 | internal static class EnvironmentEx 7 | { 8 | private static readonly Lazy ProcessPathLazy = new(() => 9 | { 10 | using var process = Process.GetCurrentProcess(); 11 | return process.MainModule?.FileName; 12 | }); 13 | 14 | public static string? ProcessPath => ProcessPathLazy.Value; 15 | } 16 | -------------------------------------------------------------------------------- /CliWrap/Utils/Extensions/AssemblyExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Reflection; 3 | using System.Resources; 4 | 5 | namespace CliWrap.Utils.Extensions; 6 | 7 | internal static class AssemblyExtensions 8 | { 9 | public static void ExtractManifestResource( 10 | this Assembly assembly, 11 | string resourceName, 12 | string destFilePath 13 | ) 14 | { 15 | var input = 16 | assembly.GetManifestResourceStream(resourceName) 17 | ?? throw new MissingManifestResourceException( 18 | $"Failed to find resource '{resourceName}'." 19 | ); 20 | 21 | using var output = File.Create(destFilePath); 22 | input.CopyTo(output); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CliWrap/Utils/Extensions/AsyncDisposableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace CliWrap.Utils.Extensions; 5 | 6 | internal static partial class AsyncDisposableExtensions 7 | { 8 | public static IAsyncDisposable ToAsyncDisposable(this IDisposable disposable) => 9 | new AsyncDisposableAdapter(disposable); 10 | } 11 | 12 | internal static partial class AsyncDisposableExtensions 13 | { 14 | // Provides a dynamic and uniform way to deal with async disposable. 15 | // Used as an abstraction to polyfill IAsyncDisposable implementations in BCL types. For example: 16 | // - Stream class on .NET Framework 4.6.1 -> calls Dispose() 17 | // - Stream class on .NET Core 3.0 -> calls DisposeAsync() 18 | // - Stream class on .NET Standard 2.0 -> calls DisposeAsync() or Dispose(), depending on the runtime 19 | private readonly struct AsyncDisposableAdapter(IDisposable target) : IAsyncDisposable 20 | { 21 | public async ValueTask DisposeAsync() 22 | { 23 | if (target is IAsyncDisposable asyncDisposable) 24 | { 25 | await asyncDisposable.DisposeAsync().ConfigureAwait(false); 26 | } 27 | else 28 | { 29 | target.Dispose(); 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CliWrap/Utils/Extensions/CancellationTokenExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | 4 | namespace CliWrap.Utils.Extensions; 5 | 6 | internal static class CancellationTokenExtensions 7 | { 8 | public static void ThrowIfCancellationRequested( 9 | this CancellationToken cancellationToken, 10 | string message 11 | ) 12 | { 13 | if (!cancellationToken.IsCancellationRequested) 14 | return; 15 | 16 | throw new OperationCanceledException(message, cancellationToken); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CliWrap/Utils/Extensions/ExceptionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | 4 | namespace CliWrap.Utils.Extensions; 5 | 6 | internal static class ExceptionExtensions 7 | { 8 | public static Exception? TryGetSingle(this AggregateException exception) 9 | { 10 | var exceptions = exception.Flatten().InnerExceptions; 11 | 12 | return exceptions.Count == 1 ? exceptions.Single() : null; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /CliWrap/Utils/Extensions/StreamExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Runtime.CompilerServices; 5 | using System.Text; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace CliWrap.Utils.Extensions; 10 | 11 | internal static class StreamExtensions 12 | { 13 | public static async Task CopyToAsync( 14 | this Stream source, 15 | Stream destination, 16 | bool autoFlush, 17 | CancellationToken cancellationToken = default 18 | ) 19 | { 20 | using var buffer = MemoryPool.Shared.Rent(BufferSizes.Stream); 21 | while (true) 22 | { 23 | var bytesRead = await source 24 | .ReadAsync(buffer.Memory, cancellationToken) 25 | .ConfigureAwait(false); 26 | 27 | if (bytesRead <= 0) 28 | break; 29 | 30 | await destination 31 | .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) 32 | .ConfigureAwait(false); 33 | 34 | if (autoFlush) 35 | await destination.FlushAsync(cancellationToken).ConfigureAwait(false); 36 | } 37 | } 38 | 39 | public static async IAsyncEnumerable ReadAllLinesAsync( 40 | this StreamReader reader, 41 | [EnumeratorCancellation] CancellationToken cancellationToken = default 42 | ) 43 | { 44 | // We could use reader.ReadLineAsync() and loop on it, but that method 45 | // only supports cancellation on .NET 7+ and it's impossible to polyfill 46 | // it for non-seekable streams. So we have to do it manually. 47 | 48 | var lineBuffer = new StringBuilder(); 49 | using var buffer = MemoryPool.Shared.Rent(BufferSizes.StreamReader); 50 | 51 | // Following sequences are treated as individual linebreaks: 52 | // - \r 53 | // - \n 54 | // - \r\n 55 | // Even though \r and \n are linebreaks on their own, \r\n together 56 | // should not yield two lines. 57 | var isLastCaretReturn = false; 58 | while (true) 59 | { 60 | var charsRead = await reader 61 | .ReadAsync(buffer.Memory, cancellationToken) 62 | .ConfigureAwait(false); 63 | if (charsRead <= 0) 64 | break; 65 | 66 | for (var i = 0; i < charsRead; i++) 67 | { 68 | var c = buffer.Memory.Span[i]; 69 | 70 | // If the current char and the last char are part of a line break sequence, 71 | // skip over the current char and move on. 72 | // The buffer was already yielded in the previous iteration, so there's 73 | // nothing left to do. 74 | if (isLastCaretReturn && c == '\n') 75 | { 76 | isLastCaretReturn = false; 77 | continue; 78 | } 79 | 80 | // If the current char is \n or \r, yield the buffer (even if it is empty) 81 | if (c is '\n' or '\r') 82 | { 83 | yield return lineBuffer.ToString(); 84 | lineBuffer.Clear(); 85 | } 86 | // For any other char, just append it to the buffer 87 | else 88 | { 89 | lineBuffer.Append(c); 90 | } 91 | 92 | isLastCaretReturn = c == '\r'; 93 | } 94 | } 95 | 96 | // Yield what's remaining in the buffer 97 | if (lineBuffer.Length > 0) 98 | yield return lineBuffer.ToString(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /CliWrap/Utils/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security; 2 | 3 | namespace CliWrap.Utils.Extensions; 4 | 5 | internal static class StringExtensions 6 | { 7 | public static SecureString ToSecureString(this string str) 8 | { 9 | var secureString = new SecureString(); 10 | 11 | foreach (var c in str) 12 | secureString.AppendChar(c); 13 | 14 | secureString.MakeReadOnly(); 15 | 16 | return secureString; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CliWrap/Utils/Extensions/TaskExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace CliWrap.Utils.Extensions; 5 | 6 | internal static class TaskExtensions 7 | { 8 | public static async Task Select( 9 | this Task task, 10 | Func transform 11 | ) 12 | { 13 | var result = await task.ConfigureAwait(false); 14 | return transform(result); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CliWrap/Utils/NativeMethods.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace CliWrap.Utils; 4 | 5 | internal static class NativeMethods 6 | { 7 | public static class Unix 8 | { 9 | [DllImport("libc", EntryPoint = "kill", SetLastError = true)] 10 | public static extern int Kill(int pid, int sig); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /CliWrap/Utils/Observable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CliWrap.Utils; 4 | 5 | internal class Observable(Func, IDisposable> subscribe) : IObservable 6 | { 7 | public IDisposable Subscribe(IObserver observer) => subscribe(observer); 8 | } 9 | 10 | internal static class Observable 11 | { 12 | public static IObservable Create(Func, IDisposable> subscribe) => 13 | new Observable(subscribe); 14 | 15 | public static IObservable CreateSynchronized(Func, IDisposable> subscribe) => 16 | Create(observer => subscribe(new SynchronizedObserver(observer))); 17 | } 18 | -------------------------------------------------------------------------------- /CliWrap/Utils/ProcessEx.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Runtime.InteropServices; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using CliWrap.Utils.Extensions; 9 | 10 | namespace CliWrap.Utils; 11 | 12 | internal class ProcessEx(ProcessStartInfo startInfo) : IDisposable 13 | { 14 | private readonly Process _nativeProcess = new() { StartInfo = startInfo }; 15 | 16 | private readonly TaskCompletionSource _exitTcs = new( 17 | TaskCreationOptions.RunContinuationsAsynchronously 18 | ); 19 | 20 | public int Id => _nativeProcess.Id; 21 | 22 | public string Name => 23 | // Can't rely on ProcessName because it becomes inaccessible after the process exits 24 | Path.GetFileName(_nativeProcess.StartInfo.FileName); 25 | 26 | // We are purposely using Stream instead of StreamWriter/StreamReader to push the concerns of 27 | // writing and reading to PipeSource/PipeTarget at the higher level. 28 | 29 | public Stream StandardInput => 30 | _nativeProcess.StartInfo.RedirectStandardInput 31 | ? _nativeProcess.StandardInput.BaseStream 32 | : Stream.Null; 33 | 34 | public Stream StandardOutput => 35 | _nativeProcess.StartInfo.RedirectStandardOutput 36 | ? _nativeProcess.StandardOutput.BaseStream 37 | : Stream.Null; 38 | 39 | public Stream StandardError => 40 | _nativeProcess.StartInfo.RedirectStandardError 41 | ? _nativeProcess.StandardError.BaseStream 42 | : Stream.Null; 43 | 44 | // We have to keep track of StartTime ourselves because it becomes inaccessible after the process exits 45 | // https://github.com/Tyrrrz/CliWrap/issues/93 46 | public DateTimeOffset StartTime { get; private set; } 47 | 48 | // We have to keep track of ExitTime ourselves because it becomes inaccessible after the process exits 49 | // https://github.com/Tyrrrz/CliWrap/issues/93 50 | public DateTimeOffset ExitTime { get; private set; } 51 | 52 | public int ExitCode => _nativeProcess.ExitCode; 53 | 54 | public void Start(Action? configureProcess = null) 55 | { 56 | // Hook up events 57 | _nativeProcess.EnableRaisingEvents = true; 58 | _nativeProcess.Exited += (_, _) => 59 | { 60 | // Record exit time 61 | ExitTime = DateTimeOffset.Now; 62 | 63 | // Release the waiting task 64 | _exitTcs.TrySetResult(null); 65 | }; 66 | 67 | // Start the process 68 | try 69 | { 70 | if (!_nativeProcess.Start()) 71 | { 72 | throw new InvalidOperationException( 73 | $"Failed to start a process with file path '{_nativeProcess.StartInfo.FileName}'. " 74 | + "Target file is not an executable or lacks the 'execute' permission." 75 | ); 76 | } 77 | } 78 | catch (Win32Exception ex) 79 | { 80 | throw new Win32Exception( 81 | $"Failed to start a process with file path '{_nativeProcess.StartInfo.FileName}'. " 82 | + "Target file or working directory doesn't exist, the provided credentials are invalid, or the resource policy cannot be set due to insufficient permissions. " 83 | + "See the inner exception for more information.", 84 | ex 85 | ); 86 | } 87 | 88 | // Record start time 89 | StartTime = DateTimeOffset.Now; 90 | 91 | // Apply custom configurations 92 | configureProcess?.Invoke(_nativeProcess); 93 | } 94 | 95 | // Sends SIGINT 96 | public void Interrupt() 97 | { 98 | bool TryInterrupt() 99 | { 100 | try 101 | { 102 | // On Windows, we need to launch an external executable that will attach 103 | // to the target process's console and then send a Ctrl+C event to it. 104 | // https://github.com/Tyrrrz/CliWrap/issues/47 105 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 106 | { 107 | using var signaler = WindowsSignaler.Deploy(); 108 | return signaler.TrySend(_nativeProcess.Id, 0); 109 | } 110 | 111 | // On Unix, we can just send the signal to the process directly 112 | if ( 113 | RuntimeInformation.IsOSPlatform(OSPlatform.Linux) 114 | || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 115 | ) 116 | { 117 | return NativeMethods.Unix.Kill(_nativeProcess.Id, 2) == 0; 118 | } 119 | 120 | // Unsupported platform 121 | return false; 122 | } 123 | catch 124 | { 125 | return false; 126 | } 127 | } 128 | 129 | if (!TryInterrupt()) 130 | { 131 | // In case of failure, revert to the default behavior of killing the process. 132 | // Ideally, we should throw an exception here, but this method is called from 133 | // a cancellation callback upstream, so we can't do that. 134 | Kill(); 135 | Debug.Fail("Failed to send an interrupt signal."); 136 | } 137 | } 138 | 139 | // Sends SIGKILL 140 | public void Kill() 141 | { 142 | try 143 | { 144 | _nativeProcess.Kill(true); 145 | } 146 | catch when (_nativeProcess.HasExited) 147 | { 148 | // The process has exited before we could kill it. This is fine. 149 | } 150 | catch 151 | { 152 | // The process either failed to exit or is in the process of exiting. 153 | // We can't really do anything about it, so just ignore the exception. 154 | Debug.Fail("Failed to kill the process."); 155 | } 156 | } 157 | 158 | public async Task WaitUntilExitAsync(CancellationToken cancellationToken = default) 159 | { 160 | await using ( 161 | cancellationToken 162 | .Register(() => _exitTcs.TrySetCanceled(cancellationToken)) 163 | .ToAsyncDisposable() 164 | ) 165 | { 166 | await _exitTcs.Task.ConfigureAwait(false); 167 | } 168 | } 169 | 170 | public void Dispose() => _nativeProcess.Dispose(); 171 | } 172 | -------------------------------------------------------------------------------- /CliWrap/Utils/SimplexStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.IO; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace CliWrap.Utils; 9 | 10 | internal class SimplexStream : Stream 11 | { 12 | private readonly SemaphoreSlim _writeLock = new(1, 1); 13 | private readonly SemaphoreSlim _readLock = new(0, 1); 14 | 15 | private IMemoryOwner _sharedBuffer = MemoryPool.Shared.Rent(BufferSizes.Stream); 16 | private int _sharedBufferBytes; 17 | private int _sharedBufferBytesRead; 18 | 19 | [ExcludeFromCodeCoverage] 20 | public override bool CanRead => true; 21 | 22 | [ExcludeFromCodeCoverage] 23 | public override bool CanSeek => false; 24 | 25 | [ExcludeFromCodeCoverage] 26 | public override bool CanWrite => true; 27 | 28 | [ExcludeFromCodeCoverage] 29 | public override long Position { get; set; } 30 | 31 | [ExcludeFromCodeCoverage] 32 | public override long Length => throw new NotSupportedException(); 33 | 34 | public override async Task WriteAsync( 35 | byte[] buffer, 36 | int offset, 37 | int count, 38 | CancellationToken cancellationToken 39 | ) 40 | { 41 | await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); 42 | 43 | // Reset the buffer if the current one is too small for the incoming data 44 | if (_sharedBuffer.Memory.Length < count) 45 | { 46 | _sharedBuffer.Dispose(); 47 | _sharedBuffer = MemoryPool.Shared.Rent(count); 48 | } 49 | 50 | buffer.AsSpan(offset, count).CopyTo(_sharedBuffer.Memory.Span); 51 | 52 | _sharedBufferBytes = count; 53 | _sharedBufferBytesRead = 0; 54 | 55 | _readLock.Release(); 56 | } 57 | 58 | public override async Task ReadAsync( 59 | byte[] buffer, 60 | int offset, 61 | int count, 62 | CancellationToken cancellationToken 63 | ) 64 | { 65 | await _readLock.WaitAsync(cancellationToken).ConfigureAwait(false); 66 | 67 | var length = Math.Min(count, _sharedBufferBytes - _sharedBufferBytesRead); 68 | 69 | _sharedBuffer 70 | .Memory.Slice(_sharedBufferBytesRead, length) 71 | .CopyTo(buffer.AsMemory(offset, length)); 72 | 73 | _sharedBufferBytesRead += length; 74 | 75 | // Release the write lock if the consumer has finished reading all of 76 | // the previously written data. 77 | if (_sharedBufferBytesRead >= _sharedBufferBytes) 78 | { 79 | _writeLock.Release(); 80 | } 81 | // Otherwise, release the read lock again so that the consumer can finish 82 | // reading the data. 83 | else 84 | { 85 | _readLock.Release(); 86 | } 87 | 88 | return length; 89 | } 90 | 91 | public async Task ReportCompletionAsync(CancellationToken cancellationToken = default) => 92 | // Write an empty buffer that will make ReadAsync(...) return 0, which signifies the end of stream 93 | await WriteAsync([], 0, 0, cancellationToken).ConfigureAwait(false); 94 | 95 | protected override void Dispose(bool disposing) 96 | { 97 | if (disposing) 98 | { 99 | _readLock.Dispose(); 100 | _writeLock.Dispose(); 101 | _sharedBuffer.Dispose(); 102 | } 103 | 104 | base.Dispose(disposing); 105 | } 106 | 107 | [ExcludeFromCodeCoverage] 108 | public override int Read(byte[] buffer, int offset, int count) => 109 | ReadAsync(buffer, offset, count).GetAwaiter().GetResult(); 110 | 111 | [ExcludeFromCodeCoverage] 112 | public override void Write(byte[] buffer, int offset, int count) => 113 | WriteAsync(buffer, offset, count).GetAwaiter().GetResult(); 114 | 115 | [ExcludeFromCodeCoverage] 116 | public override void Flush() => throw new NotSupportedException(); 117 | 118 | [ExcludeFromCodeCoverage] 119 | public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); 120 | 121 | [ExcludeFromCodeCoverage] 122 | public override void SetLength(long value) => throw new NotSupportedException(); 123 | } 124 | -------------------------------------------------------------------------------- /CliWrap/Utils/SynchronizedObserver.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | 4 | namespace CliWrap.Utils; 5 | 6 | internal class SynchronizedObserver(IObserver observer) : IObserver 7 | { 8 | private readonly Lock _lock = new(); 9 | 10 | public void OnCompleted() 11 | { 12 | using (_lock.EnterScope()) 13 | { 14 | observer.OnCompleted(); 15 | } 16 | } 17 | 18 | public void OnError(Exception error) 19 | { 20 | using (_lock.EnterScope()) 21 | { 22 | observer.OnError(error); 23 | } 24 | } 25 | 26 | public void OnNext(T value) 27 | { 28 | using (_lock.EnterScope()) 29 | { 30 | observer.OnNext(value); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CliWrap/Utils/WindowsSignaler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Reflection; 6 | using CliWrap.Utils.Extensions; 7 | 8 | namespace CliWrap.Utils; 9 | 10 | internal partial class WindowsSignaler(string filePath) : IDisposable 11 | { 12 | public bool TrySend(int processId, int signalId) 13 | { 14 | using var process = new Process 15 | { 16 | StartInfo = new ProcessStartInfo 17 | { 18 | FileName = filePath, 19 | Arguments = 20 | processId.ToString(CultureInfo.InvariantCulture) 21 | + ' ' 22 | + signalId.ToString(CultureInfo.InvariantCulture), 23 | CreateNoWindow = true, 24 | UseShellExecute = false, 25 | Environment = 26 | { 27 | // This is a .NET 3.5 executable, so we need to configure framework rollover 28 | // to allow it to also run against .NET 4.0 and higher. 29 | // https://gist.github.com/MichalStrehovsky/d6bc5e4d459c23d0cf3bd17af9a1bcf5 30 | ["COMPLUS_OnlyUseLatestCLR"] = "1", 31 | }, 32 | }, 33 | }; 34 | 35 | if (!process.Start()) 36 | return false; 37 | 38 | if (!process.WaitForExit(30_000)) 39 | return false; 40 | 41 | return process.ExitCode == 0; 42 | } 43 | 44 | public void Dispose() 45 | { 46 | try 47 | { 48 | File.Delete(filePath); 49 | } 50 | catch 51 | { 52 | Debug.Fail("Failed to delete the signaler executable."); 53 | } 54 | } 55 | } 56 | 57 | internal partial class WindowsSignaler 58 | { 59 | public static WindowsSignaler Deploy() 60 | { 61 | // Signaler executable is embedded inside this library as a resource 62 | var filePath = Path.Combine(Path.GetTempPath(), $"CliWrap.Signaler.{Guid.NewGuid()}.exe"); 63 | Assembly.GetExecutingAssembly().ExtractManifestResource("CliWrap.Signaler.exe", filePath); 64 | 65 | return new WindowsSignaler(filePath); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /CliWrap/key.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/CliWrap/4d0471ea35c8e30388a312c0cb9c8d737dbe153e/CliWrap/key.snk -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0.0.0-dev 5 | Tyrrrz 6 | Copyright (C) Oleksii Holub 7 | latest 8 | enable 9 | true 10 | false 11 | false 12 | 13 | 14 | 15 | 16 | annotations 17 | 18 | 19 | 20 | $(Company) 21 | Library for interacting with external command-line interfaces 22 | shell pipe command line executable interface wrapper standard input output error arguments net core 23 | https://github.com/Tyrrrz/CliWrap 24 | https://github.com/Tyrrrz/CliWrap/releases 25 | MIT 26 | 27 | 28 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2025 Oleksii Holub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tyrrrz/CliWrap/4d0471ea35c8e30388a312c0cb9c8d737dbe153e/favicon.png --------------------------------------------------------------------------------