├── .editorconfig ├── .github ├── dependabot.yaml └── workflows │ ├── build-debug.yaml │ ├── build-release.yaml │ ├── stale.yaml │ └── toc.yaml ├── .gitignore ├── LICENSE ├── ProcessX.sln ├── README.md ├── sandbox ├── ConsoleApp │ ├── ConsoleApp.csproj │ └── Program.cs └── ReturnMessage │ ├── Program.cs │ └── ReturnMessage.csproj ├── src └── ProcessX │ ├── ProcessAsyncEnumerable.cs │ ├── ProcessAsyncEnumerator.cs │ ├── ProcessErrorException.cs │ ├── ProcessX.cs │ ├── ProcessX.csproj │ └── Zx │ ├── Env.cs │ ├── EscapeFormattableString.cs │ ├── StringProcessExtensions.cs │ └── Which.cs └── tests └── ProcessX.Tests ├── ProcessX.Tests.csproj └── UnitTest1.cs /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | # Visual Studio Spell checker configs (https://learn.microsoft.com/en-us/visualstudio/ide/text-spell-checker?view=vs-2022#how-to-customize-the-spell-checker) 13 | spelling_exclusion_path = ./exclusion.dic 14 | 15 | [*.cs] 16 | indent_size = 4 17 | charset = utf-8-bom 18 | end_of_line = unset 19 | 20 | # Solution files 21 | [*.{sln,slnx}] 22 | end_of_line = unset 23 | 24 | # MSBuild project files 25 | [*.{csproj,props,targets}] 26 | end_of_line = unset 27 | 28 | # Xml config files 29 | [*.{ruleset,config,nuspec,resx,runsettings,DotSettings}] 30 | end_of_line = unset 31 | 32 | [*{_AssemblyInfo.cs,.notsupported.cs}] 33 | generated_code = true 34 | 35 | # C# code style settings 36 | [*.{cs}] 37 | dotnet_diagnostic.IDE0044.severity = none # IDE0044: Make field readonly 38 | 39 | # https://stackoverflow.com/questions/79195382/how-to-disable-fading-unused-methods-in-visual-studio-2022-17-12-0 40 | dotnet_diagnostic.IDE0051.severity = none # IDE0051: Remove unused private member 41 | dotnet_diagnostic.IDE0130.severity = none # IDE0130: Namespace does not match folder structure 42 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" # Check for updates to GitHub Actions every week 8 | ignore: 9 | # I just want update action when major/minor version is updated. patch updates are too noisy. 10 | - dependency-name: '*' 11 | update-types: 12 | - version-update:semver-patch 13 | -------------------------------------------------------------------------------- /.github/workflows/build-debug.yaml: -------------------------------------------------------------------------------- 1 | name: Build-Debug 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build-dotnet: 13 | permissions: 14 | contents: read 15 | runs-on: ubuntu-24.04 16 | timeout-minutes: 10 17 | steps: 18 | - uses: Cysharp/Actions/.github/actions/checkout@main 19 | - uses: Cysharp/Actions/.github/actions/setup-dotnet@main 20 | - run: dotnet build -c Debug 21 | - run: dotnet test -c Debug --no-build 22 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build-Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: "tag: git tag you want create. (sample 1.0.0)" 8 | required: true 9 | dry-run: 10 | description: "dry-run: true will never create release/nuget." 11 | required: true 12 | default: false 13 | type: boolean 14 | 15 | jobs: 16 | build-dotnet: 17 | permissions: 18 | contents: read 19 | runs-on: ubuntu-24.04 20 | timeout-minutes: 10 21 | steps: 22 | - uses: Cysharp/Actions/.github/actions/checkout@main 23 | - uses: Cysharp/Actions/.github/actions/setup-dotnet@main 24 | # pack nuget 25 | - run: dotnet build -c Release -p:Version=${{ inputs.tag }} 26 | - run: dotnet test -c Release --no-build -p:Version=${{ inputs.tag }} 27 | - run: dotnet pack -c Release --no-build -p:Version=${{ inputs.tag }} -o ./publish 28 | - uses: Cysharp/Actions/.github/actions/upload-artifact@main 29 | with: 30 | name: nuget 31 | path: ./publish 32 | retention-days: 1 33 | 34 | # release 35 | create-release: 36 | needs: [build-dotnet] 37 | permissions: 38 | contents: write 39 | uses: Cysharp/Actions/.github/workflows/create-release.yaml@main 40 | with: 41 | commit-id: '' 42 | dry-run: ${{ inputs.dry-run }} 43 | tag: ${{ inputs.tag }} 44 | nuget-push: true 45 | secrets: inherit 46 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | jobs: 9 | stale: 10 | permissions: 11 | contents: read 12 | pull-requests: write 13 | issues: write 14 | uses: Cysharp/Actions/.github/workflows/stale-issue.yaml@main 15 | -------------------------------------------------------------------------------- /.github/workflows/toc.yaml: -------------------------------------------------------------------------------- 1 | name: TOC Generator 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'README.md' 7 | 8 | jobs: 9 | toc: 10 | permissions: 11 | contents: write 12 | uses: Cysharp/Actions/.github/workflows/toc-generator.yaml@main 13 | with: 14 | TOC_TITLE: "## Table of Contents" 15 | secrets: inherit 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build Folders (you can keep bin if you'd like, to store dlls and pdbs) 2 | [Bb]in/ 3 | [Oo]bj/ 4 | 5 | # mstest test results 6 | TestResults 7 | 8 | ## Ignore Visual Studio temporary files, build results, and 9 | ## files generated by popular Visual Studio add-ons. 10 | 11 | # User-specific files 12 | *.suo 13 | *.user 14 | *.sln.docstates 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Rr]elease/ 19 | x64/ 20 | *_i.c 21 | *_p.c 22 | *.ilk 23 | *.obj 24 | *.pch 25 | *.pdb 26 | *.pgc 27 | *.pgd 28 | *.rsp 29 | *.sbr 30 | *.tlb 31 | *.tli 32 | *.tlh 33 | *.tmp 34 | *.log 35 | *.vspscc 36 | *.vssscc 37 | .builds 38 | 39 | # Visual C++ cache files 40 | ipch/ 41 | *.aps 42 | *.ncb 43 | *.opensdf 44 | *.sdf 45 | 46 | # Visual Studio profiler 47 | *.psess 48 | *.vsp 49 | *.vspx 50 | 51 | # Guidance Automation Toolkit 52 | *.gpState 53 | 54 | # ReSharper is a .NET coding add-in 55 | _ReSharper* 56 | 57 | # NCrunch 58 | *.ncrunch* 59 | .*crunch*.local.xml 60 | 61 | # Installshield output folder 62 | [Ee]xpress 63 | 64 | # DocProject is a documentation generator add-in 65 | DocProject/buildhelp/ 66 | DocProject/Help/*.HxT 67 | DocProject/Help/*.HxC 68 | DocProject/Help/*.hhc 69 | DocProject/Help/*.hhk 70 | DocProject/Help/*.hhp 71 | DocProject/Help/Html2 72 | DocProject/Help/html 73 | 74 | # Click-Once directory 75 | publish 76 | 77 | # Publish Web Output 78 | *.Publish.xml 79 | 80 | # NuGet Packages Directory 81 | packages 82 | 83 | # Windows Azure Build Output 84 | csx 85 | *.build.csdef 86 | 87 | # Windows Store app package directory 88 | AppPackages/ 89 | 90 | # Others 91 | [Bb]in 92 | [Oo]bj 93 | sql 94 | TestResults 95 | [Tt]est[Rr]esult* 96 | *.Cache 97 | ClientBin 98 | [Ss]tyle[Cc]op.* 99 | ~$* 100 | *.dbmdl 101 | Generated_Code #added for RIA/Silverlight projects 102 | 103 | # Backup & report files from converting an old project file to a newer 104 | # Visual Studio version. Backup files are not needed, because we have git ;-) 105 | _UpgradeReport_Files/ 106 | Backup*/ 107 | UpgradeLog*.XML 108 | .vs/config/applicationhost.config 109 | .vs/restore.dg 110 | 111 | # OTHER 112 | nuget/tools/* 113 | *.nupkg 114 | 115 | .vs 116 | 117 | # Unity 118 | Library/ 119 | Temp/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cysharp, Inc. 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 | -------------------------------------------------------------------------------- /ProcessX.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29613.14 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProcessX", "src\ProcessX\ProcessX.csproj", "{7C485BF1-5E74-4638-9179-0F535E3F4DE1}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProcessX.Tests", "tests\ProcessX.Tests\ProcessX.Tests.csproj", "{F4E6B8E8-9F69-445C-823B-A594AB23599E}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "sandbox\ConsoleApp\ConsoleApp.csproj", "{FF47BCEA-0910-4A16-B7F7-1F92498085A8}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sandbox", "sandbox", "{E867683A-4F47-421A-B666-53178446B942}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3651D11A-EAA0-4489-8982-B0B64C758EF4}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{737CE3B1-18AD-4A89-9EA7-1EE2ACB931DD}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C4449B34-8145-4536-A0DC-8836E408BC51}" 19 | ProjectSection(SolutionItems) = preProject 20 | .gitignore = .gitignore 21 | .circleci\config.yml = .circleci\config.yml 22 | LICENSE = LICENSE 23 | README.md = README.md 24 | EndProjectSection 25 | EndProject 26 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReturnMessage", "sandbox\ReturnMessage\ReturnMessage.csproj", "{0E468B58-A81E-450D-B4E4-32B087EBE1F7}" 27 | EndProject 28 | Global 29 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 30 | Debug|Any CPU = Debug|Any CPU 31 | Release|Any CPU = Release|Any CPU 32 | EndGlobalSection 33 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 34 | {7C485BF1-5E74-4638-9179-0F535E3F4DE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {7C485BF1-5E74-4638-9179-0F535E3F4DE1}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {7C485BF1-5E74-4638-9179-0F535E3F4DE1}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {7C485BF1-5E74-4638-9179-0F535E3F4DE1}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {F4E6B8E8-9F69-445C-823B-A594AB23599E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {F4E6B8E8-9F69-445C-823B-A594AB23599E}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {F4E6B8E8-9F69-445C-823B-A594AB23599E}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {F4E6B8E8-9F69-445C-823B-A594AB23599E}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {FF47BCEA-0910-4A16-B7F7-1F92498085A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {FF47BCEA-0910-4A16-B7F7-1F92498085A8}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {FF47BCEA-0910-4A16-B7F7-1F92498085A8}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {FF47BCEA-0910-4A16-B7F7-1F92498085A8}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {0E468B58-A81E-450D-B4E4-32B087EBE1F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {0E468B58-A81E-450D-B4E4-32B087EBE1F7}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {0E468B58-A81E-450D-B4E4-32B087EBE1F7}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {0E468B58-A81E-450D-B4E4-32B087EBE1F7}.Release|Any CPU.Build.0 = Release|Any CPU 50 | EndGlobalSection 51 | GlobalSection(SolutionProperties) = preSolution 52 | HideSolutionNode = FALSE 53 | EndGlobalSection 54 | GlobalSection(NestedProjects) = preSolution 55 | {7C485BF1-5E74-4638-9179-0F535E3F4DE1} = {3651D11A-EAA0-4489-8982-B0B64C758EF4} 56 | {F4E6B8E8-9F69-445C-823B-A594AB23599E} = {737CE3B1-18AD-4A89-9EA7-1EE2ACB931DD} 57 | {FF47BCEA-0910-4A16-B7F7-1F92498085A8} = {E867683A-4F47-421A-B666-53178446B942} 58 | {0E468B58-A81E-450D-B4E4-32B087EBE1F7} = {E867683A-4F47-421A-B666-53178446B942} 59 | EndGlobalSection 60 | GlobalSection(ExtensibilityGlobals) = postSolution 61 | SolutionGuid = {C5585A8D-24C7-479E-8B7F-1017D80E2214} 62 | EndGlobalSection 63 | EndGlobal 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Actions](https://github.com/Cysharp/ProcessX/workflows/Build-Debug/badge.svg)](https://github.com/Cysharp/ProcessX/actions) 2 | 3 | ProcessX 4 | === 5 | 6 | ProcessX simplifies call an external process with the aync streams in C# 8.0 without complex `Process` code. You can receive standard output results by `await foreach`, it is completely asynchronous and realtime. 7 | 8 | ![image](https://user-images.githubusercontent.com/46207/73369038-504f0c80-42f5-11ea-8b36-5c5c979ac882.png) 9 | 10 | Also provides zx mode to write shell script in C#, details see [Zx](#zx) section. 11 | 12 | ![image](https://user-images.githubusercontent.com/46207/130373766-0f16e9ad-57ba-446b-81ee-c255c7149035.png) 13 | 14 | 15 | 16 | ## Table of Contents 17 | 18 | - [Getting Started](#getting-started) 19 | - [Cancellation](#cancellation) 20 | - [Raw Process/StdError Stream](#raw-processstderror-stream) 21 | - [Read Binary Data](#read-binary-data) 22 | - [Change acceptable exit codes](#change-acceptable-exit-codes) 23 | - [Zx](#zx) 24 | - [Reference](#reference) 25 | - [Competitor](#competitor) 26 | - [License](#license) 27 | 28 | 29 | 30 | Getting Started 31 | --- 32 | Install library from NuGet that support from `.NET Standard 2.0`. 33 | 34 | > PM> Install-Package [ProcessX](https://www.nuget.org/packages/ProcessX) 35 | 36 | Main API is only `Cysharp.Diagnostics.ProcessX.StartAsync` and throws `ProcessErrorException` when error detected. 37 | 38 | * **Simple**, only write single string command like the shell script. 39 | * **Asynchronous**, by C# 8.0 async streams. 40 | * **Manage Error**, handling exitcode and stderror. 41 | 42 | ```csharp 43 | using Cysharp.Diagnostics; // using namespace 44 | 45 | // async iterate. 46 | await foreach (string item in ProcessX.StartAsync("dotnet --info")) 47 | { 48 | Console.WriteLine(item); 49 | } 50 | 51 | // receive string result from stdout. 52 | var version = await ProcessX.StartAsync("dotnet --version").FirstAsync(); 53 | 54 | // receive buffered result(similar as WaitForExit). 55 | string[] result = await ProcessX.StartAsync("dotnet --info").ToTask(); 56 | 57 | // like the shell exec, write all data to console. 58 | await ProcessX.StartAsync("dotnet --info").WriteLineAllAsync(); 59 | 60 | // consume all result and wait complete asynchronously(useful to use no result process). 61 | await ProcessX.StartAsync("cmd /c mkdir foo").WaitAsync(); 62 | 63 | // when ExitCode is not 0 or StandardError is exists, throws ProcessErrorException 64 | try 65 | { 66 | await foreach (var item in ProcessX.StartAsync("dotnet --foo --bar")) { } 67 | } 68 | catch (ProcessErrorException ex) 69 | { 70 | // int .ExitCode 71 | // string[] .ErrorOutput 72 | Console.WriteLine(ex.ToString()); 73 | } 74 | ``` 75 | 76 | Cancellation 77 | --- 78 | to Cancel, you can use `WithCancellation` of IAsyncEnumerable. 79 | 80 | ```csharp 81 | // when cancel has been called and process still exists, call process kill before exit. 82 | await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cancellationToken)) 83 | { 84 | Console.WriteLine(item); 85 | } 86 | ``` 87 | 88 | timeout, you can use `CancellationTokenSource(delay)`. 89 | 90 | ```csharp 91 | using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1))) 92 | { 93 | await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cts.Token)) 94 | { 95 | Console.WriteLine(item); 96 | } 97 | } 98 | ``` 99 | 100 | Raw Process/StdError Stream 101 | --- 102 | In default, when stdError is used, buffering error messages and throws `ProcessErrorException` with error messages after process exited. If you want to use stdError in streaming or avoid throws error when process using stderror as progress, diagnostics, you can use `GetDualAsyncEnumerable` method. Also `GetDualAsyncEnumerable` can get raw `Process`, you can use `ProcessID`, `StandardInput` etc. 103 | 104 | ```csharp 105 | // first argument is Process, if you want to know ProcessID, use StandardInput, use it. 106 | var (_, stdOut, stdError) = ProcessX.GetDualAsyncEnumerable("dotnet --foo --bar"); 107 | 108 | var consumeStdOut = Task.Run(async () => 109 | { 110 | await foreach (var item in stdOut) 111 | { 112 | Console.WriteLine("STDOUT: " + item); 113 | } 114 | }); 115 | 116 | var errorBuffered = new List(); 117 | var consumeStdError = Task.Run(async () => 118 | { 119 | await foreach (var item in stdError) 120 | { 121 | Console.WriteLine("STDERROR: " + item); 122 | errorBuffered.Add(item); 123 | } 124 | }); 125 | 126 | try 127 | { 128 | await Task.WhenAll(consumeStdOut, consumeStdError); 129 | } 130 | catch (ProcessErrorException ex) 131 | { 132 | // stdout iterator throws exception when exitcode is not 0. 133 | Console.WriteLine("ERROR, ExitCode: " + ex.ExitCode); 134 | 135 | // ex.ErrorOutput is empty, if you want to use it, buffer yourself. 136 | // Console.WriteLine(string.Join(Environment.NewLine, errorBuffered)); 137 | } 138 | ``` 139 | 140 | Read Binary Data 141 | --- 142 | If stdout is binary data, you can use `StartReadBinaryAsync` to read `byte[]`. 143 | 144 | ```csharp 145 | byte[] bin = await ProcessX.StartReadBinaryAsync($"..."); 146 | ``` 147 | 148 | Change acceptable exit codes 149 | --- 150 | In default, ExitCode is not 0 throws ProcessErrorException. You can change acceptable exit codes globally by `ProcessX.AcceptableExitCodes` property. Default is `[0]`. 151 | 152 | Zx 153 | --- 154 | like the [google/zx](https://github.com/google/zx), you can write shell script in C#. 155 | 156 | ```csharp 157 | // ProcessX and C# 9.0 Top level statement; like google/zx. 158 | 159 | using Zx; 160 | using static Zx.Env; 161 | 162 | // `await string` execute process like shell 163 | await "cat package.json | grep name"; 164 | 165 | // receive result msg of stdout 166 | var branch = await "git branch --show-current"; 167 | await $"dep deploy --branch={branch}"; 168 | 169 | // parallel request (similar as Task.WhenAll) 170 | await new[] 171 | { 172 | "echo 1", 173 | "echo 2", 174 | "echo 3", 175 | }; 176 | 177 | // you can also use cd(chdir) 178 | await "cd ../../"; 179 | 180 | // run with $"" automatically escaped and quoted 181 | var dir = "foo/foo bar"; 182 | await run($"mkdir {dir}"); // mkdir "/foo/foo bar" 183 | 184 | // helper for Console.WriteLine and colorize 185 | log("red log.", ConsoleColor.Red); 186 | using (color(ConsoleColor.Blue)) 187 | { 188 | log("blue log"); 189 | Console.WriteLine("also blue"); 190 | await run($"echo {"blue blue blue"}"); 191 | } 192 | 193 | // helper for web request 194 | var text = await fetchText("http://wttr.in"); 195 | log(text); 196 | 197 | // helper for ReadLine(stdin) 198 | var bear = await question("What kind of bear is best?"); 199 | log($"You answered: {bear}"); 200 | 201 | // run has some variant(run2, runl, withTimeout, withCancellation) 202 | // runl returns string[](runlist -> runl) 203 | var sdks = await runl($"dotnet --list-sdks"); 204 | ``` 205 | 206 | writing shell script in C# has advantage over bash/cmd/PowerShell 207 | 208 | * Static typed 209 | * async/await 210 | * Code formatter 211 | * Clean syntax via C# 212 | * Powerful editor environment(Visual Studio/Code/Rider) 213 | 214 | `Zx.Env` has configure property and utility methods, we recommend to use via `using static Zx.Env;`. 215 | 216 | ```csharp 217 | using Zx; 218 | using static Zx.Env; 219 | 220 | // Env.verbose, write all stdout/stderror log to console. default is true. 221 | verbose = false; 222 | 223 | // Env.useShell, default is true; which invoke process by `cmd/bash "command argment..."`. 224 | useShell = true; 225 | 226 | // Env.shell, default is Windows -> "cmd /c", Linux -> "(which bash) -c";. 227 | shell = "/bin/sh -c"; 228 | 229 | // Env.terminateToken, CancellationToken that triggered by SIGTERM(Ctrl + C). 230 | var token = terminateToken; 231 | 232 | // Env.fetch(string requestUri), request HTTP/1, return is HttpResponseMessage. 233 | var resp = await fetch("http://wttr.in"); 234 | if (resp.IsSuccessStatusCode) 235 | { 236 | Console.WriteLine(await resp.Content.ReadAsStringAsync()); 237 | } 238 | 239 | // Env.fetchText(string requestUri), request HTTP/1, return is string. 240 | var text = await fetchText("http://wttr.in"); 241 | Console.WriteLine(text); 242 | 243 | // Env.sleep(int seconds|TimeSpan timeSpan), wrapper of Task.Delay. 244 | await sleep(5); // wait 5 seconds 245 | 246 | // Env.withTimeout(string command, int seconds|TimeSpan timeSpan), execute process with timeout. Require to use with "$". 247 | await withTimeout($"echo foo", 10); 248 | 249 | // Env.withCancellation(string command, CancellationToken cancellationToken), execute process with cancellation. Require to use with "$". 250 | await withCancellation($"echo foo", terminateToken); 251 | 252 | // Env.run(FormattableString), automatically escaped and quoted. argument string requires to use with "$" 253 | await run($"mkdir {dir}"); 254 | 255 | // Env.run(FormattableString), automatically escaped and quoted. argument string requires to use with "$" 256 | await run($"mkdir {dir}"); 257 | 258 | // Env.runl(FormattableString), returns string[], automatically escaped and quoted. argument string requires to use with "$" 259 | var l1 = runl("dotnet --list-sdks"); 260 | 261 | // Env.process(string command), same as `await string` but returns Task. 262 | var t = process("dotnet info"); 263 | 264 | // Env.processl(string command), returns Task. 265 | var l2 = processl("dotnet --list-sdks"); 266 | 267 | // Env.ignore(Task), ignore ProcessErrorException 268 | await ignore(run($"dotnet noinfo")); 269 | 270 | // ***2 receives tuple of result (StdOut, StdError). 271 | var (stdout, stderror) = run2($""); 272 | var (stdout, stderror) = runl2($""); 273 | var (stdout, stderror) = withTimeout2($""); 274 | var (stdout, stderror) = withCancellation2($""); 275 | var (stdout, stderror) = process2($""); 276 | var (stdout, stderror) = processl2($""); 277 | ``` 278 | 279 | By default (useShell == true), commands are executed through the shell. This means that `dotnet --version` is actually converted to something like `"cmd /c \"dotnet --version\""` during execution. When strings contain spaces, they need to be escaped, but please note that escape handling differs depending on the shell (cmd, bash, pwsh, etc.). If you want to avoid execution through the shell, you can set `Env.useShell = false`, which will result in more intuitive execution. 280 | 281 | ```csharp 282 | using Zx; 283 | using static Zx.Env; 284 | 285 | useShell = false; 286 | await "dotnet --version"; 287 | ``` 288 | 289 | If you want to escape the arguments, you can also use `run($"string")`. 290 | 291 | If you want to more colorize like Chalk on JavaScript, [Cysharp/Kokuban](https://github.com/Cysharp/Kokuban) styler for .NET ConsoleApp will help. 292 | 293 | Reference 294 | --- 295 | `ProcessX.StartAsync` overloads, you can set workingDirectory, environmentVariable, encoding. 296 | 297 | ```csharp 298 | // return ProcessAsyncEnumerable 299 | StartAsync(string command, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 300 | StartAsync(string fileName, string? arguments, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 301 | StartAsync(ProcessStartInfo processStartInfo) 302 | 303 | // return (Process, ProcessAsyncEnumerable, ProcessAsyncEnumerable) 304 | GetDualAsyncEnumerable(string command, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 305 | GetDualAsyncEnumerable(string fileName, string? arguments, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 306 | GetDualAsyncEnumerable(ProcessStartInfo processStartInfo) 307 | 308 | // return Task 309 | StartReadBinaryAsync(string command, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 310 | StartReadBinaryAsync(string fileName, string? arguments, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 311 | StartReadBinaryAsync(ProcessStartInfo processStartInfo) 312 | 313 | // return Task ;get the first result(if empty, throws exception) and wait completed 314 | FirstAsync(CancellationToken cancellationToken = default) 315 | 316 | // return Task ;get the first result(if empty, returns null) and wait completed 317 | FirstOrDefaultAsync(CancellationToken cancellationToken = default) 318 | 319 | // return Task 320 | WaitAsync(CancellationToken cancellationToken = default) 321 | 322 | // return Task 323 | ToTask(CancellationToken cancellationToken = default) 324 | 325 | // return Task 326 | WriteLineAllAsync(CancellationToken cancellationToken = default) 327 | ``` 328 | 329 | Competitor 330 | --- 331 | * [Tyrrrz/CliWrap](https://github.com/Tyrrrz/CliWrap) - Wrapper for command line interfaces. 332 | * [jamesmanning/RunProcessAsTask](https://github.com/jamesmanning/RunProcessAsTask) - Simple wrapper around System.Diagnostics.Process to expose it as a System.Threading.Tasks.Task. 333 | * [mayuki/Chell](https://github.com/mayuki/Chell) Write scripts with the power of C# and .NET. 334 | 335 | License 336 | --- 337 | This library is under the MIT License. 338 | -------------------------------------------------------------------------------- /sandbox/ConsoleApp/ConsoleApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | 9.0 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /sandbox/ConsoleApp/Program.cs: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | // ProcessX and C# 9.0 Top level statement; like google/zx. 5 | 6 | using Cysharp.Diagnostics; 7 | using System.Runtime.CompilerServices; 8 | using Zx; 9 | using static Zx.Env; 10 | 11 | useShell = false; 12 | await "dotnet --version"; 13 | 14 | //// `await string` execute process like shell 15 | ////await "cat package.json | grep name"; 16 | 17 | //var lst = await runl($"dotnet --list-sdks"); 18 | 19 | //// receive result msg of stdout 20 | //var branch = await "git branch --show-current"; 21 | ////await $"dep deploy --branch={branch}"; 22 | 23 | //// parallel request (similar as Task.WhenAll) 24 | //await new[] 25 | //{ 26 | // "echo 1", 27 | // "echo 2", 28 | // "echo 3", 29 | //}; 30 | 31 | //// you can also use cd(chdir) 32 | //await "cd ../../"; 33 | 34 | //// run with $"" automatically escaped and quoted 35 | //var dir = "foo/foo bar"; 36 | //await run($"mkdir {dir}"); // mkdir "/foo/foo bar" 37 | 38 | //// helper for Console.WriteLine and colorize 39 | //log("red log.", System.ConsoleColor.Red); 40 | //using (color(System.ConsoleColor.Blue)) 41 | //{ 42 | // log("blue log"); 43 | // System.Console.WriteLine("also blue"); 44 | // await run($"echo {"blue blue blue"}"); 45 | //} 46 | 47 | //// helper for web request 48 | //var text = await fetchText("http://wttr.in"); 49 | //log(text); 50 | 51 | //// helper for ReadLine(stdin) 52 | //var bear = await question("What kind of bear is best?"); 53 | //log($"You answered: {bear}"); 54 | 55 | 56 | 57 | //await ignore(run($"dotnet noinfo")); 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /sandbox/ReturnMessage/Program.cs: -------------------------------------------------------------------------------- 1 | using ConsoleAppFramework; 2 | using Microsoft.Extensions.Hosting; 3 | using System; 4 | using System.Threading.Tasks; 5 | 6 | namespace ReturnMessage 7 | { 8 | public class Program : ConsoleAppBase 9 | { 10 | static async Task Main(string[] args) 11 | { 12 | await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); 13 | } 14 | 15 | [Command("str")] 16 | public void StringWrite([Option("m")]string echoMesage, [Option("c")]int repeatCount) 17 | { 18 | for (int i = 0; i < repeatCount; i++) 19 | { 20 | Console.WriteLine(echoMesage); 21 | } 22 | } 23 | 24 | [Command("bin")] 25 | public async Task BinaryWrite([Option("s")]int writeSize, [Option("c")]int repeatCount, [Option("w")]int waitMilliseconds) 26 | { 27 | var stdOut = Console.OpenStandardOutput(); 28 | for (int i = 0; i < repeatCount; i++) 29 | { 30 | var bin = new byte[writeSize]; 31 | Array.Fill(bin, unchecked((byte)(i + 1))); 32 | await stdOut.WriteAsync(bin); 33 | 34 | await Task.Delay(waitMilliseconds); 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sandbox/ReturnMessage/ReturnMessage.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ProcessX/ProcessAsyncEnumerable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Threading; 5 | using System.Threading.Channels; 6 | using System.Threading.Tasks; 7 | 8 | namespace Cysharp.Diagnostics 9 | { 10 | public class ProcessAsyncEnumerable : IAsyncEnumerable 11 | { 12 | readonly Process? process; 13 | readonly ChannelReader channel; 14 | 15 | internal ProcessAsyncEnumerable(Process? process, ChannelReader channel) 16 | { 17 | this.process = process; 18 | this.channel = channel; 19 | } 20 | 21 | public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) 22 | { 23 | return new ProcessAsyncEnumerator(process, channel, cancellationToken); 24 | } 25 | 26 | /// 27 | /// Consume all result and wait complete asynchronously. 28 | /// 29 | public async Task WaitAsync(CancellationToken cancellationToken = default) 30 | { 31 | await foreach (var _ in this.WithCancellation(cancellationToken).ConfigureAwait(false)) 32 | { 33 | } 34 | } 35 | 36 | /// 37 | /// Returning first value and wait complete asynchronously. 38 | /// 39 | public async Task FirstAsync(CancellationToken cancellationToken = default) 40 | { 41 | string? data = null; 42 | await foreach (var item in this.WithCancellation(cancellationToken).ConfigureAwait(false)) 43 | { 44 | if (data == null) 45 | { 46 | data = (item ?? ""); 47 | } 48 | } 49 | 50 | if (data == null) 51 | { 52 | throw new InvalidOperationException("Process does not return any data."); 53 | } 54 | else 55 | { 56 | return data; 57 | } 58 | } 59 | 60 | /// 61 | /// Returning first value or null and wait complete asynchronously. 62 | /// 63 | public async Task FirstOrDefaultAsync(CancellationToken cancellationToken = default) 64 | { 65 | string? data = null; 66 | await foreach (var item in this.WithCancellation(cancellationToken).ConfigureAwait(false)) 67 | { 68 | if (data == null) 69 | { 70 | data = (item ?? ""); 71 | } 72 | } 73 | return data; 74 | } 75 | 76 | public async Task ToTask(CancellationToken cancellationToken = default) 77 | { 78 | var list = new List(); 79 | await foreach (var item in this.WithCancellation(cancellationToken).ConfigureAwait(false)) 80 | { 81 | list.Add(item); 82 | } 83 | return list.ToArray(); 84 | } 85 | 86 | /// 87 | /// Write the all received data to console. 88 | /// 89 | public async Task WriteLineAllAsync(CancellationToken cancellationToken = default) 90 | { 91 | await foreach (var item in this.WithCancellation(cancellationToken).ConfigureAwait(false)) 92 | { 93 | Console.WriteLine(item); 94 | } 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /src/ProcessX/ProcessAsyncEnumerator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics; 3 | using System.Threading; 4 | using System.Threading.Channels; 5 | using System.Threading.Tasks; 6 | 7 | namespace Cysharp.Diagnostics 8 | { 9 | class ProcessAsyncEnumerator : IAsyncEnumerator 10 | { 11 | readonly Process? process; 12 | readonly ChannelReader channel; 13 | readonly CancellationToken cancellationToken; 14 | readonly CancellationTokenRegistration cancellationTokenRegistration; 15 | string? current; 16 | bool disposed; 17 | 18 | public ProcessAsyncEnumerator(Process? process, ChannelReader channel, CancellationToken cancellationToken) 19 | { 20 | // process is not null, kill when canceled. 21 | this.process = process; 22 | this.channel = channel; 23 | this.cancellationToken = cancellationToken; 24 | if (cancellationToken.CanBeCanceled) 25 | { 26 | cancellationTokenRegistration = cancellationToken.Register(() => 27 | { 28 | _ = DisposeAsync(); 29 | }); 30 | } 31 | } 32 | 33 | #pragma warning disable CS8603 34 | // when call after MoveNext, current always not null. 35 | public string Current => current; 36 | #pragma warning restore CS8603 37 | 38 | public async ValueTask MoveNextAsync() 39 | { 40 | if (channel.TryRead(out current)) 41 | { 42 | return true; 43 | } 44 | else 45 | { 46 | if (await channel.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) 47 | { 48 | if (channel.TryRead(out current)) 49 | { 50 | return true; 51 | } 52 | } 53 | return false; 54 | } 55 | } 56 | 57 | public ValueTask DisposeAsync() 58 | { 59 | if (!disposed) 60 | { 61 | disposed = true; 62 | try 63 | { 64 | cancellationTokenRegistration.Dispose(); 65 | if (process != null) 66 | { 67 | process.EnableRaisingEvents = false; 68 | if (!process.HasExited) 69 | { 70 | process.Kill(); 71 | } 72 | } 73 | } 74 | finally 75 | { 76 | if (process != null) 77 | { 78 | process.Dispose(); 79 | } 80 | } 81 | } 82 | 83 | return default; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/ProcessX/ProcessErrorException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Cysharp.Diagnostics 4 | { 5 | public class ProcessErrorException : Exception 6 | { 7 | public int ExitCode { get; } 8 | public string[] ErrorOutput { get; } 9 | 10 | public ProcessErrorException(int exitCode, string[] errorOutput) 11 | : base("Process returns error, ExitCode:" + exitCode + Environment.NewLine + string.Join(Environment.NewLine, errorOutput)) 12 | { 13 | this.ExitCode = exitCode; 14 | this.ErrorOutput = errorOutput; 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/ProcessX/ProcessX.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Channels; 9 | using System.Threading.Tasks; 10 | 11 | namespace Cysharp.Diagnostics 12 | { 13 | public static class ProcessX 14 | { 15 | public static IReadOnlyList AcceptableExitCodes { get; set; } = new[] { 0 }; 16 | 17 | static bool IsInvalidExitCode(Process process) 18 | { 19 | return !AcceptableExitCodes.Any(x => x == process.ExitCode); 20 | } 21 | 22 | static (string fileName, string? arguments) ParseCommand(string command) 23 | { 24 | var cmdBegin = command.IndexOf(' '); 25 | if (cmdBegin == -1) 26 | { 27 | return (command, null); 28 | } 29 | else 30 | { 31 | var fileName = command.Substring(0, cmdBegin); 32 | var arguments = command.Substring(cmdBegin + 1, command.Length - (cmdBegin + 1)); 33 | return (fileName, arguments); 34 | } 35 | } 36 | 37 | static Process SetupRedirectableProcess(ref ProcessStartInfo processStartInfo, bool redirectStandardInput) 38 | { 39 | // override setings. 40 | processStartInfo.UseShellExecute = false; 41 | processStartInfo.CreateNoWindow = true; 42 | processStartInfo.ErrorDialog = false; 43 | processStartInfo.RedirectStandardError = true; 44 | processStartInfo.RedirectStandardOutput = true; 45 | processStartInfo.RedirectStandardInput = redirectStandardInput; 46 | 47 | var process = new Process() 48 | { 49 | StartInfo = processStartInfo, 50 | EnableRaisingEvents = true, 51 | }; 52 | 53 | return process; 54 | } 55 | 56 | public static ProcessAsyncEnumerable StartAsync(string command, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 57 | { 58 | var (fileName, arguments) = ParseCommand(command); 59 | return StartAsync(fileName, arguments, workingDirectory, environmentVariable, encoding); 60 | } 61 | 62 | public static ProcessAsyncEnumerable StartAsync(string fileName, string? arguments, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 63 | { 64 | var pi = new ProcessStartInfo() 65 | { 66 | FileName = fileName, 67 | Arguments = arguments, 68 | }; 69 | 70 | if (workingDirectory != null) 71 | { 72 | pi.WorkingDirectory = workingDirectory; 73 | } 74 | 75 | if (environmentVariable != null) 76 | { 77 | foreach (var item in environmentVariable) 78 | { 79 | pi.EnvironmentVariables[item.Key] = item.Value; 80 | } 81 | } 82 | 83 | if (encoding != null) 84 | { 85 | pi.StandardOutputEncoding = encoding; 86 | pi.StandardErrorEncoding = encoding; 87 | } 88 | 89 | return StartAsync(pi); 90 | } 91 | 92 | public static ProcessAsyncEnumerable StartAsync(ProcessStartInfo processStartInfo) 93 | { 94 | var process = SetupRedirectableProcess(ref processStartInfo, false); 95 | 96 | var outputChannel = Channel.CreateUnbounded(new UnboundedChannelOptions 97 | { 98 | SingleReader = true, 99 | SingleWriter = true, 100 | AllowSynchronousContinuations = true 101 | }); 102 | 103 | var errorList = new List(); 104 | 105 | var waitOutputDataCompleted = new TaskCompletionSource(); 106 | 107 | void OnOutputDataReceived(object sender, DataReceivedEventArgs e) 108 | { 109 | if (e.Data != null) 110 | { 111 | outputChannel?.Writer.TryWrite(e.Data); 112 | } 113 | else 114 | { 115 | waitOutputDataCompleted?.TrySetResult(null); 116 | } 117 | } 118 | 119 | process.OutputDataReceived += OnOutputDataReceived; 120 | 121 | var waitErrorDataCompleted = new TaskCompletionSource(); 122 | process.ErrorDataReceived += (sender, e) => 123 | { 124 | if (e.Data != null) 125 | { 126 | lock (errorList) 127 | { 128 | errorList.Add(e.Data); 129 | } 130 | } 131 | else 132 | { 133 | waitErrorDataCompleted.TrySetResult(null); 134 | } 135 | }; 136 | 137 | process.Exited += async (sender, e) => 138 | { 139 | await waitErrorDataCompleted.Task.ConfigureAwait(false); 140 | 141 | if (errorList.Count == 0) 142 | { 143 | await waitOutputDataCompleted.Task.ConfigureAwait(false); 144 | } 145 | else 146 | { 147 | process.OutputDataReceived -= OnOutputDataReceived; 148 | } 149 | 150 | if (IsInvalidExitCode(process)) 151 | { 152 | outputChannel.Writer.TryComplete(new ProcessErrorException(process.ExitCode, errorList.ToArray())); 153 | } 154 | else 155 | { 156 | if (errorList.Count == 0) 157 | { 158 | outputChannel.Writer.TryComplete(); 159 | } 160 | else 161 | { 162 | outputChannel.Writer.TryComplete(new ProcessErrorException(process.ExitCode, errorList.ToArray())); 163 | } 164 | } 165 | }; 166 | 167 | if (!process.Start()) 168 | { 169 | throw new InvalidOperationException("Can't start process. FileName:" + processStartInfo.FileName + ", Arguments:" + processStartInfo.Arguments); 170 | } 171 | 172 | process.BeginOutputReadLine(); 173 | process.BeginErrorReadLine(); 174 | 175 | return new ProcessAsyncEnumerable(process, outputChannel.Reader); 176 | } 177 | 178 | public static (Process Process, ProcessAsyncEnumerable StdOut, ProcessAsyncEnumerable StdError) GetDualAsyncEnumerable(string command, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 179 | { 180 | var (fileName, arguments) = ParseCommand(command); 181 | return GetDualAsyncEnumerable(fileName, arguments, workingDirectory, environmentVariable, encoding); 182 | } 183 | 184 | public static (Process Process, ProcessAsyncEnumerable StdOut, ProcessAsyncEnumerable StdError) GetDualAsyncEnumerable(string fileName, string? arguments, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 185 | { 186 | var pi = new ProcessStartInfo() 187 | { 188 | FileName = fileName, 189 | Arguments = arguments, 190 | }; 191 | 192 | if (workingDirectory != null) 193 | { 194 | pi.WorkingDirectory = workingDirectory; 195 | } 196 | 197 | if (environmentVariable != null) 198 | { 199 | foreach (var item in environmentVariable) 200 | { 201 | pi.EnvironmentVariables.Add(item.Key, item.Value); 202 | } 203 | } 204 | 205 | if (encoding != null) 206 | { 207 | pi.StandardOutputEncoding = encoding; 208 | pi.StandardErrorEncoding = encoding; 209 | } 210 | 211 | return GetDualAsyncEnumerable(pi); 212 | } 213 | 214 | public static (Process Process, ProcessAsyncEnumerable StdOut, ProcessAsyncEnumerable StdError) GetDualAsyncEnumerable(ProcessStartInfo processStartInfo) 215 | { 216 | var process = SetupRedirectableProcess(ref processStartInfo, true); 217 | 218 | var outputChannel = Channel.CreateUnbounded(new UnboundedChannelOptions 219 | { 220 | SingleReader = true, 221 | SingleWriter = true, 222 | AllowSynchronousContinuations = true 223 | }); 224 | 225 | var errorChannel = Channel.CreateUnbounded(new UnboundedChannelOptions 226 | { 227 | SingleReader = true, 228 | SingleWriter = true, 229 | AllowSynchronousContinuations = true 230 | }); 231 | 232 | var waitOutputDataCompleted = new TaskCompletionSource(); 233 | process.OutputDataReceived += (sender, e) => 234 | { 235 | if (e.Data != null) 236 | { 237 | outputChannel.Writer.TryWrite(e.Data); 238 | } 239 | else 240 | { 241 | waitOutputDataCompleted.TrySetResult(null); 242 | } 243 | }; 244 | 245 | var waitErrorDataCompleted = new TaskCompletionSource(); 246 | process.ErrorDataReceived += (sender, e) => 247 | { 248 | if (e.Data != null) 249 | { 250 | errorChannel.Writer.TryWrite(e.Data); 251 | } 252 | else 253 | { 254 | waitErrorDataCompleted.TrySetResult(null); 255 | } 256 | }; 257 | 258 | process.Exited += async (sender, e) => 259 | { 260 | await waitErrorDataCompleted.Task.ConfigureAwait(false); 261 | await waitOutputDataCompleted.Task.ConfigureAwait(false); 262 | 263 | if (IsInvalidExitCode(process)) 264 | { 265 | errorChannel.Writer.TryComplete(); 266 | outputChannel.Writer.TryComplete(new ProcessErrorException(process.ExitCode, Array.Empty())); 267 | } 268 | else 269 | { 270 | errorChannel.Writer.TryComplete(); 271 | outputChannel.Writer.TryComplete(); 272 | } 273 | }; 274 | 275 | if (!process.Start()) 276 | { 277 | throw new InvalidOperationException("Can't start process. FileName:" + processStartInfo.FileName + ", Arguments:" + processStartInfo.Arguments); 278 | } 279 | 280 | process.BeginOutputReadLine(); 281 | process.BeginErrorReadLine(); 282 | 283 | // error itertor does not handle process itself. 284 | return (process, new ProcessAsyncEnumerable(process, outputChannel.Reader), new ProcessAsyncEnumerable(null, errorChannel.Reader)); 285 | } 286 | 287 | // Binary 288 | 289 | public static Task StartReadBinaryAsync(string command, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 290 | { 291 | var (fileName, arguments) = ParseCommand(command); 292 | return StartReadBinaryAsync(fileName, arguments, workingDirectory, environmentVariable, encoding); 293 | } 294 | 295 | public static Task StartReadBinaryAsync(string fileName, string? arguments, string? workingDirectory = null, IDictionary? environmentVariable = null, Encoding? encoding = null) 296 | { 297 | var pi = new ProcessStartInfo() 298 | { 299 | FileName = fileName, 300 | Arguments = arguments, 301 | }; 302 | 303 | if (workingDirectory != null) 304 | { 305 | pi.WorkingDirectory = workingDirectory; 306 | } 307 | 308 | if (environmentVariable != null) 309 | { 310 | foreach (var item in environmentVariable) 311 | { 312 | pi.EnvironmentVariables.Add(item.Key, item.Value); 313 | } 314 | } 315 | 316 | if (encoding != null) 317 | { 318 | pi.StandardOutputEncoding = encoding; 319 | pi.StandardErrorEncoding = encoding; 320 | } 321 | 322 | return StartReadBinaryAsync(pi); 323 | } 324 | 325 | public static Task StartReadBinaryAsync(ProcessStartInfo processStartInfo) 326 | { 327 | var process = SetupRedirectableProcess(ref processStartInfo, false); 328 | 329 | var errorList = new List(); 330 | 331 | var cts = new CancellationTokenSource(); 332 | var resultTask = new TaskCompletionSource(); 333 | var readTask = new TaskCompletionSource(); 334 | 335 | var waitErrorDataCompleted = new TaskCompletionSource(); 336 | process.ErrorDataReceived += (sender, e) => 337 | { 338 | if (e.Data != null) 339 | { 340 | lock (errorList) 341 | { 342 | errorList.Add(e.Data); 343 | } 344 | } 345 | else 346 | { 347 | waitErrorDataCompleted.TrySetResult(null); 348 | } 349 | }; 350 | 351 | process.Exited += async (sender, e) => 352 | { 353 | await waitErrorDataCompleted.Task.ConfigureAwait(false); 354 | 355 | if (errorList.Count == 0 && !IsInvalidExitCode(process)) 356 | { 357 | var resultBin = await readTask.Task.ConfigureAwait(false); 358 | if (resultBin != null) 359 | { 360 | resultTask.TrySetResult(resultBin); 361 | return; 362 | } 363 | } 364 | 365 | cts.Cancel(); 366 | 367 | resultTask.TrySetException(new ProcessErrorException(process.ExitCode, errorList.ToArray())); 368 | }; 369 | 370 | if (!process.Start()) 371 | { 372 | throw new InvalidOperationException("Can't start process. FileName:" + processStartInfo.FileName + ", Arguments:" + processStartInfo.Arguments); 373 | } 374 | 375 | RunAsyncReadFully(process.StandardOutput.BaseStream, readTask, cts.Token); 376 | process.BeginErrorReadLine(); 377 | 378 | return resultTask.Task; 379 | } 380 | 381 | static async void RunAsyncReadFully(Stream stream, TaskCompletionSource completion, CancellationToken cancellationToken) 382 | { 383 | try 384 | { 385 | var ms = new MemoryStream(); 386 | await stream.CopyToAsync(ms, 81920, cancellationToken); 387 | var result = ms.ToArray(); 388 | completion.TrySetResult(result); 389 | } 390 | catch 391 | { 392 | completion.TrySetResult(null); 393 | } 394 | } 395 | } 396 | } -------------------------------------------------------------------------------- /src/ProcessX/ProcessX.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0;netstandard2.1 5 | 8.0 6 | enable 7 | true 8 | true 9 | $(NoWarn);CS1591 10 | 11 | 12 | $(Version) 13 | Cysharp 14 | Cysharp 15 | © Cysharp, Inc. 16 | process;async; 17 | Simplify call external process with the async streams in C# 8.0. 18 | https://github.com/Cysharp/ProcessX 19 | $(PackageProjectUrl) 20 | git 21 | MIT 22 | true 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/ProcessX/Zx/Env.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Diagnostics; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Net.Http; 5 | using System.Runtime.InteropServices; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | namespace Zx 11 | { 12 | public static class Env 13 | { 14 | public static bool verbose { get; set; } = true; 15 | 16 | static string? _shell; 17 | public static string shell 18 | { 19 | get 20 | { 21 | if (_shell == null) 22 | { 23 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 24 | { 25 | _shell = "cmd /c"; 26 | } 27 | else 28 | { 29 | if (Which.TryGetPath("bash", out var bashPath)) 30 | { 31 | _shell = bashPath + " -c"; 32 | } 33 | else 34 | { 35 | throw new InvalidOperationException("shell is not found in PATH, set Env.shell manually."); 36 | } 37 | } 38 | } 39 | return _shell; 40 | } 41 | set 42 | { 43 | _shell = value; 44 | } 45 | } 46 | 47 | public static bool useShell { get; set; } = true; 48 | 49 | static readonly Lazy _terminateTokenSource = new Lazy(() => 50 | { 51 | var source = new CancellationTokenSource(); 52 | Console.CancelKeyPress += (sender, e) => source.Cancel(); 53 | return source; 54 | }); 55 | 56 | public static CancellationToken terminateToken => _terminateTokenSource.Value.Token; 57 | 58 | public static string? workingDirectory { get; set; } 59 | 60 | static readonly Lazy> _envVars = new Lazy>(() => 61 | { 62 | return new Dictionary(); 63 | }); 64 | 65 | public static IDictionary envVars => _envVars.Value; 66 | 67 | public static Task fetch(string requestUri) 68 | { 69 | return new HttpClient().GetAsync(requestUri); 70 | } 71 | 72 | public static Task fetchText(string requestUri) 73 | { 74 | return new HttpClient().GetStringAsync(requestUri); 75 | } 76 | 77 | public static Task fetchBytes(string requestUri) 78 | { 79 | return new HttpClient().GetByteArrayAsync(requestUri); 80 | } 81 | 82 | public static Task sleep(int seconds, CancellationToken cancellationToken = default) 83 | { 84 | return Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken); 85 | } 86 | 87 | public static Task sleep(TimeSpan timeSpan, CancellationToken cancellationToken = default) 88 | { 89 | return Task.Delay(timeSpan, cancellationToken); 90 | } 91 | 92 | public static async Task withTimeout(FormattableString command, int seconds) 93 | { 94 | using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(seconds))) 95 | { 96 | return (await ProcessStartAsync(EscapeFormattableString.Escape(command), cts.Token)).StdOut; 97 | } 98 | } 99 | 100 | public static async Task<(string StdOut, string StdError)> withTimeout2(FormattableString command, int seconds) 101 | { 102 | using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(seconds))) 103 | { 104 | return (await ProcessStartAsync(EscapeFormattableString.Escape(command), cts.Token)); 105 | } 106 | } 107 | 108 | public static async Task withTimeout(FormattableString command, TimeSpan timeSpan) 109 | { 110 | using (var cts = new CancellationTokenSource(timeSpan)) 111 | { 112 | return (await ProcessStartAsync(EscapeFormattableString.Escape(command), cts.Token)).StdOut; 113 | } 114 | } 115 | 116 | public static async Task<(string StdOut, string StdError)> withTimeout2(FormattableString command, TimeSpan timeSpan) 117 | { 118 | using (var cts = new CancellationTokenSource(timeSpan)) 119 | { 120 | return (await ProcessStartAsync(EscapeFormattableString.Escape(command), cts.Token)); 121 | } 122 | } 123 | 124 | public static async Task withCancellation(FormattableString command, CancellationToken cancellationToken) 125 | { 126 | return (await ProcessStartAsync(EscapeFormattableString.Escape(command), cancellationToken)).StdOut; 127 | } 128 | 129 | public static async Task<(string StdOut, string StdError)> withCancellation2(FormattableString command, CancellationToken cancellationToken) 130 | { 131 | return (await ProcessStartAsync(EscapeFormattableString.Escape(command), cancellationToken)); 132 | } 133 | 134 | public static Task run(FormattableString command, CancellationToken cancellationToken = default) 135 | { 136 | return process(EscapeFormattableString.Escape(command), cancellationToken); 137 | } 138 | 139 | public static Task<(string StdOut, string StdError)> run2(FormattableString command, CancellationToken cancellationToken = default) 140 | { 141 | return process2(EscapeFormattableString.Escape(command), cancellationToken); 142 | } 143 | 144 | public static Task runl(FormattableString command, CancellationToken cancellationToken = default) 145 | { 146 | return processl(EscapeFormattableString.Escape(command), cancellationToken); 147 | } 148 | 149 | public static Task<(string[] StdOut, string[] StdError)> runl2(FormattableString command, CancellationToken cancellationToken = default) 150 | { 151 | return processl2(EscapeFormattableString.Escape(command), cancellationToken); 152 | } 153 | 154 | public static string escape(FormattableString command) 155 | { 156 | return EscapeFormattableString.Escape(command); 157 | } 158 | 159 | public static async Task process(string command, CancellationToken cancellationToken = default) 160 | { 161 | return (await ProcessStartAsync(command, cancellationToken)).StdOut; 162 | } 163 | 164 | public static async Task<(string StdOut, string StdError)> process2(string command, CancellationToken cancellationToken = default) 165 | { 166 | return await ProcessStartAsync(command, cancellationToken); 167 | } 168 | 169 | public static async Task processl(string command, CancellationToken cancellationToken = default) 170 | { 171 | return (await ProcessStartListAsync(command, cancellationToken)).StdOut; 172 | } 173 | 174 | public static async Task<(string[] StdOut, string[] StdError)> processl2(string command, CancellationToken cancellationToken = default) 175 | { 176 | return await ProcessStartListAsync(command, cancellationToken); 177 | } 178 | 179 | public static async Task ignore(Task task) 180 | { 181 | try 182 | { 183 | return await task.ConfigureAwait(false); 184 | } 185 | catch (ProcessErrorException) 186 | { 187 | return default(T)!; 188 | } 189 | } 190 | 191 | public static async Task question(string question) 192 | { 193 | Console.WriteLine(question); 194 | var str = await Console.In.ReadLineAsync(); 195 | return str ?? ""; 196 | } 197 | 198 | public static void log(object? value, ConsoleColor? color = default) 199 | { 200 | if (color != null) 201 | { 202 | using (Env.color(color.Value)) 203 | { 204 | Console.WriteLine(value); 205 | } 206 | } 207 | else 208 | { 209 | Console.WriteLine(value); 210 | } 211 | } 212 | 213 | public static IDisposable color(ConsoleColor color) 214 | { 215 | var current = Console.ForegroundColor; 216 | Console.ForegroundColor = color; 217 | return new ColorScope(current); 218 | } 219 | 220 | static async Task<(string StdOut, string StdError)> ProcessStartAsync(string command, CancellationToken cancellationToken, bool forceSilcent = false) 221 | { 222 | var cmd = useShell 223 | ? shell + " \"" + command + "\"" 224 | : command; 225 | var sbOut = new StringBuilder(); 226 | var sbError = new StringBuilder(); 227 | 228 | var (_, stdout, stderror) = Cysharp.Diagnostics.ProcessX.GetDualAsyncEnumerable(cmd, workingDirectory, envVars); 229 | 230 | var runStdout = Task.Run(async () => 231 | { 232 | var isFirst = true; 233 | await foreach (var item in stdout.WithCancellation(cancellationToken).ConfigureAwait(false)) 234 | { 235 | if (!isFirst) 236 | { 237 | sbOut.AppendLine(); 238 | } 239 | else 240 | { 241 | isFirst = false; 242 | } 243 | 244 | sbOut.Append(item); 245 | 246 | if (verbose && !forceSilcent) 247 | { 248 | Console.WriteLine(item); 249 | } 250 | } 251 | }); 252 | 253 | var runStdError = Task.Run(async () => 254 | { 255 | var isFirst = true; 256 | await foreach (var item in stderror.WithCancellation(cancellationToken).ConfigureAwait(false)) 257 | { 258 | if (!isFirst) 259 | { 260 | sbOut.AppendLine(); 261 | } 262 | else 263 | { 264 | isFirst = false; 265 | } 266 | sbError.Append(item); 267 | 268 | if (verbose && !forceSilcent) 269 | { 270 | Console.WriteLine(item); 271 | } 272 | } 273 | }); 274 | 275 | await Task.WhenAll(runStdout, runStdError).ConfigureAwait(false); 276 | 277 | return (sbOut.ToString(), sbError.ToString()); 278 | } 279 | 280 | static async Task<(string[] StdOut, string[] StdError)> ProcessStartListAsync(string command, CancellationToken cancellationToken, bool forceSilcent = false) 281 | { 282 | var cmd = shell + " \"" + command + "\""; 283 | var sbOut = new List(); 284 | var sbError = new List(); 285 | 286 | var (_, stdout, stderror) = Cysharp.Diagnostics.ProcessX.GetDualAsyncEnumerable(cmd, workingDirectory, envVars); 287 | 288 | var runStdout = Task.Run(async () => 289 | { 290 | await foreach (var item in stdout.WithCancellation(cancellationToken).ConfigureAwait(false)) 291 | { 292 | sbOut.Add(item); 293 | 294 | if (verbose && !forceSilcent) 295 | { 296 | Console.WriteLine(item); 297 | } 298 | } 299 | }); 300 | 301 | var runStdError = Task.Run(async () => 302 | { 303 | await foreach (var item in stderror.WithCancellation(cancellationToken).ConfigureAwait(false)) 304 | { 305 | sbError.Add(item); 306 | 307 | if (verbose && !forceSilcent) 308 | { 309 | Console.WriteLine(item); 310 | } 311 | } 312 | }); 313 | 314 | await Task.WhenAll(runStdout, runStdError).ConfigureAwait(false); 315 | 316 | return (sbOut.ToArray(), sbError.ToArray()); 317 | } 318 | 319 | class ColorScope : IDisposable 320 | { 321 | readonly ConsoleColor color; 322 | 323 | public ColorScope(ConsoleColor color) 324 | { 325 | this.color = color; 326 | } 327 | 328 | public void Dispose() 329 | { 330 | Console.ForegroundColor = color; 331 | } 332 | } 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/ProcessX/Zx/EscapeFormattableString.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Zx 4 | { 5 | internal static class EscapeFormattableString 6 | { 7 | internal static string Escape(FormattableString formattableString) 8 | { 9 | // already escaped. 10 | if (formattableString.Format.StartsWith("\"") && formattableString.Format.EndsWith("\"")) 11 | { 12 | return formattableString.ToString(); 13 | } 14 | 15 | // GetArguments returns inner object[] field, it can modify. 16 | var args = formattableString.GetArguments(); 17 | 18 | for (int i = 0; i < args.Length; i++) 19 | { 20 | if (args[i] is string) 21 | { 22 | args[i] = "\"" + args[i].ToString().Replace("\"", "\\\"") + "\""; // poor logic 23 | } 24 | } 25 | 26 | return formattableString.ToString(); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/ProcessX/Zx/StringProcessExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Runtime.CompilerServices; 5 | using System.Text.RegularExpressions; 6 | using System.Threading.Tasks; 7 | 8 | namespace Zx 9 | { 10 | public static class StringProcessExtensions 11 | { 12 | public static TaskAwaiter GetAwaiter(this string command) 13 | { 14 | return ProcessCommand(command).GetAwaiter(); 15 | } 16 | 17 | public static TaskAwaiter GetAwaiter(this string[] commands) 18 | { 19 | async Task ProcessCommands() 20 | { 21 | await Task.WhenAll(commands.Select(ProcessCommand)); 22 | } 23 | 24 | return ProcessCommands().GetAwaiter(); 25 | } 26 | 27 | static Task ProcessCommand(string command) 28 | { 29 | if (TryChangeDirectory(command)) 30 | { 31 | return Task.FromResult(""); 32 | } 33 | 34 | return Env.process(command); 35 | } 36 | 37 | static bool TryChangeDirectory(string command) 38 | { 39 | if (command.StartsWith("cd ") || command.StartsWith("chdir ")) 40 | { 41 | var path = Regex.Replace(command, "^cd|^chdir", "").Trim(); 42 | Environment.CurrentDirectory = Path.Combine(Environment.CurrentDirectory, path); 43 | return true; 44 | } 45 | 46 | return false; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/ProcessX/Zx/Which.cs: -------------------------------------------------------------------------------- 1 | // This class is borrowd from https://github.com/mayuki/Chell 2 | 3 | using System; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | 8 | namespace Zx 9 | { 10 | internal static class Which 11 | { 12 | public static bool TryGetPath(string commandName, out string matchedPath) 13 | { 14 | var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); 15 | var paths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(isWindows ? ';' : ':'); 16 | var pathExts = Array.Empty(); 17 | 18 | if (isWindows) 19 | { 20 | paths = paths.Prepend(Environment.CurrentDirectory).ToArray(); 21 | pathExts = (Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC").Split(';'); 22 | } 23 | 24 | foreach (var path in paths) 25 | { 26 | // /path/to/foo.ext 27 | foreach (var ext in pathExts) 28 | { 29 | var fullPath = Path.Combine(path, $"{commandName}{ext}"); 30 | if (File.Exists(fullPath)) 31 | { 32 | matchedPath = fullPath; 33 | return true; 34 | } 35 | } 36 | 37 | // /path/to/foo 38 | { 39 | var fullPath = Path.Combine(path, commandName); 40 | if (File.Exists(fullPath)) 41 | { 42 | matchedPath = fullPath; 43 | return true; 44 | } 45 | } 46 | } 47 | 48 | matchedPath = string.Empty; 49 | return false; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /tests/ProcessX.Tests/ProcessX.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/ProcessX.Tests/UnitTest1.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Xunit; 3 | 4 | namespace ProcessX.Tests 5 | { 6 | public class UnitTest1 7 | { 8 | [Fact] 9 | public void Test1() 10 | { 11 | 12 | } 13 | } 14 | } 15 | --------------------------------------------------------------------------------