├── .cspell.json ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── linters │ ├── .ecrc │ ├── .jscpd.json │ ├── .markdown-lint.yml │ └── .yaml-lint.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── infer-sharp.yml │ ├── lint.yml │ ├── release.yml │ └── spell-check.yml ├── .gitignore ├── .globalconfig ├── .markdownlint.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── Directory.Build.props ├── LICENSE ├── README.md ├── SimpleExec.sln ├── SimpleExec ├── Command.cs ├── ExitCodeException.cs ├── ExitCodeReadException.cs ├── ProcessExtensions.cs ├── ProcessStartInfo.cs ├── PublicAPI.Shipped.txt ├── PublicAPI.Unshipped.txt ├── SimpleExec.csproj └── packages.lock.json ├── SimpleExecTester ├── Program.cs ├── SimpleExecTester.csproj └── packages.lock.json ├── SimpleExecTests ├── .editorconfig ├── CancellingCommands.cs ├── ConfiguringEnvironments.cs ├── EchoingCommands.cs ├── ExitCodes.cs ├── Infra │ ├── Capture.cs │ ├── Tester.cs │ ├── WindowsFactAttribute.cs │ └── WindowsTheoryAttribute.cs ├── ReadingCommands.cs ├── RunningCommands.cs ├── SimpleExecTests.csproj ├── hello-world.cmd └── packages.lock.json ├── assets ├── simple-exec.png └── simple-exec.svg ├── build ├── build.cmd └── global.json /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en-GB", 4 | "overrides": [ 5 | { 6 | "filename": "{*.cmd,*.cs,*.csproj,*.props,*.yml,build}", 7 | "language": "en-US" 8 | }, 9 | { 10 | "filename": "{.cspell.json,*.svg,PublicAPI.*.txt}", 11 | "enabled": false 12 | } 13 | ], 14 | "words": [ 15 | "ADAMRALPH", 16 | "analyzer", 17 | "analyzers", 18 | "autobuild", 19 | "codeql", 20 | "cref", 21 | "Cresnar", 22 | "devcontainers", 23 | "errorlevel", 24 | "inheritdoc", 25 | "langword", 26 | "markdownlint", 27 | "MyGet", 28 | "netstandard", 29 | "Newtonsoft", 30 | "NOLOGO", 31 | "NuGet", 32 | "nupkg", 33 | "paramref", 34 | "PATHEXT", 35 | "pipefail", 36 | "refs", 37 | "Robocopy", 38 | "SHFMT", 39 | "simpleexec", 40 | "simpleexectester", 41 | "xunit" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/dotnet:8.0 2 | 3 | RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install lts/* && npm install -g cspell 2>&1" 4 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "C# (.NET)", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "settings": { 7 | "terminal.integrated.shell.linux": "/bin/bash" 8 | }, 9 | "extensions": [ 10 | "alefragnani.Bookmarks", 11 | "DavidAnson.vscode-markdownlint", 12 | "editorconfig.editorconfig", 13 | "jeff-hykin.code-eol", 14 | "bierner.markdown-preview-github-styles", 15 | "ms-dotnettools.csharp", 16 | "redhat.vscode-yaml", 17 | "streetsidesoftware.code-spell-checker" 18 | ], 19 | "remoteUser": "vscode" 20 | } 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = crlf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.cs] 12 | csharp_style_expression_bodied_accessors = true 13 | csharp_style_expression_bodied_constructors = true 14 | csharp_style_expression_bodied_indexers = true 15 | csharp_style_expression_bodied_lambdas = true 16 | csharp_style_expression_bodied_local_functions = true 17 | csharp_style_expression_bodied_methods = true 18 | csharp_style_expression_bodied_operators = true 19 | csharp_style_expression_bodied_properties = true 20 | csharp_style_namespace_declarations = file_scoped 21 | csharp_style_var_elsewhere = true 22 | csharp_style_var_for_built_in_types = true 23 | csharp_style_var_when_type_is_apparent = true 24 | dotnet_style_qualification_for_event = true 25 | dotnet_style_qualification_for_field = true 26 | dotnet_style_qualification_for_method = true 27 | dotnet_style_qualification_for_property = true 28 | file_header_template = unset 29 | indent_size = 4 30 | 31 | [*.sln] 32 | indent_size = 1 33 | indent_style = tab 34 | 35 | [build] 36 | end_of_line = lf 37 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "devcontainers" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "docker" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | - package-ecosystem: "dotnet-sdk" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "daily" 19 | - package-ecosystem: "nuget" 20 | directory: "/" 21 | schedule: 22 | interval: "daily" 23 | groups: 24 | xunit: 25 | patterns: 26 | - "xunit" 27 | - "xunit.*" 28 | -------------------------------------------------------------------------------- /.github/linters/.ecrc: -------------------------------------------------------------------------------- 1 | { 2 | "Exclude": [ 3 | "\\.git" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/linters/.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "*Tests/**" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/linters/.markdown-lint.yml: -------------------------------------------------------------------------------- 1 | extends: ../../.markdownlint.yml 2 | -------------------------------------------------------------------------------- /.github/linters/.yaml-lint.yml: -------------------------------------------------------------------------------- 1 | extends: default 2 | rules: 3 | document-start: 4 | present: false 5 | line-length: 6 | max: 160 7 | new-lines: 8 | type: dos 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [main, release-*] 5 | pull_request: 6 | permissions: read-all 7 | env: 8 | DOTNET_NOLOGO: true 9 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 10 | MINVERBUILDMETADATA: build.${{ github.run_id }}.${{ github.run_attempt}} 11 | jobs: 12 | ci: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | job: 17 | - name: macos 18 | os: macos-14 19 | - name: ubuntu 20 | os: ubuntu-24.04 21 | - name: windows 22 | os: windows-2022 23 | name: ${{ matrix.job.name }} 24 | runs-on: ${{ matrix.job.os }} 25 | steps: 26 | - uses: actions/setup-dotnet@v4.3.1 27 | with: 28 | dotnet-version: | 29 | 8.0.407 30 | 9.0.202 31 | - uses: actions/checkout@v4.2.2 32 | with: 33 | fetch-depth: 0 34 | filter: tree:0 35 | - run: ./build --logger GitHubActions 36 | - if: matrix.job.name == 'ubuntu' 37 | uses: actions/upload-artifact@v4.6.2 38 | with: 39 | name: NuGet packages 40 | path: ./**/*.nupkg 41 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main, release-*] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main, release-*] 20 | schedule: 21 | - cron: '43 4 * * 0' 22 | 23 | permissions: read-all 24 | 25 | jobs: 26 | analyze: 27 | name: Analyze 28 | runs-on: ubuntu-latest 29 | permissions: 30 | actions: read 31 | contents: read 32 | security-events: write 33 | 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | language: [ 'csharp' ] 38 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 39 | # Learn more: 40 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v4.2.2 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v3 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 https://git.io/JvXDl 63 | 64 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 65 | # and modify them (or add more) to build your code if your project 66 | # uses a compiled language 67 | 68 | #- run: | 69 | # make bootstrap 70 | # make release 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v3 74 | -------------------------------------------------------------------------------- /.github/workflows/infer-sharp.yml: -------------------------------------------------------------------------------- 1 | name: infer-sharp 2 | on: 3 | push: 4 | branches: [main, release-*] 5 | pull_request: 6 | permissions: 7 | security-events: write 8 | jobs: 9 | infer-sharp: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/setup-dotnet@v4.3.1 13 | with: 14 | dotnet-version: '9.0.202' 15 | - uses: actions/checkout@v4.2.2 16 | - run: dotnet build 17 | - run: ls -al 18 | - run: pwd 19 | - uses: microsoft/infersharpaction@v1.5 20 | with: 21 | binary-path: './SimpleExec' 22 | - run: cat infer-out/report.txt 23 | - uses: actions/upload-artifact@v4.6.2 24 | with: 25 | name: InferSharp reports 26 | path: infer-out/report.* 27 | - uses: github/codeql-action/upload-sarif@v3.23.0 28 | with: 29 | sarif_file: infer-out/report.sarif 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: [main, release-*] 5 | pull_request: 6 | permissions: read-all 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: read 13 | statuses: write 14 | steps: 15 | - uses: actions/checkout@v4.2.2 16 | with: 17 | fetch-depth: 0 18 | filter: tree:0 19 | - uses: super-linter/super-linter@v7.3.0 20 | env: 21 | DEFAULT_BRANCH: main 22 | FILTER_REGEX_EXCLUDE: \bcodeql-analysis\.yml$|\bLICENSE$ 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | VALIDATE_JSON_PRETTIER: false 25 | VALIDATE_MARKDOWN_PRETTIER: false 26 | VALIDATE_SHELL_SHFMT: false 27 | VALIDATE_YAML_PRETTIER: false 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: ["*.*.*"] 5 | permissions: read-all 6 | env: 7 | DOTNET_NOLOGO: true 8 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 9 | MINVERBUILDMETADATA: build.${{ github.run_id }}.${{ github.run_attempt}} 10 | jobs: 11 | release: 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - uses: actions/setup-dotnet@v4.3.1 15 | with: 16 | dotnet-version: 9.0.202 17 | - uses: actions/checkout@v4.2.2 18 | - run: dotnet build --configuration Release --nologo 19 | - name: push 20 | env: 21 | SOURCE: ${{ secrets.NUGET_PUSH_SOURCE }} 22 | API_KEY: ${{ secrets.NUGET_PUSH_API_KEY }} 23 | if: env.SOURCE != '' || env.API_KEY != '' 24 | run: dotnet nuget push ./**/*.nupkg --source ${{ env.SOURCE }} --api-key ${{ env.API_KEY }} 25 | -------------------------------------------------------------------------------- /.github/workflows/spell-check.yml: -------------------------------------------------------------------------------- 1 | name: spell-check 2 | on: 3 | push: 4 | branches: [main, release-*] 5 | pull_request: 6 | permissions: read-all 7 | jobs: 8 | spell-check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4.2.2 12 | - run: npx cspell@5.2.1 "**/*" 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vs/ 3 | bin/ 4 | obj/ 5 | .DS_Store 6 | *.user 7 | -------------------------------------------------------------------------------- /.globalconfig: -------------------------------------------------------------------------------- 1 | is_global = true 2 | 3 | dotnet_analyzer_diagnostic.severity = warning 4 | 5 | # CA1014: Mark assemblies with CLSCompliantAttribute 6 | dotnet_diagnostic.CA1014.severity = none 7 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | MD013: false 2 | MD024: 3 | siblings_only: true 4 | MD026: 5 | punctuation: ".,;:!。,;:!" 6 | MD033: 7 | allowed_elements: 8 | - img 9 | - sub 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdown.extension.toc.levels": "2..2" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 12.1.0 4 | 5 | ### Enhancements 6 | 7 | - [#681: Target .NET 9 and remove .NET 6 target](https://github.com/adamralph/simple-exec/pull/681) 8 | 9 | ## 12.0.0 10 | 11 | ### Enhancements 12 | 13 | - [#521: **[BREAKING]** Target .NET 6 and .NET 7 and remove .NET Standard 2.1 target](https://github.com/adamralph/simple-exec/pull/521) 14 | - [#578: include build metadata in informational version](https://github.com/adamralph/simple-exec/pull/578) 15 | - [#584: avoid race between cancellation and exit](https://github.com/adamralph/simple-exec/pull/584) 16 | - [#585: Target .NET 8](https://github.com/adamralph/simple-exec/pull/585) 17 | - [#600: **[BREAKING]** Cancel child processes by default](https://github.com/adamralph/simple-exec/issues/600) 18 | 19 | ## 11.0.0 20 | 21 | ### Fixed bugs 22 | 23 | - [#491: **[BREAKING]** PATHEXT file extension order is not respected on Windows](https://github.com/adamralph/simple-exec/issues/491) 24 | 25 | ## 10.0.0 26 | 27 | ### Enhancements 28 | 29 | - [#441: **[BREAKING]** Automatically resolve .cmd and .bat paths on Windows](https://github.com/adamralph/simple-exec/issues/441) 30 | 31 | ## 9.1.0 32 | 33 | ### Enhancements 34 | 35 | - [#430: Overloads with argument lists](https://github.com/adamralph/simple-exec/issues/430) 36 | 37 | ### Fixed bugs 38 | 39 | - [#433: XML docs state that echo is to standard error (stderr) instead of standard output (stdout)](https://github.com/adamralph/simple-exec/pull/433) 40 | 41 | ## 9.0.0 42 | 43 | ### Enhancements 44 | 45 | - [#351: **[BREAKING]** New API for version 9](https://github.com/adamralph/simple-exec/issues/351) 46 | - [#352: Echo to standard out instead of standard error](https://github.com/adamralph/simple-exec/issues/352) 47 | - [#375: **[BREAKING]** Target .NET Standard 2.1 and remove .NET Standard 2.0 target](https://github.com/adamralph/simple-exec/pull/375) 48 | - [#390: Nullable annotations](https://github.com/adamralph/simple-exec/issues/390) 49 | 50 | ### Other 51 | 52 | - [#312: **[BREAKING]** Remove NonZeroExitCodeException](https://github.com/adamralph/simple-exec/issues/312) 53 | 54 | ## 8.0.0 55 | 56 | ### Enhancements 57 | 58 | - [#313: **[BREAKING]** Custom exit code handling](https://github.com/adamralph/simple-exec/issues/313) 59 | - [#318: **[BREAKING]** log exception thrown while killing process during cancellation](https://github.com/adamralph/simple-exec/pull/318) 60 | - [#319: echo asynchronously when possible](https://github.com/adamralph/simple-exec/pull/319) 61 | - [#324: add README.md to package](https://github.com/adamralph/simple-exec/pull/324) 62 | 63 | ### Fixed bugs 64 | 65 | - [#320: source stepping doesn't work](https://github.com/adamralph/simple-exec/pull/320) 66 | 67 | ## 7.0.0 68 | 69 | ### Enhancements 70 | 71 | - [#254: **[BREAKING]** throw ArgumentException when command name missing](https://github.com/adamralph/simple-exec/pull/254) 72 | - [#279: **[BREAKING]** Support preferred encoding when reading commands](https://github.com/adamralph/simple-exec/pull/279) 73 | 74 | ### Fixed bugs 75 | 76 | - [#253: **[BREAKING]** Reading a non-existent command throws an InvalidOperationException](https://github.com/adamralph/simple-exec/pull/253) 77 | - [#281: Missing ConfigureAwait(false) in ReadAsync](https://github.com/adamralph/simple-exec/pull/281) 78 | 79 | ## 6.4.0 80 | 81 | ### Enhancements 82 | 83 | - [#230: Pass CancellationToken To RunAsync and ReadAsync](https://github.com/adamralph/simple-exec/issues/230) 84 | - [#249: add remark about Read deadlocks](https://github.com/adamralph/simple-exec/pull/249) 85 | 86 | ## 6.3.0 87 | 88 | ### Enhancements 89 | 90 | - [#183: upgrade to SourceLink 1.0.0](https://github.com/adamralph/simple-exec/pull/183) 91 | - [#222: Add support for the CreateNoWindow option](https://github.com/adamralph/simple-exec/issues/222) 92 | 93 | ## 6.2.0 94 | 95 | ### Enhancements 96 | 97 | - [#174: Support passing environment variables to processes](https://github.com/adamralph/simple-exec/issues/174) 98 | 99 | ## 6.1.0 100 | 101 | ### Enhancements 102 | 103 | - [#137: Update SourceLink to 1.0.0-beta2-19367-01](https://github.com/adamralph/simple-exec/issues/137) 104 | - [#140: Prefix messages in stderr](https://github.com/adamralph/simple-exec/issues/140) 105 | - [#143: Add XML documentation file to package 🤦‍♂](https://github.com/adamralph/simple-exec/issues/143) 106 | 107 | ## 6.0.0 108 | 109 | ### Enhancements 110 | 111 | - [#128: **[BREAKING]** replace cmd.exe usage with optional params for windows](https://github.com/adamralph/simple-exec/pull/128) 112 | 113 | ## 5.0.1 114 | 115 | ### Fixed bugs 116 | 117 | - [#112: The filename, directory name, or volume label syntax is incorrect.](https://github.com/adamralph/simple-exec/issues/112) 118 | 119 | ## 5.0.0 120 | 121 | ### Enhancements 122 | 123 | - [#98: upgrade Source Link to 1.0.0-beta2-18618-05](https://github.com/adamralph/simple-exec/pull/98) 124 | - [#100: **[BREAKING]** Use cmd.exe on Windows](https://github.com/adamralph/simple-exec/issues/100) 125 | - [#111: Handle large output when reading](https://github.com/adamralph/simple-exec/pull/111) 126 | 127 | ### Other 128 | 129 | - [#104: **[BREAKING]** Remove deprecated CommandException](https://github.com/adamralph/simple-exec/issues/104) 130 | 131 | ## 4.2.0 132 | 133 | ### Enhancements 134 | 135 | - [#84: Throw a NonZeroExitCodeException, with an ExitCode property](https://github.com/adamralph/simple-exec/issues/84) 136 | 137 | ## 4.1.0 138 | 139 | ### Enhancements 140 | 141 | - [#81: Throw CommandException instead of Exception](https://github.com/adamralph/simple-exec/issues/81) 142 | 143 | ## 4.0.0 144 | 145 | ### Enhancements 146 | 147 | - [#57: Add API documentation](https://github.com/adamralph/simple-exec/issues/57) 148 | - [#63: **[BREAKING]** Switch to optional parameters](https://github.com/adamralph/simple-exec/issues/63) 149 | - [#64: **[BREAKING]** Echo to stderr instead of stdout](https://github.com/adamralph/simple-exec/issues/64) 150 | 151 | ## 3.0.0 152 | 153 | ### Enhancements 154 | 155 | - [#48: **[BREAKING]** Don't redirect stderr](https://github.com/adamralph/simple-exec/issues/48) 156 | 157 | ## 2.3.0 158 | 159 | ### Enhancements 160 | 161 | - [#36: Echo suppression](https://github.com/adamralph/simple-exec/pull/36) 162 | - [#44: Source stepping](https://github.com/adamralph/simple-exec/pull/44) 163 | 164 | ### Fixed bugs 165 | 166 | - [#45: ConfigureAwait(false) is missing in a few places](https://github.com/adamralph/simple-exec/pull/45) 167 | 168 | ## 2.2.0 169 | 170 | ### Enhancements 171 | 172 | - [#31: Target .NET Standard 2.0](https://github.com/adamralph/simple-exec/pull/31) 173 | 174 | ## 2.1.0 175 | 176 | ### Enhancements 177 | 178 | - [#18: Read() method, capturing stdout](https://github.com/adamralph/simple-exec/issues/18) 179 | 180 | ## 2.0.0 181 | 182 | ### Enhancements 183 | 184 | - [#4: build using release config](https://github.com/adamralph/simple-exec/pull/4) 185 | - [#5: capture stderr in exception message](https://github.com/adamralph/simple-exec/pull/5) 186 | - [#7: Async API](https://github.com/adamralph/simple-exec/issues/7) 187 | - [#9: **[BREAKING]** Simpler API](https://github.com/adamralph/simple-exec/issues/9) 188 | - [#10: Echo the command in the console (stdout)](https://github.com/adamralph/simple-exec/issues/10) 189 | 190 | ## 1.0.0 191 | 192 | ### Enhancements 193 | 194 | - [#1: Run commands with args and optional working directory](https://github.com/adamralph/simple-exec/issues/1) 195 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | All 5 | embedded 6 | true 7 | true 8 | true 9 | enable 10 | 11 | EnableGenerateDocumentationFile 12 | enable 13 | true 14 | true 15 | 16 | 17 | 18 | true 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleExec 2 | 3 | ![SimpleExec](https://raw.githubusercontent.com/adamralph/simple-exec/092a28b5dcd011725cef7f3b207fcb9a056b651d/assets/simple-exec.svg) 4 | 5 | _[![NuGet version](https://img.shields.io/nuget/v/SimpleExec.svg?style=flat)](https://www.nuget.org/packages/SimpleExec)_ 6 | 7 | _[![CI](https://github.com/adamralph/simple-exec/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/adamralph/simple-exec/actions/workflows/ci.yml?query=branch%3Amain)_ 8 | _[![CodeQL analysis](https://github.com/adamralph/simple-exec/actions/workflows/codeql-analysis.yml/badge.svg?branch=main)](https://github.com/adamralph/simple-exec/actions/workflows/codeql-analysis.yml?query=branch%3Amain)_ 9 | _[![InferSharp](https://github.com/adamralph/simple-exec/actions/workflows/infer-sharp.yml/badge.svg?branch=main)](https://github.com/adamralph/simple-exec/actions/workflows/infer-sharp.yml?query=branch%3Amain)_ 10 | _[![Lint](https://github.com/adamralph/simple-exec/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/adamralph/simple-exec/actions/workflows/lint.yml?query=branch%3Amain)_ 11 | _[![Spell check](https://github.com/adamralph/simple-exec/actions/workflows/spell-check.yml/badge.svg?branch=main)](https://github.com/adamralph/simple-exec/actions/workflows/spell-check.yml?query=branch%3Amain)_ 12 | 13 | SimpleExec is a [.NET library](https://www.nuget.org/packages/SimpleExec) that runs external commands. It wraps [`System.Diagnostics.Process`](https://apisof.net/catalog/System.Diagnostics.Process) to make things easier. 14 | 15 | SimpleExec intentionally does not invoke the system shell. 16 | 17 | Platform support: [.NET 8.0 and later](https://dot.net). 18 | 19 | - [Quick start](#quick-start) 20 | - [Run](#run) 21 | - [Read](#read) 22 | - [Other optional arguments](#other-optional-arguments) 23 | - [Exceptions](#exceptions) 24 | 25 | ## Quick start 26 | 27 | ```c# 28 | using static SimpleExec.Command; 29 | ``` 30 | 31 | ```c# 32 | Run("foo", "arg1 arg2"); 33 | ``` 34 | 35 | ## Run 36 | 37 | ```c# 38 | Run("foo"); 39 | Run("foo", "arg1 arg2"); 40 | Run("foo", new[] { "arg1", "arg2" }); 41 | 42 | await RunAsync("foo"); 43 | await RunAsync("foo", "arg1 arg2"); 44 | await RunAsync("foo", new[] { "arg1", "arg2" }); 45 | ``` 46 | 47 | By default, the command is echoed to standard output (stdout) for visibility. 48 | 49 | ## Read 50 | 51 | ```c# 52 | var (standardOutput1, standardError1) = await ReadAsync("foo"); 53 | var (standardOutput2, standardError2) = await ReadAsync("foo", "arg1 arg2"); 54 | var (standardOutput3, standardError3) = await ReadAsync("foo", new[] { "arg1", "arg2" }); 55 | ``` 56 | 57 | ## Other optional arguments 58 | 59 | ```c# 60 | string workingDirectory = "", 61 | bool noEcho = false, 62 | string? echoPrefix = null, 63 | Action>? configureEnvironment = null, 64 | bool createNoWindow = false, 65 | Encoding? encoding = null, 66 | Func? handleExitCode = null, 67 | string? standardInput = null, 68 | bool cancellationIgnoresProcessTree = false, 69 | CancellationToken cancellationToken = default, 70 | ``` 71 | 72 | ## Exceptions 73 | 74 | If the command has a non-zero exit code, an `ExitCodeException` is thrown with an `int` `ExitCode` property and a message in the form of: 75 | 76 | ```c# 77 | $"The process exited with code {ExitCode}." 78 | ``` 79 | 80 | In the case of `ReadAsync`, an `ExitCodeReadException` is thrown, which inherits from `ExitCodeException`, and has `string` `Out` and `Error` properties, representing standard out (stdout) and standard error (stderr), and a message in the form of: 81 | 82 | ```c# 83 | $@"The process exited with code {ExitCode}. 84 | 85 | Standard Output: 86 | 87 | {Out} 88 | 89 | Standard Error: 90 | 91 | {Error}" 92 | ``` 93 | 94 | ### Overriding default exit code handling 95 | 96 | Most programs return a zero exit code when they succeed and a non-zero exit code fail. However, there are some programs which return a non-zero exit code when they succeed. For example, [Robocopy](https://ss64.com/nt/robocopy.html) returns an exit code less than 8 when it succeeds and 8 or greater when a failure occurs. 97 | 98 | The throwing of exceptions for specific non-zero exit codes may be suppressed by passing a delegate to `handleExitCode` which returns `true` when it has handled the exit code and default exit code handling should be suppressed, and returns `false` otherwise. 99 | 100 | For example, when running Robocopy, exception throwing should be suppressed for an exit code less than 8: 101 | 102 | ```c# 103 | Run("ROBOCOPY", "from to", handleExitCode: code => code < 8); 104 | ``` 105 | 106 | Note that it may be useful to record the exit code. For example: 107 | 108 | ```c# 109 | var exitCode = 0; 110 | Run("ROBOCOPY", "from to", handleExitCode: code => (exitCode = code) < 8); 111 | 112 | // see https://ss64.com/nt/robocopy-exit.html 113 | var oneOrMoreFilesCopied = exitCode & 1; 114 | var extraFilesOrDirectoriesDetected = exitCode & 2; 115 | var misMatchedFilesOrDirectoriesDetected = exitCode & 4; 116 | ``` 117 | 118 | --- 119 | 120 | [Run](https://thenounproject.com/term/target/975371) by [Gregor Cresnar](https://thenounproject.com/grega.cresnar/) from [the Noun Project](https://thenounproject.com/). 121 | -------------------------------------------------------------------------------- /SimpleExec.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.28922.388 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleExec", "SimpleExec\SimpleExec.csproj", "{C987B855-9D1C-4E56-B841-2E968E45DEBA}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleExecTests", "SimpleExecTests\SimpleExecTests.csproj", "{6FB1F0CF-FA6C-4868-846E-9481F4692A68}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleExecTester", "SimpleExecTester\SimpleExecTester.csproj", "{65DE7849-D516-4FB5-865D-A6E6B2E5BA3D}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {C987B855-9D1C-4E56-B841-2E968E45DEBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {C987B855-9D1C-4E56-B841-2E968E45DEBA}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {C987B855-9D1C-4E56-B841-2E968E45DEBA}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {C987B855-9D1C-4E56-B841-2E968E45DEBA}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {6FB1F0CF-FA6C-4868-846E-9481F4692A68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {6FB1F0CF-FA6C-4868-846E-9481F4692A68}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {6FB1F0CF-FA6C-4868-846E-9481F4692A68}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {6FB1F0CF-FA6C-4868-846E-9481F4692A68}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {65DE7849-D516-4FB5-865D-A6E6B2E5BA3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {65DE7849-D516-4FB5-865D-A6E6B2E5BA3D}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {65DE7849-D516-4FB5-865D-A6E6B2E5BA3D}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {65DE7849-D516-4FB5-865D-A6E6B2E5BA3D}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {8AD4E16A-FB92-4D52-B09E-8F676325E879} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /SimpleExec/Command.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Reflection; 3 | using System.Runtime.InteropServices; 4 | using System.Text; 5 | 6 | namespace SimpleExec; 7 | 8 | /// 9 | /// Contains methods for running commands and reading standard output (stdout). 10 | /// 11 | public static class Command 12 | { 13 | private static readonly Action> defaultAction = _ => { }; 14 | private static readonly string defaultEchoPrefix = Assembly.GetEntryAssembly()?.GetName().Name ?? "SimpleExec"; 15 | 16 | /// 17 | /// Runs a command without redirecting standard output (stdout) and standard error (stderr) and without writing to standard input (stdin). 18 | /// By default, the command line is echoed to standard output (stdout). 19 | /// 20 | /// The name of the command. This can be a path to an executable file. 21 | /// The arguments to pass to the command. 22 | /// The working directory in which to run the command. 23 | /// Whether or not to echo the resulting command line and working directory (if specified) to standard output (stdout). 24 | /// The prefix to use when echoing the command line and working directory (if specified) to standard output (stdout). 25 | /// An action which configures environment variables for the command. 26 | /// Whether to run the command in a new window. 27 | /// 28 | /// A delegate which accepts an representing exit code of the command and 29 | /// returns when it has handled the exit code and default exit code handling should be suppressed, and 30 | /// returns otherwise. 31 | /// 32 | /// 33 | /// Whether to ignore the process tree when cancelling the command. 34 | /// If set to true, when the command is cancelled, any child processes created by the command 35 | /// are left running after the command is cancelled. 36 | /// 37 | /// A to observe while waiting for the command to exit. 38 | /// The command exited with non-zero exit code. 39 | /// 40 | /// By default, the resulting command line and the working directory (if specified) are echoed to standard output (stdout). 41 | /// To suppress this behavior, provide the parameter with a value of true. 42 | /// 43 | public static void Run( 44 | string name, 45 | string args = "", 46 | string workingDirectory = "", 47 | bool noEcho = false, 48 | string? echoPrefix = null, 49 | Action>? configureEnvironment = null, 50 | bool createNoWindow = false, 51 | Func? handleExitCode = null, 52 | bool cancellationIgnoresProcessTree = false, 53 | CancellationToken cancellationToken = default) => 54 | ProcessStartInfo 55 | .Create( 56 | Resolve(Validate(name)), 57 | args, 58 | [], 59 | workingDirectory, 60 | false, 61 | configureEnvironment ?? defaultAction, 62 | createNoWindow) 63 | .Run(noEcho, echoPrefix ?? defaultEchoPrefix, handleExitCode, cancellationIgnoresProcessTree, cancellationToken); 64 | 65 | /// 66 | /// Runs a command without redirecting standard output (stdout) and standard error (stderr) and without writing to standard input (stdin). 67 | /// By default, the command line is echoed to standard output (stdout). 68 | /// 69 | /// The name of the command. This can be a path to an executable file. 70 | /// 71 | /// The arguments to pass to the command. 72 | /// As with , the strings don't need to be escaped. 73 | /// 74 | /// The working directory in which to run the command. 75 | /// Whether or not to echo the resulting command name, arguments, and working directory (if specified) to standard output (stdout). 76 | /// The prefix to use when echoing the command name, arguments, and working directory (if specified) to standard output (stdout). 77 | /// An action which configures environment variables for the command. 78 | /// Whether to run the command in a new window. 79 | /// 80 | /// A delegate which accepts an representing exit code of the command and 81 | /// returns when it has handled the exit code and default exit code handling should be suppressed, and 82 | /// returns otherwise. 83 | /// 84 | /// 85 | /// Whether to ignore the process tree when cancelling the command. 86 | /// If set to true, when the command is cancelled, any child processes created by the command 87 | /// are left running after the command is cancelled. 88 | /// 89 | /// A to observe while waiting for the command to exit. 90 | /// The command exited with non-zero exit code. 91 | public static void Run( 92 | string name, 93 | IEnumerable args, 94 | string workingDirectory = "", 95 | bool noEcho = false, 96 | string? echoPrefix = null, 97 | Action>? configureEnvironment = null, 98 | bool createNoWindow = false, 99 | Func? handleExitCode = null, 100 | bool cancellationIgnoresProcessTree = false, 101 | CancellationToken cancellationToken = default) => 102 | ProcessStartInfo 103 | .Create( 104 | Resolve(Validate(name)), 105 | "", 106 | args ?? throw new ArgumentNullException(nameof(args)), 107 | workingDirectory, 108 | false, 109 | configureEnvironment ?? defaultAction, 110 | createNoWindow) 111 | .Run(noEcho, echoPrefix ?? defaultEchoPrefix, handleExitCode, cancellationIgnoresProcessTree, cancellationToken); 112 | 113 | private static void Run( 114 | this System.Diagnostics.ProcessStartInfo startInfo, 115 | bool noEcho, 116 | string echoPrefix, 117 | Func? handleExitCode, 118 | bool cancellationIgnoresProcessTree, 119 | CancellationToken cancellationToken) 120 | { 121 | using var process = new Process(); 122 | process.StartInfo = startInfo; 123 | 124 | process.Run(noEcho, echoPrefix, cancellationIgnoresProcessTree, cancellationToken); 125 | 126 | if (!(handleExitCode?.Invoke(process.ExitCode) ?? false) && process.ExitCode != 0) 127 | { 128 | throw new ExitCodeException(process.ExitCode); 129 | } 130 | } 131 | 132 | /// 133 | /// Runs a command asynchronously without redirecting standard output (stdout) and standard error (stderr) and without writing to standard input (stdin). 134 | /// By default, the command line is echoed to standard output (stdout). 135 | /// 136 | /// The name of the command. This can be a path to an executable file. 137 | /// The arguments to pass to the command. 138 | /// The working directory in which to run the command. 139 | /// Whether or not to echo the resulting command line and working directory (if specified) to standard output (stdout). 140 | /// The prefix to use when echoing the command line and working directory (if specified) to standard output (stdout). 141 | /// An action which configures environment variables for the command. 142 | /// Whether to run the command in a new window. 143 | /// 144 | /// A delegate which accepts an representing exit code of the command and 145 | /// returns when it has handled the exit code and default exit code handling should be suppressed, and 146 | /// returns otherwise. 147 | /// 148 | /// 149 | /// Whether to ignore the process tree when cancelling the command. 150 | /// If set to true, when the command is cancelled, any child processes created by the command 151 | /// are left running after the command is cancelled. 152 | /// 153 | /// A to observe while waiting for the command to exit. 154 | /// A that represents the asynchronous running of the command. 155 | /// The command exited with non-zero exit code. 156 | /// 157 | /// By default, the resulting command line and the working directory (if specified) are echoed to standard output (stdout). 158 | /// To suppress this behavior, provide the parameter with a value of true. 159 | /// 160 | public static Task RunAsync( 161 | string name, 162 | string args = "", 163 | string workingDirectory = "", 164 | bool noEcho = false, 165 | string? echoPrefix = null, 166 | Action>? configureEnvironment = null, 167 | bool createNoWindow = false, 168 | Func? handleExitCode = null, 169 | bool cancellationIgnoresProcessTree = false, 170 | CancellationToken cancellationToken = default) => 171 | ProcessStartInfo 172 | .Create( 173 | Resolve(Validate(name)), 174 | args, 175 | [], 176 | workingDirectory, 177 | false, 178 | configureEnvironment ?? defaultAction, 179 | createNoWindow) 180 | .RunAsync(noEcho, echoPrefix ?? defaultEchoPrefix, handleExitCode, cancellationIgnoresProcessTree, cancellationToken); 181 | 182 | /// 183 | /// Runs a command asynchronously without redirecting standard output (stdout) and standard error (stderr) and without writing to standard input (stdin). 184 | /// By default, the command line is echoed to standard output (stdout). 185 | /// 186 | /// The name of the command. This can be a path to an executable file. 187 | /// 188 | /// The arguments to pass to the command. 189 | /// As with , the strings don't need to be escaped. 190 | /// 191 | /// The working directory in which to run the command. 192 | /// Whether or not to echo the resulting command name, arguments, and working directory (if specified) to standard output (stdout). 193 | /// The prefix to use when echoing the command name, arguments, and working directory (if specified) to standard output (stdout). 194 | /// An action which configures environment variables for the command. 195 | /// Whether to run the command in a new window. 196 | /// 197 | /// A delegate which accepts an representing exit code of the command and 198 | /// returns when it has handled the exit code and default exit code handling should be suppressed, and 199 | /// returns otherwise. 200 | /// 201 | /// 202 | /// Whether to ignore the process tree when cancelling the command. 203 | /// If set to true, when the command is cancelled, any child processes created by the command 204 | /// are left running after the command is cancelled. 205 | /// 206 | /// A to observe while waiting for the command to exit. 207 | /// A that represents the asynchronous running of the command. 208 | /// The command exited with non-zero exit code. 209 | public static Task RunAsync( 210 | string name, 211 | IEnumerable args, 212 | string workingDirectory = "", 213 | bool noEcho = false, 214 | string? echoPrefix = null, 215 | Action>? configureEnvironment = null, 216 | bool createNoWindow = false, 217 | Func? handleExitCode = null, 218 | bool cancellationIgnoresProcessTree = false, 219 | CancellationToken cancellationToken = default) => 220 | ProcessStartInfo 221 | .Create( 222 | Resolve(Validate(name)), 223 | "", 224 | args ?? throw new ArgumentNullException(nameof(args)), 225 | workingDirectory, 226 | false, 227 | configureEnvironment ?? defaultAction, 228 | createNoWindow) 229 | .RunAsync(noEcho, echoPrefix ?? defaultEchoPrefix, handleExitCode, cancellationIgnoresProcessTree, cancellationToken); 230 | 231 | private static async Task RunAsync( 232 | this System.Diagnostics.ProcessStartInfo startInfo, 233 | bool noEcho, 234 | string echoPrefix, 235 | Func? handleExitCode, 236 | bool cancellationIgnoresProcessTree, 237 | CancellationToken cancellationToken) 238 | { 239 | using var process = new Process(); 240 | process.StartInfo = startInfo; 241 | 242 | await process.RunAsync(noEcho, echoPrefix, cancellationIgnoresProcessTree, cancellationToken).ConfigureAwait(false); 243 | 244 | if (!(handleExitCode?.Invoke(process.ExitCode) ?? false) && process.ExitCode != 0) 245 | { 246 | throw new ExitCodeException(process.ExitCode); 247 | } 248 | } 249 | 250 | /// 251 | /// Runs a command and reads standard output (stdout) and standard error (stderr) and optionally writes to standard input (stdin). 252 | /// 253 | /// The name of the command. This can be a path to an executable file. 254 | /// The arguments to pass to the command. 255 | /// The working directory in which to run the command. 256 | /// An action which configures environment variables for the command. 257 | /// The preferred for standard output (stdout) and standard output (stdout). 258 | /// 259 | /// A delegate which accepts an representing exit code of the command and 260 | /// returns when it has handled the exit code and default exit code handling should be suppressed, and 261 | /// returns otherwise. 262 | /// 263 | /// The contents of standard input (stdin). 264 | /// 265 | /// Whether to ignore the process tree when cancelling the command. 266 | /// If set to true, when the command is cancelled, any child processes created by the command 267 | /// are left running after the command is cancelled. 268 | /// 269 | /// A to observe while waiting for the command to exit. 270 | /// 271 | /// A representing the asynchronous running of the command and reading of standard output (stdout) and standard error (stderr). 272 | /// The task result is a representing the contents of standard output (stdout) and standard error (stderr). 273 | /// 274 | /// 275 | /// The command exited with non-zero exit code. The exception contains the contents of standard output (stdout) and standard error (stderr). 276 | /// 277 | public static Task<(string StandardOutput, string StandardError)> ReadAsync( 278 | string name, 279 | string args = "", 280 | string workingDirectory = "", 281 | Action>? configureEnvironment = null, 282 | Encoding? encoding = null, 283 | Func? handleExitCode = null, 284 | string? standardInput = null, 285 | bool cancellationIgnoresProcessTree = false, 286 | CancellationToken cancellationToken = default) => 287 | ProcessStartInfo 288 | .Create( 289 | Resolve(Validate(name)), 290 | args, 291 | [], 292 | workingDirectory, 293 | true, 294 | configureEnvironment ?? defaultAction, 295 | true, 296 | encoding) 297 | .ReadAsync( 298 | handleExitCode, 299 | standardInput, 300 | cancellationIgnoresProcessTree, 301 | cancellationToken); 302 | 303 | /// 304 | /// Runs a command and reads standard output (stdout) and standard error (stderr) and optionally writes to standard input (stdin). 305 | /// 306 | /// The name of the command. This can be a path to an executable file. 307 | /// 308 | /// The arguments to pass to the command. 309 | /// As with , the strings don't need to be escaped. 310 | /// 311 | /// The working directory in which to run the command. 312 | /// An action which configures environment variables for the command. 313 | /// The preferred for standard output (stdout) and standard error (stderr). 314 | /// 315 | /// A delegate which accepts an representing exit code of the command and 316 | /// returns when it has handled the exit code and default exit code handling should be suppressed, and 317 | /// returns otherwise. 318 | /// 319 | /// The contents of standard input (stdin). 320 | /// 321 | /// Whether to ignore the process tree when cancelling the command. 322 | /// If set to true, when the command is cancelled, any child processes created by the command 323 | /// are left running after the command is cancelled. 324 | /// 325 | /// A to observe while waiting for the command to exit. 326 | /// 327 | /// A representing the asynchronous running of the command and reading of standard output (stdout) and standard error (stderr). 328 | /// The task result is a representing the contents of standard output (stdout) and standard error (stderr). 329 | /// 330 | /// 331 | /// The command exited with non-zero exit code. The exception contains the contents of standard output (stdout) and standard error (stderr). 332 | /// 333 | public static Task<(string StandardOutput, string StandardError)> ReadAsync( 334 | string name, 335 | IEnumerable args, 336 | string workingDirectory = "", 337 | Action>? configureEnvironment = null, 338 | Encoding? encoding = null, 339 | Func? handleExitCode = null, 340 | string? standardInput = null, 341 | bool cancellationIgnoresProcessTree = false, 342 | CancellationToken cancellationToken = default) => 343 | ProcessStartInfo 344 | .Create( 345 | Resolve(Validate(name)), 346 | "", 347 | args ?? throw new ArgumentNullException(nameof(args)), 348 | workingDirectory, 349 | true, 350 | configureEnvironment ?? defaultAction, 351 | true, 352 | encoding) 353 | .ReadAsync( 354 | handleExitCode, 355 | standardInput, 356 | cancellationIgnoresProcessTree, 357 | cancellationToken); 358 | 359 | private static async Task<(string StandardOutput, string StandardError)> ReadAsync( 360 | this System.Diagnostics.ProcessStartInfo startInfo, 361 | Func? handleExitCode, 362 | string? standardInput, 363 | bool cancellationIgnoresProcessTree, 364 | CancellationToken cancellationToken) 365 | { 366 | using var process = new Process(); 367 | process.StartInfo = startInfo; 368 | 369 | var runProcess = process.RunAsync(true, "", cancellationIgnoresProcessTree, cancellationToken); 370 | 371 | Task readOutput; 372 | Task readError; 373 | 374 | try 375 | { 376 | await process.StandardInput.WriteAsync(standardInput).ConfigureAwait(false); 377 | process.StandardInput.Close(); 378 | 379 | readOutput = process.StandardOutput.ReadToEndAsync(cancellationToken); 380 | readError = process.StandardError.ReadToEndAsync(cancellationToken); 381 | } 382 | catch (Exception) 383 | { 384 | await runProcess.ConfigureAwait(false); 385 | throw; 386 | } 387 | 388 | await Task.WhenAll(runProcess, readOutput, readError).ConfigureAwait(false); 389 | 390 | #pragma warning disable CA1849 // Call async methods when in an async method 391 | var output = readOutput.Result; 392 | var error = readError.Result; 393 | #pragma warning restore CA1849 // Call async methods when in an async method 394 | 395 | return (handleExitCode?.Invoke(process.ExitCode) ?? false) || process.ExitCode == 0 396 | ? (output, error) 397 | : throw new ExitCodeReadException(process.ExitCode, output, error); 398 | } 399 | 400 | private static string Validate(string name) => 401 | string.IsNullOrWhiteSpace(name) 402 | ? throw new ArgumentException("The command name is missing.", nameof(name)) 403 | : name; 404 | 405 | private static string Resolve(string name) 406 | { 407 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || Path.IsPathRooted(name)) 408 | { 409 | return name; 410 | } 411 | 412 | var extension = Path.GetExtension(name); 413 | if (!string.IsNullOrEmpty(extension) && extension != ".cmd" && extension != ".bat") 414 | { 415 | return name; 416 | } 417 | 418 | var pathExt = Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.BAT;.CMD"; 419 | 420 | var windowsExecutableExtensions = pathExt.Split(';') 421 | .Select(ext => ext.TrimStart('.')) 422 | .Where(ext => 423 | string.Equals(ext, "exe", StringComparison.OrdinalIgnoreCase) || 424 | string.Equals(ext, "bat", StringComparison.OrdinalIgnoreCase) || 425 | string.Equals(ext, "cmd", StringComparison.OrdinalIgnoreCase)); 426 | 427 | var searchFileNames = string.IsNullOrEmpty(extension) 428 | ? windowsExecutableExtensions.Select(ex => Path.ChangeExtension(name, ex)).ToList() 429 | : [name]; 430 | 431 | var path = GetSearchDirectories().SelectMany(_ => searchFileNames, Path.Combine) 432 | .FirstOrDefault(File.Exists); 433 | 434 | return path == null || Path.GetExtension(path) == ".exe" ? name : path; 435 | } 436 | 437 | // see https://github.com/dotnet/runtime/blob/14304eb31eea134db58870a6d87312231b1e02b6/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs#L703-L726 438 | private static IEnumerable GetSearchDirectories() 439 | { 440 | var currentProcessPath = Process.GetCurrentProcess().MainModule?.FileName; 441 | if (!string.IsNullOrEmpty(currentProcessPath)) 442 | { 443 | var currentProcessDirectory = Path.GetDirectoryName(currentProcessPath); 444 | if (!string.IsNullOrEmpty(currentProcessDirectory)) 445 | { 446 | yield return currentProcessDirectory; 447 | } 448 | } 449 | 450 | yield return Directory.GetCurrentDirectory(); 451 | 452 | var path = Environment.GetEnvironmentVariable("PATH"); 453 | if (string.IsNullOrEmpty(path)) 454 | { 455 | yield break; 456 | } 457 | 458 | foreach (var directory in path.Split(Path.PathSeparator)) 459 | { 460 | yield return directory; 461 | } 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /SimpleExec/ExitCodeException.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleExec; 2 | 3 | /// 4 | /// The command exited with an unexpected exit code. 5 | /// 6 | /// The exit code of the command. 7 | #pragma warning disable CA1032 // Implement standard exception constructors 8 | public class ExitCodeException(int exitCode) : Exception 9 | #pragma warning restore CA1032 // Implement standard exception constructors 10 | { 11 | /// 12 | /// Gets the exit code of the command. 13 | /// 14 | public int ExitCode { get; } = exitCode; 15 | 16 | /// 17 | public override string Message => $"The command exited with code {this.ExitCode}."; 18 | } 19 | -------------------------------------------------------------------------------- /SimpleExec/ExitCodeReadException.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleExec; 2 | 3 | /// 4 | /// The command being read exited with an unexpected exit code. 5 | /// 6 | #pragma warning disable CA1032 // Implement standard exception constructors 7 | public class ExitCodeReadException : ExitCodeException 8 | #pragma warning restore CA1032 // Implement standard exception constructors 9 | { 10 | private static readonly string twoNewLines = $"{Environment.NewLine}{Environment.NewLine}"; 11 | 12 | /// 13 | /// Constructs an instance of a . 14 | /// 15 | /// The exit code of the command. 16 | /// The contents of standard output (stdout). 17 | /// The contents of standard error (stderr). 18 | public ExitCodeReadException(int exitCode, string standardOutput, string standardError) : base(exitCode) => (this.StandardOutput, this.StandardError) = (standardOutput, standardError); 19 | 20 | /// 21 | /// Gets the contents of standard output (stdout). 22 | /// 23 | public string StandardOutput { get; } 24 | 25 | /// 26 | /// Gets the contents of standard error (stderr). 27 | /// 28 | public string StandardError { get; } 29 | 30 | /// 31 | public override string Message => 32 | $"{base.Message}{twoNewLines}Standard output (stdout):{twoNewLines}{this.StandardOutput}{twoNewLines}Standard error (stderr):{twoNewLines}{this.StandardError}"; 33 | } 34 | -------------------------------------------------------------------------------- /SimpleExec/ProcessExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Globalization; 3 | using System.Text; 4 | 5 | namespace SimpleExec; 6 | 7 | internal static class ProcessExtensions 8 | { 9 | public static void Run(this Process process, bool noEcho, string echoPrefix, bool cancellationIgnoresProcessTree, CancellationToken cancellationToken) 10 | { 11 | var cancelled = 0L; 12 | 13 | if (!noEcho) 14 | { 15 | Console.Out.Write(process.StartInfo.GetEchoLines(echoPrefix)); 16 | } 17 | 18 | _ = process.Start(); 19 | 20 | using var register = cancellationToken.Register( 21 | () => 22 | { 23 | if (process.TryKill(cancellationIgnoresProcessTree)) 24 | { 25 | _ = Interlocked.Increment(ref cancelled); 26 | } 27 | }, 28 | useSynchronizationContext: false); 29 | 30 | process.WaitForExit(); 31 | 32 | if (Interlocked.Read(ref cancelled) == 1) 33 | { 34 | cancellationToken.ThrowIfCancellationRequested(); 35 | } 36 | } 37 | 38 | public static async Task RunAsync(this Process process, bool noEcho, string echoPrefix, bool cancellationIgnoresProcessTree, CancellationToken cancellationToken) 39 | { 40 | using var sync = new SemaphoreSlim(1, 1); 41 | var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); 42 | 43 | process.EnableRaisingEvents = true; 44 | process.Exited += (_, _) => sync.Run(() => tcs.Task.Status != TaskStatus.Canceled, () => _ = tcs.TrySetResult()); 45 | 46 | if (!noEcho) 47 | { 48 | await Console.Out.WriteAsync(process.StartInfo.GetEchoLines(echoPrefix)).ConfigureAwait(false); 49 | } 50 | 51 | _ = process.Start(); 52 | 53 | await using var register = cancellationToken.Register( 54 | () => sync.Run( 55 | () => tcs.Task.Status != TaskStatus.RanToCompletion, 56 | () => 57 | { 58 | if (process.TryKill(cancellationIgnoresProcessTree)) 59 | { 60 | _ = tcs.TrySetCanceled(cancellationToken); 61 | } 62 | }), 63 | useSynchronizationContext: false).ConfigureAwait(false); 64 | 65 | await tcs.Task.ConfigureAwait(false); 66 | } 67 | 68 | private static string GetEchoLines(this System.Diagnostics.ProcessStartInfo info, string echoPrefix) 69 | { 70 | var builder = new StringBuilder(); 71 | 72 | if (!string.IsNullOrEmpty(info.WorkingDirectory)) 73 | { 74 | _ = builder.AppendLine(CultureInfo.InvariantCulture, $"{echoPrefix}: Working directory: {info.WorkingDirectory}"); 75 | } 76 | 77 | if (info.ArgumentList.Count > 0) 78 | { 79 | _ = builder.AppendLine(CultureInfo.InvariantCulture, $"{echoPrefix}: {info.FileName}"); 80 | 81 | foreach (var arg in info.ArgumentList) 82 | { 83 | _ = builder.AppendLine(CultureInfo.InvariantCulture, $"{echoPrefix}: {arg}"); 84 | } 85 | } 86 | else 87 | { 88 | _ = builder.AppendLine(CultureInfo.InvariantCulture, $"{echoPrefix}: {info.FileName}{(string.IsNullOrEmpty(info.Arguments) ? "" : $" {info.Arguments}")}"); 89 | } 90 | 91 | return builder.ToString(); 92 | } 93 | 94 | private static bool TryKill(this Process process, bool ignoreProcessTree) 95 | { 96 | // exceptions may be thrown for all kinds of reasons 97 | // and the _same exception_ may be thrown for all kinds of reasons 98 | // System.Diagnostics.Process is "fine" 99 | try 100 | { 101 | process.Kill(!ignoreProcessTree); 102 | } 103 | #pragma warning disable CA1031 // Do not catch general exception types 104 | catch (Exception) 105 | #pragma warning restore CA1031 // Do not catch general exception types 106 | { 107 | return false; 108 | } 109 | 110 | return true; 111 | } 112 | 113 | private static void Run(this SemaphoreSlim sync, Func doubleCheckPredicate, Action action) 114 | { 115 | if (!doubleCheckPredicate()) 116 | { 117 | return; 118 | } 119 | 120 | try 121 | { 122 | sync.Wait(); 123 | } 124 | catch (ObjectDisposedException) 125 | { 126 | return; 127 | } 128 | 129 | try 130 | { 131 | if (doubleCheckPredicate()) 132 | { 133 | action(); 134 | } 135 | } 136 | finally 137 | { 138 | try 139 | { 140 | _ = sync.Release(); 141 | } 142 | catch (ObjectDisposedException) 143 | { 144 | } 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /SimpleExec/ProcessStartInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace SimpleExec; 4 | 5 | internal static class ProcessStartInfo 6 | { 7 | public static System.Diagnostics.ProcessStartInfo Create( 8 | string name, 9 | string args, 10 | IEnumerable argList, 11 | string workingDirectory, 12 | bool redirectStandardStreams, 13 | Action> configureEnvironment, 14 | bool createNoWindow, 15 | Encoding? encoding = null) 16 | { 17 | var startInfo = new System.Diagnostics.ProcessStartInfo 18 | { 19 | FileName = name, 20 | Arguments = args, 21 | WorkingDirectory = workingDirectory, 22 | UseShellExecute = false, 23 | RedirectStandardError = redirectStandardStreams, 24 | RedirectStandardInput = redirectStandardStreams, 25 | RedirectStandardOutput = redirectStandardStreams, 26 | CreateNoWindow = createNoWindow, 27 | StandardErrorEncoding = encoding, 28 | StandardOutputEncoding = encoding, 29 | }; 30 | 31 | foreach (var arg in argList) 32 | { 33 | startInfo.ArgumentList.Add(arg); 34 | } 35 | 36 | configureEnvironment(startInfo.Environment); 37 | 38 | return startInfo; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /SimpleExec/PublicAPI.Shipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | override SimpleExec.ExitCodeException.Message.get -> string! 3 | override SimpleExec.ExitCodeReadException.Message.get -> string! 4 | SimpleExec.Command 5 | SimpleExec.ExitCodeException 6 | SimpleExec.ExitCodeException.ExitCode.get -> int 7 | SimpleExec.ExitCodeException.ExitCodeException(int exitCode) -> void 8 | SimpleExec.ExitCodeReadException 9 | SimpleExec.ExitCodeReadException.ExitCodeReadException(int exitCode, string! standardOutput, string! standardError) -> void 10 | SimpleExec.ExitCodeReadException.StandardError.get -> string! 11 | SimpleExec.ExitCodeReadException.StandardOutput.get -> string! 12 | static SimpleExec.Command.ReadAsync(string! name, string! args = "", string! workingDirectory = "", System.Action!>? configureEnvironment = null, System.Text.Encoding? encoding = null, System.Func? handleExitCode = null, string? standardInput = null, bool cancellationIgnoresProcessTree = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(string! StandardOutput, string! StandardError)>! 13 | static SimpleExec.Command.ReadAsync(string! name, System.Collections.Generic.IEnumerable! args, string! workingDirectory = "", System.Action!>? configureEnvironment = null, System.Text.Encoding? encoding = null, System.Func? handleExitCode = null, string? standardInput = null, bool cancellationIgnoresProcessTree = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(string! StandardOutput, string! StandardError)>! 14 | static SimpleExec.Command.Run(string! name, string! args = "", string! workingDirectory = "", bool noEcho = false, string? echoPrefix = null, System.Action!>? configureEnvironment = null, bool createNoWindow = false, System.Func? handleExitCode = null, bool cancellationIgnoresProcessTree = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void 15 | static SimpleExec.Command.Run(string! name, System.Collections.Generic.IEnumerable! args, string! workingDirectory = "", bool noEcho = false, string? echoPrefix = null, System.Action!>? configureEnvironment = null, bool createNoWindow = false, System.Func? handleExitCode = null, bool cancellationIgnoresProcessTree = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void 16 | static SimpleExec.Command.RunAsync(string! name, string! args = "", string! workingDirectory = "", bool noEcho = false, string? echoPrefix = null, System.Action!>? configureEnvironment = null, bool createNoWindow = false, System.Func? handleExitCode = null, bool cancellationIgnoresProcessTree = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! 17 | static SimpleExec.Command.RunAsync(string! name, System.Collections.Generic.IEnumerable! args, string! workingDirectory = "", bool noEcho = false, string? echoPrefix = null, System.Action!>? configureEnvironment = null, bool createNoWindow = false, System.Func? handleExitCode = null, bool cancellationIgnoresProcessTree = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! 18 | -------------------------------------------------------------------------------- /SimpleExec/PublicAPI.Unshipped.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamralph/simple-exec/2a7887a58cb11c8fec234f152ce2c5dcc8542a66/SimpleExec/PublicAPI.Unshipped.txt -------------------------------------------------------------------------------- /SimpleExec/SimpleExec.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Runs external commands. 5 | true 6 | true 7 | 9.0 8 | simple-exec.png 9 | Apache-2.0 10 | https://github.com/adamralph/simple-exec 11 | README.md 12 | https://github.com/adamralph/simple-exec/blob/main/CHANGELOG.md 13 | true 14 | net8.0;net9.0 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /SimpleExec/packages.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": { 4 | "net8.0": { 5 | "Microsoft.CodeAnalysis.PublicApiAnalyzers": { 6 | "type": "Direct", 7 | "requested": "[3.3.4, )", 8 | "resolved": "3.3.4", 9 | "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" 10 | }, 11 | "MinVer": { 12 | "type": "Direct", 13 | "requested": "[6.1.0-beta.1, )", 14 | "resolved": "6.1.0-beta.1", 15 | "contentHash": "7G+74w2V7Cd9wy6JmUuRsDY/lskO9smQNlp9rF6BJr70RTQb6odJ3n6Oau23/eBuvQjI6vy4bVbQPZvDFey01g==" 16 | } 17 | }, 18 | "net9.0": { 19 | "Microsoft.CodeAnalysis.PublicApiAnalyzers": { 20 | "type": "Direct", 21 | "requested": "[3.3.4, )", 22 | "resolved": "3.3.4", 23 | "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" 24 | }, 25 | "MinVer": { 26 | "type": "Direct", 27 | "requested": "[6.1.0-beta.1, )", 28 | "resolved": "6.1.0-beta.1", 29 | "contentHash": "7G+74w2V7Cd9wy6JmUuRsDY/lskO9smQNlp9rF6BJr70RTQb6odJ3n6Oau23/eBuvQjI6vy4bVbQPZvDFey01g==" 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /SimpleExecTester/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace SimpleExecTester; 4 | 5 | internal static class Program 6 | { 7 | public static int Main(string[] args) 8 | { 9 | if (args.Contains("unicode")) 10 | { 11 | Console.OutputEncoding = Encoding.Unicode; 12 | args = [.. args, "Pi (\u03a0)"]; 13 | } 14 | 15 | Console.Out.WriteLine($"Arg count: {args.Length}"); 16 | 17 | var input = args.Contains("in") 18 | ? Console.In.ReadToEnd() 19 | .Replace("\r", "\\r", StringComparison.Ordinal) 20 | .Replace("\n", "\\n", StringComparison.Ordinal) 21 | : null; 22 | 23 | Console.Out.WriteLine($"SimpleExecTester (stdin): {input}"); 24 | Console.Out.WriteLine($"SimpleExecTester (stdout): {string.Join(" ", args)}"); 25 | Console.Error.WriteLine($"SimpleExecTester (stderr): {string.Join(" ", args)}"); 26 | 27 | if (args.Contains("large")) 28 | { 29 | Console.Out.WriteLine(new string('x', (int)Math.Pow(2, 12))); 30 | Console.Error.WriteLine(new string('x', (int)Math.Pow(2, 12))); 31 | } 32 | 33 | var exitCode = 0; 34 | if (args.FirstOrDefault(arg => int.TryParse(arg, out exitCode)) != null) 35 | { 36 | return exitCode; 37 | } 38 | 39 | if (args.Contains("sleep")) 40 | { 41 | Thread.Sleep(Timeout.Infinite); 42 | return 0; 43 | } 44 | 45 | Console.WriteLine($"foo={Environment.GetEnvironmentVariable("foo")}"); 46 | 47 | return 0; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /SimpleExecTester/SimpleExecTester.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | default 6 | Exe 7 | major 8 | net8.0;net9.0 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /SimpleExecTester/packages.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": { 4 | "net8.0": {}, 5 | "net9.0": {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /SimpleExecTests/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # CA2007: Consider calling ConfigureAwait on the awaited task 4 | dotnet_diagnostic.CA2007.severity = none 5 | -------------------------------------------------------------------------------- /SimpleExecTests/CancellingCommands.cs: -------------------------------------------------------------------------------- 1 | using SimpleExec; 2 | using SimpleExecTests.Infra; 3 | using Xunit; 4 | 5 | namespace SimpleExecTests; 6 | 7 | public static class CancellingCommands 8 | { 9 | [Fact] 10 | public static void RunningACommand() 11 | { 12 | // arrange 13 | using var cancellationTokenSource = new CancellationTokenSource(); 14 | 15 | // use a cancellation token source to ensure value type equality comparison in assertion is meaningful 16 | var cancellationToken = cancellationTokenSource.Token; 17 | cancellationTokenSource.Cancel(); 18 | 19 | // act 20 | var exception = Record.Exception(() => Command.Run("dotnet", $"exec {Tester.Path} sleep", cancellationToken: cancellationToken)); 21 | 22 | // assert 23 | Assert.Equal(cancellationToken, Assert.IsType(exception).CancellationToken); 24 | } 25 | 26 | [Fact] 27 | public static async Task RunningACommandAsync() 28 | { 29 | // arrange 30 | using var cancellationTokenSource = new CancellationTokenSource(); 31 | 32 | // use a cancellation token source to ensure value type equality comparison in assertion is meaningful 33 | var cancellationToken = cancellationTokenSource.Token; 34 | await cancellationTokenSource.CancelAsync(); 35 | 36 | // act 37 | var exception = await Record.ExceptionAsync(() => Command.RunAsync("dotnet", $"exec {Tester.Path} sleep", cancellationToken: cancellationToken)); 38 | 39 | // assert 40 | Assert.Equal(cancellationToken, Assert.IsType(exception).CancellationToken); 41 | } 42 | 43 | [Fact] 44 | public static async Task ReadingACommandAsync() 45 | { 46 | // arrange 47 | using var cancellationTokenSource = new CancellationTokenSource(); 48 | 49 | // use a cancellation token source to ensure value type equality comparison in assertion is meaningful 50 | var cancellationToken = cancellationTokenSource.Token; 51 | await cancellationTokenSource.CancelAsync(); 52 | 53 | // act 54 | var exception = await Record.ExceptionAsync(() => Command.ReadAsync("dotnet", $"exec {Tester.Path} sleep", cancellationToken: cancellationToken)); 55 | 56 | // assert 57 | Assert.Equal(cancellationToken, Assert.IsType(exception).CancellationToken); 58 | } 59 | 60 | [Theory] 61 | [InlineData(true)] 62 | [InlineData(false)] 63 | public static async Task RunningACommandAsyncWithCreateNoWindow(bool createNoWindow) 64 | { 65 | // arrange 66 | using var cancellationTokenSource = new CancellationTokenSource(); 67 | 68 | // use a cancellation token source to ensure value type equality comparison in assertion is meaningful 69 | var cancellationToken = cancellationTokenSource.Token; 70 | 71 | var command = Command.RunAsync( 72 | "dotnet", $"exec {Tester.Path} sleep", createNoWindow: createNoWindow, cancellationToken: cancellationToken); 73 | 74 | // act 75 | await cancellationTokenSource.CancelAsync(); 76 | 77 | // assert 78 | var exception = await Record.ExceptionAsync(() => command); 79 | Assert.Equal(cancellationToken, Assert.IsType(exception).CancellationToken); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /SimpleExecTests/ConfiguringEnvironments.cs: -------------------------------------------------------------------------------- 1 | using SimpleExec; 2 | using SimpleExecTests.Infra; 3 | using Xunit; 4 | 5 | namespace SimpleExecTests; 6 | 7 | public static class ConfiguringEnvironments 8 | { 9 | [Fact] 10 | public static async Task ConfiguringEnvironment() 11 | { 12 | // act 13 | var (standardOutput, _) = await Command.ReadAsync("dotnet", $"exec {Tester.Path} environment", configureEnvironment: env => env["foo"] = "bar"); 14 | 15 | // assert 16 | Assert.Contains("foo=bar", standardOutput, StringComparison.Ordinal); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /SimpleExecTests/EchoingCommands.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using SimpleExec; 3 | using SimpleExecTests.Infra; 4 | using Xunit; 5 | 6 | namespace SimpleExecTests; 7 | 8 | public static class EchoingCommands 9 | { 10 | [Fact] 11 | public static void EchoingACommand() 12 | { 13 | // arrange 14 | Console.SetOut(Capture.Out); 15 | 16 | // act 17 | Command.Run("dotnet", $"exec {Tester.Path} {TestName()}"); 18 | 19 | // assert 20 | Assert.Contains(TestName(), Capture.Out.ToString()!, StringComparison.Ordinal); 21 | } 22 | 23 | [Fact] 24 | public static void EchoingACommandWithAnArgList() 25 | { 26 | // arrange 27 | Console.SetOut(Capture.Out); 28 | 29 | // act 30 | Command.Run("dotnet", ["exec", Tester.Path, "he llo", "\"world \"today\"",]); 31 | 32 | // assert 33 | var lines = Capture.Out.ToString()!.Split('\r', '\n').ToList(); 34 | Assert.Contains(lines, line => line.EndsWith(": exec", StringComparison.Ordinal)); 35 | Assert.Contains(lines, line => line.EndsWith($": {Tester.Path}", StringComparison.Ordinal)); 36 | Assert.Contains(lines, line => line.EndsWith(": he llo", StringComparison.Ordinal)); 37 | Assert.Contains(lines, line => line.EndsWith(": \"world \"today\"", StringComparison.Ordinal)); 38 | } 39 | 40 | [Fact] 41 | public static void SuppressingCommandEcho() 42 | { 43 | // arrange 44 | Console.SetOut(Capture.Out); 45 | 46 | // act 47 | Command.Run("dotnet", $"exec {Tester.Path} {TestName()}", noEcho: true); 48 | 49 | // assert 50 | Assert.DoesNotContain(TestName(), Capture.Out.ToString()!, StringComparison.Ordinal); 51 | } 52 | 53 | [Fact] 54 | public static void EchoingACommandWithASpecificPrefix() 55 | { 56 | // arrange 57 | Console.SetOut(Capture.Out); 58 | 59 | // act 60 | Command.Run("dotnet", $"exec {Tester.Path} {TestName()}", noEcho: false, echoPrefix: $"{TestName()} prefix"); 61 | 62 | // assert 63 | var error = Capture.Out.ToString()!; 64 | 65 | Assert.Contains(TestName(), error, StringComparison.Ordinal); 66 | Assert.Contains($"{TestName()} prefix:", error, StringComparison.Ordinal); 67 | } 68 | 69 | [Fact] 70 | public static void SuppressingCommandEchoWithASpecificPrefix() 71 | { 72 | // arrange 73 | Console.SetOut(Capture.Out); 74 | 75 | // act 76 | Command.Run("dotnet", $"exec {Tester.Path} {TestName()}", noEcho: true, echoPrefix: $"{TestName()} prefix"); 77 | 78 | // assert 79 | var error = Capture.Out.ToString()!; 80 | 81 | Assert.DoesNotContain(TestName(), error, StringComparison.Ordinal); 82 | Assert.DoesNotContain($"{TestName()} prefix:", error, StringComparison.Ordinal); 83 | } 84 | 85 | private static string TestName([CallerMemberName] string _ = "") => _; 86 | } 87 | -------------------------------------------------------------------------------- /SimpleExecTests/ExitCodes.cs: -------------------------------------------------------------------------------- 1 | using SimpleExec; 2 | using SimpleExecTests.Infra; 3 | using Xunit; 4 | 5 | namespace SimpleExecTests; 6 | 7 | public static class ExitCodes 8 | { 9 | [Theory] 10 | [InlineData(0, false)] 11 | [InlineData(1, false)] 12 | [InlineData(2, true)] 13 | public static void RunningACommand(int exitCode, bool shouldThrow) 14 | { 15 | // act 16 | var exception = Record.Exception(() => Command.Run("dotnet", $"exec {Tester.Path} {exitCode}", handleExitCode: code => code == 1)); 17 | 18 | // assert 19 | if (shouldThrow) 20 | { 21 | Assert.Equal(exitCode, Assert.IsType(exception).ExitCode); 22 | } 23 | else 24 | { 25 | Assert.Null(exception); 26 | } 27 | } 28 | 29 | [Theory] 30 | [InlineData(0, false)] 31 | [InlineData(1, false)] 32 | [InlineData(2, true)] 33 | public static async Task RunningACommandAsync(int exitCode, bool shouldThrow) 34 | { 35 | // act 36 | var exception = await Record.ExceptionAsync(() => Command.RunAsync("dotnet", $"exec {Tester.Path} {exitCode}", handleExitCode: code => code == 1)); 37 | 38 | // assert 39 | if (shouldThrow) 40 | { 41 | Assert.Equal(exitCode, Assert.IsType(exception).ExitCode); 42 | } 43 | else 44 | { 45 | Assert.Null(exception); 46 | } 47 | } 48 | 49 | [Theory] 50 | [InlineData(0, false)] 51 | [InlineData(1, false)] 52 | [InlineData(2, true)] 53 | public static async Task ReadingACommandAsync(int exitCode, bool shouldThrow) 54 | { 55 | // act 56 | var exception = await Record.ExceptionAsync(async () => _ = await Command.ReadAsync("dotnet", $"exec {Tester.Path} {exitCode}", handleExitCode: code => code == 1)); 57 | 58 | // assert 59 | if (shouldThrow) 60 | { 61 | Assert.Equal(exitCode, Assert.IsType(exception).ExitCode); 62 | } 63 | else 64 | { 65 | Assert.Null(exception); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SimpleExecTests/Infra/Capture.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleExecTests.Infra; 2 | 3 | internal static class Capture 4 | { 5 | private static readonly Lazy @out = new(() => new StringWriter()); 6 | 7 | public static TextWriter Out => @out.Value; 8 | } 9 | -------------------------------------------------------------------------------- /SimpleExecTests/Infra/Tester.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleExecTests.Infra; 2 | 3 | internal static class Tester 4 | { 5 | public static string Path => 6 | #if NET8_0 && DEBUG 7 | $"../../../../SimpleExecTester/bin/Debug/net8.0/SimpleExecTester.dll"; 8 | #endif 9 | #if NET8_0 && RELEASE 10 | $"../../../../SimpleExecTester/bin/Release/net8.0/SimpleExecTester.dll"; 11 | #endif 12 | #if NET9_0 && DEBUG 13 | "../../../../SimpleExecTester/bin/Debug/net9.0/SimpleExecTester.dll"; 14 | #endif 15 | #if NET9_0 && RELEASE 16 | "../../../../SimpleExecTester/bin/Release/net9.0/SimpleExecTester.dll"; 17 | #endif 18 | } 19 | -------------------------------------------------------------------------------- /SimpleExecTests/Infra/WindowsFactAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using Xunit; 3 | 4 | namespace SimpleExecTests.Infra; 5 | 6 | internal sealed class WindowsFactAttribute : FactAttribute 7 | { 8 | public override string Skip 9 | { 10 | get => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Windows only" : base.Skip; 11 | set => base.Skip = value; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /SimpleExecTests/Infra/WindowsTheoryAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using Xunit; 3 | 4 | namespace SimpleExecTests.Infra; 5 | 6 | internal sealed class WindowsTheoryAttribute : TheoryAttribute 7 | { 8 | public override string Skip 9 | { 10 | get => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Windows only" : base.Skip; 11 | set => base.Skip = value; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /SimpleExecTests/ReadingCommands.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Text; 3 | using SimpleExec; 4 | using SimpleExecTests.Infra; 5 | using Xunit; 6 | 7 | namespace SimpleExecTests; 8 | 9 | public static class ReadingCommands 10 | { 11 | [Theory] 12 | [InlineData(false)] 13 | [InlineData(true)] 14 | public static async Task ReadingACommandAsync(bool largeOutput) 15 | { 16 | // act 17 | var (standardOutput, standardError) = await Command.ReadAsync("dotnet", $"exec {Tester.Path} hello world" + (largeOutput ? " large" : "")); 18 | 19 | // assert 20 | Assert.Contains("hello world", standardOutput, StringComparison.Ordinal); 21 | Assert.Contains("hello world", standardError, StringComparison.Ordinal); 22 | } 23 | 24 | [Theory] 25 | [InlineData(false)] 26 | [InlineData(true)] 27 | public static async Task ReadingACommandAsyncWithAnArgList(bool largeOutput) 28 | { 29 | // arrange 30 | var args = new List { "exec", Tester.Path, "he llo", "world", }; 31 | if (largeOutput) 32 | { 33 | args.Add("large"); 34 | } 35 | 36 | // act 37 | var (standardOutput, standardError) = await Command.ReadAsync("dotnet", args); 38 | 39 | // assert 40 | Assert.Contains(largeOutput ? "Arg count: 3" : "Arg count: 2", standardOutput, StringComparison.Ordinal); 41 | Assert.Contains("he llo world", standardOutput, StringComparison.Ordinal); 42 | Assert.Contains("he llo world", standardError, StringComparison.Ordinal); 43 | } 44 | 45 | [Theory] 46 | [InlineData(false)] 47 | [InlineData(true)] 48 | public static async Task ReadingACommandWithInputAsync(bool largeOutput) 49 | { 50 | // act 51 | var (standardOutput, standardError) = await Command.ReadAsync("dotnet", $"exec {Tester.Path} hello world in" + (largeOutput ? " large" : ""), standardInput: "this is input"); 52 | 53 | // assert 54 | Assert.Contains("hello world", standardOutput, StringComparison.Ordinal); 55 | Assert.Contains("this is input", standardOutput, StringComparison.Ordinal); 56 | Assert.Contains("hello world", standardError, StringComparison.Ordinal); 57 | } 58 | 59 | [Theory] 60 | [InlineData(false)] 61 | [InlineData(true)] 62 | public static async Task ReadingAUnicodeCommandAsync(bool largeOutput) 63 | { 64 | // act 65 | var (standardOutput, standardError) = await Command.ReadAsync("dotnet", $"exec {Tester.Path} hello world unicode" + (largeOutput ? " large" : ""), encoding: new UnicodeEncoding()); 66 | 67 | // assert 68 | Assert.Contains("Pi (\u03a0)", standardOutput, StringComparison.Ordinal); 69 | Assert.Contains("Pi (\u03a0)", standardError, StringComparison.Ordinal); 70 | } 71 | 72 | [Fact] 73 | public static async Task ReadingAFailingCommandAsync() 74 | { 75 | // act 76 | var exception = await Record.ExceptionAsync(() => Command.ReadAsync("dotnet", $"exec {Tester.Path} 1 hello world")); 77 | 78 | // assert 79 | var exitCodeReadException = Assert.IsType(exception); 80 | Assert.Equal(1, exitCodeReadException.ExitCode); 81 | Assert.Contains("hello world", exitCodeReadException.StandardOutput, StringComparison.Ordinal); 82 | Assert.Contains("hello world", exitCodeReadException.StandardError, StringComparison.Ordinal); 83 | } 84 | 85 | [Fact] 86 | public static async Task ReadingACommandAsyncInANonExistentWorkDirectory() 87 | { 88 | // act 89 | var exception = await Record.ExceptionAsync(() => Command.ReadAsync("dotnet", $"exec {Tester.Path}", "non-existent-working-directory")); 90 | 91 | // assert 92 | _ = Assert.IsType(exception); 93 | } 94 | 95 | [Fact] 96 | public static async Task ReadingANonExistentCommandAsync() 97 | { 98 | // act 99 | var exception = await Record.ExceptionAsync(() => Command.ReadAsync("simple-exec-tests-non-existent-command")); 100 | 101 | // assert 102 | _ = Assert.IsType(exception); 103 | } 104 | 105 | [Theory] 106 | [InlineData("")] 107 | [InlineData(" ")] 108 | public static async Task ReadingNoCommandAsync(string name) 109 | { 110 | // act 111 | var exception = await Record.ExceptionAsync(() => Command.ReadAsync(name)); 112 | 113 | // assert 114 | Assert.Equal(nameof(name), Assert.IsType(exception).ParamName); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /SimpleExecTests/RunningCommands.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Globalization; 3 | using System.Runtime.InteropServices; 4 | using SimpleExec; 5 | using SimpleExecTests.Infra; 6 | using Xunit; 7 | 8 | namespace SimpleExecTests; 9 | 10 | public static class RunningCommands 11 | { 12 | private static readonly string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "hello-world.cmd" : "ls"; 13 | 14 | [Fact] 15 | public static void RunningASucceedingCommand() 16 | { 17 | // act 18 | var exception = Record.Exception(() => Command.Run(command)); 19 | 20 | // assert 21 | Assert.Null(exception); 22 | } 23 | 24 | [Fact] 25 | public static void RunningASucceedingCommandWithArgs() 26 | { 27 | // act 28 | var exception = Record.Exception(() => Command.Run("dotnet", $"exec {Tester.Path} hello world")); 29 | 30 | // assert 31 | Assert.Null(exception); 32 | } 33 | 34 | [Fact] 35 | public static async Task RunningASucceedingCommandAsync() 36 | { 37 | // act 38 | var exception = await Record.ExceptionAsync(() => Command.RunAsync(command)); 39 | 40 | // assert 41 | Assert.Null(exception); 42 | } 43 | 44 | [Fact] 45 | public static void RunningAFailingCommand() 46 | { 47 | // act 48 | var exception = Record.Exception(() => Command.Run("dotnet", $"exec {Tester.Path} 1 hello world")); 49 | 50 | // assert 51 | Assert.Equal(1, Assert.IsType(exception).ExitCode); 52 | } 53 | 54 | [Fact] 55 | public static async Task RunningAFailingCommandAsync() 56 | { 57 | // act 58 | var exception = await Record.ExceptionAsync(() => Command.RunAsync("dotnet", $"exec {Tester.Path} 1 hello world")); 59 | 60 | // assert 61 | Assert.Equal(1, Assert.IsType(exception).ExitCode); 62 | } 63 | 64 | [Fact] 65 | public static void RunningACommandInANonExistentWorkDirectory() 66 | { 67 | // act 68 | var exception = Record.Exception(() => Command.Run("dotnet", $"exec {Tester.Path}", "non-existent-working-directory")); 69 | 70 | // assert 71 | _ = Assert.IsType(exception); 72 | } 73 | 74 | [Fact] 75 | public static async Task RunningACommandAsyncInANonExistentWorkDirectory() 76 | { 77 | // act 78 | var exception = await Record.ExceptionAsync(() => Command.RunAsync("dotnet", $"exec {Tester.Path}", "non-existent-working-directory")); 79 | 80 | // assert 81 | _ = Assert.IsType(exception); 82 | } 83 | 84 | [Fact] 85 | public static void RunningANonExistentCommand() 86 | { 87 | // act 88 | var exception = Record.Exception(() => Command.Run("simple-exec-tests-non-existent-command")); 89 | 90 | // assert 91 | _ = Assert.IsType(exception); 92 | } 93 | 94 | [Fact] 95 | public static async Task RunningANonExistentCommandAsync() 96 | { 97 | // act 98 | var exception = await Record.ExceptionAsync(() => Command.RunAsync("simple-exec-tests-non-existent-command")); 99 | 100 | // assert 101 | _ = Assert.IsType(exception); 102 | } 103 | 104 | [Theory] 105 | [InlineData("")] 106 | [InlineData(" ")] 107 | public static void RunningNoCommand(string name) 108 | { 109 | // act 110 | var exception = Record.Exception(() => Command.Run(name)); 111 | 112 | // assert 113 | Assert.Equal(nameof(name), Assert.IsType(exception).ParamName); 114 | } 115 | 116 | [Theory] 117 | [InlineData("")] 118 | [InlineData(" ")] 119 | public static async Task RunningNoCommandAsync(string name) 120 | { 121 | // act 122 | var exception = await Record.ExceptionAsync(() => Command.RunAsync(name)); 123 | 124 | // assert 125 | Assert.Equal(nameof(name), Assert.IsType(exception).ParamName); 126 | } 127 | 128 | [WindowsFact] 129 | public static async Task RunningCommandsInPathOnWindows() 130 | { 131 | // arrange 132 | var directory = Path.Combine( 133 | Path.GetTempPath(), 134 | "SimpleExecTests", 135 | DateTimeOffset.UtcNow.UtcTicks.ToString(CultureInfo.InvariantCulture), 136 | "RunningCommandsInPathOnWindows"); 137 | 138 | _ = Directory.CreateDirectory(directory); 139 | 140 | if (!SpinWait.SpinUntil(() => Directory.Exists(directory), 50)) 141 | { 142 | throw new IOException($"Failed to create directory '{directory}'."); 143 | } 144 | 145 | var name = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()); 146 | var fullName = Path.Combine(directory, Path.ChangeExtension(name, "cmd")); 147 | await File.WriteAllTextAsync(fullName, "echo foo"); 148 | 149 | Environment.SetEnvironmentVariable( 150 | "PATH", 151 | $"{Environment.GetEnvironmentVariable("PATH")}{Path.PathSeparator}{directory}", 152 | EnvironmentVariableTarget.Process); 153 | 154 | // act 155 | var exception = Record.Exception(() => Command.Run(name)); 156 | 157 | // assert 158 | Assert.Null(exception); 159 | } 160 | 161 | [WindowsTheory] 162 | [InlineData(".BAT;.CMD", "hello from bat")] 163 | [InlineData(".CMD;.BAT", "hello from cmd")] 164 | public static async Task RunningCommandsInPathOnWindowsWithSpecificPathExt( 165 | string pathExt, string expected) 166 | { 167 | // arrange 168 | var directory = Path.Combine( 169 | Path.GetTempPath(), 170 | "SimpleExecTests", 171 | DateTimeOffset.UtcNow.UtcTicks.ToString(CultureInfo.InvariantCulture), 172 | "RunningCommandsInPathOnWindows"); 173 | 174 | _ = Directory.CreateDirectory(directory); 175 | 176 | if (!SpinWait.SpinUntil(() => Directory.Exists(directory), 50)) 177 | { 178 | throw new IOException($"Failed to create directory '{directory}'."); 179 | } 180 | 181 | var name = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()); 182 | var batName = Path.Combine(directory, Path.ChangeExtension(name, "bat")); 183 | await File.WriteAllTextAsync(batName, "@echo hello from bat"); 184 | 185 | var cmdName = Path.Combine(directory, Path.ChangeExtension(name, "cmd")); 186 | await File.WriteAllTextAsync(cmdName, "@echo hello from cmd"); 187 | 188 | Environment.SetEnvironmentVariable( 189 | "PATH", 190 | $"{Environment.GetEnvironmentVariable("PATH")}{Path.PathSeparator}{directory}", 191 | EnvironmentVariableTarget.Process); 192 | 193 | Environment.SetEnvironmentVariable( 194 | "PATHEXT", 195 | pathExt, 196 | EnvironmentVariableTarget.Process); 197 | 198 | // act 199 | var actual = (await Command.ReadAsync(name)).StandardOutput.Trim(); 200 | 201 | // assert 202 | Assert.Equal(expected, actual); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /SimpleExecTests/SimpleExecTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | default 5 | 6 | $(NoWarn);CA1515 7 | major 8 | net8.0;net9.0 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Always 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /SimpleExecTests/hello-world.cmd: -------------------------------------------------------------------------------- 1 | @echo "Hello, world!" 2 | -------------------------------------------------------------------------------- /SimpleExecTests/packages.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": { 4 | "net8.0": { 5 | "GitHubActionsTestLogger": { 6 | "type": "Direct", 7 | "requested": "[2.4.1, )", 8 | "resolved": "2.4.1", 9 | "contentHash": "SH1ar/kg36CggzMqLUDRoUqR8SSjK/JiQ2JS8MYg8u0RCLDkkDEbPGIN91omOPx9f2GuDqsxxofSdgsQje3Xuw==", 10 | "dependencies": { 11 | "Microsoft.TestPlatform.ObjectModel": "17.10.0" 12 | } 13 | }, 14 | "Microsoft.NET.Test.Sdk": { 15 | "type": "Direct", 16 | "requested": "[17.12.0, )", 17 | "resolved": "17.12.0", 18 | "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", 19 | "dependencies": { 20 | "Microsoft.CodeCoverage": "17.12.0", 21 | "Microsoft.TestPlatform.TestHost": "17.12.0" 22 | } 23 | }, 24 | "xunit": { 25 | "type": "Direct", 26 | "requested": "[2.9.3, )", 27 | "resolved": "2.9.3", 28 | "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", 29 | "dependencies": { 30 | "xunit.analyzers": "1.18.0", 31 | "xunit.assert": "2.9.3", 32 | "xunit.core": "[2.9.3]" 33 | } 34 | }, 35 | "xunit.runner.visualstudio": { 36 | "type": "Direct", 37 | "requested": "[3.0.2, )", 38 | "resolved": "3.0.2", 39 | "contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow==" 40 | }, 41 | "Microsoft.CodeCoverage": { 42 | "type": "Transitive", 43 | "resolved": "17.12.0", 44 | "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" 45 | }, 46 | "Microsoft.TestPlatform.ObjectModel": { 47 | "type": "Transitive", 48 | "resolved": "17.12.0", 49 | "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", 50 | "dependencies": { 51 | "System.Reflection.Metadata": "1.6.0" 52 | } 53 | }, 54 | "Microsoft.TestPlatform.TestHost": { 55 | "type": "Transitive", 56 | "resolved": "17.12.0", 57 | "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", 58 | "dependencies": { 59 | "Microsoft.TestPlatform.ObjectModel": "17.12.0", 60 | "Newtonsoft.Json": "13.0.1" 61 | } 62 | }, 63 | "Newtonsoft.Json": { 64 | "type": "Transitive", 65 | "resolved": "13.0.1", 66 | "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" 67 | }, 68 | "System.Reflection.Metadata": { 69 | "type": "Transitive", 70 | "resolved": "1.6.0", 71 | "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" 72 | }, 73 | "xunit.abstractions": { 74 | "type": "Transitive", 75 | "resolved": "2.0.3", 76 | "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" 77 | }, 78 | "xunit.analyzers": { 79 | "type": "Transitive", 80 | "resolved": "1.18.0", 81 | "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" 82 | }, 83 | "xunit.assert": { 84 | "type": "Transitive", 85 | "resolved": "2.9.3", 86 | "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" 87 | }, 88 | "xunit.core": { 89 | "type": "Transitive", 90 | "resolved": "2.9.3", 91 | "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", 92 | "dependencies": { 93 | "xunit.extensibility.core": "[2.9.3]", 94 | "xunit.extensibility.execution": "[2.9.3]" 95 | } 96 | }, 97 | "xunit.extensibility.core": { 98 | "type": "Transitive", 99 | "resolved": "2.9.3", 100 | "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", 101 | "dependencies": { 102 | "xunit.abstractions": "2.0.3" 103 | } 104 | }, 105 | "xunit.extensibility.execution": { 106 | "type": "Transitive", 107 | "resolved": "2.9.3", 108 | "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", 109 | "dependencies": { 110 | "xunit.extensibility.core": "[2.9.3]" 111 | } 112 | }, 113 | "simpleexec": { 114 | "type": "Project" 115 | }, 116 | "simpleexectester": { 117 | "type": "Project" 118 | } 119 | }, 120 | "net9.0": { 121 | "GitHubActionsTestLogger": { 122 | "type": "Direct", 123 | "requested": "[2.4.1, )", 124 | "resolved": "2.4.1", 125 | "contentHash": "SH1ar/kg36CggzMqLUDRoUqR8SSjK/JiQ2JS8MYg8u0RCLDkkDEbPGIN91omOPx9f2GuDqsxxofSdgsQje3Xuw==", 126 | "dependencies": { 127 | "Microsoft.TestPlatform.ObjectModel": "17.10.0" 128 | } 129 | }, 130 | "Microsoft.NET.Test.Sdk": { 131 | "type": "Direct", 132 | "requested": "[17.12.0, )", 133 | "resolved": "17.12.0", 134 | "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", 135 | "dependencies": { 136 | "Microsoft.CodeCoverage": "17.12.0", 137 | "Microsoft.TestPlatform.TestHost": "17.12.0" 138 | } 139 | }, 140 | "xunit": { 141 | "type": "Direct", 142 | "requested": "[2.9.3, )", 143 | "resolved": "2.9.3", 144 | "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", 145 | "dependencies": { 146 | "xunit.analyzers": "1.18.0", 147 | "xunit.assert": "2.9.3", 148 | "xunit.core": "[2.9.3]" 149 | } 150 | }, 151 | "xunit.runner.visualstudio": { 152 | "type": "Direct", 153 | "requested": "[3.0.2, )", 154 | "resolved": "3.0.2", 155 | "contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow==" 156 | }, 157 | "Microsoft.CodeCoverage": { 158 | "type": "Transitive", 159 | "resolved": "17.12.0", 160 | "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" 161 | }, 162 | "Microsoft.TestPlatform.ObjectModel": { 163 | "type": "Transitive", 164 | "resolved": "17.12.0", 165 | "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", 166 | "dependencies": { 167 | "System.Reflection.Metadata": "1.6.0" 168 | } 169 | }, 170 | "Microsoft.TestPlatform.TestHost": { 171 | "type": "Transitive", 172 | "resolved": "17.12.0", 173 | "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", 174 | "dependencies": { 175 | "Microsoft.TestPlatform.ObjectModel": "17.12.0", 176 | "Newtonsoft.Json": "13.0.1" 177 | } 178 | }, 179 | "Newtonsoft.Json": { 180 | "type": "Transitive", 181 | "resolved": "13.0.1", 182 | "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" 183 | }, 184 | "System.Reflection.Metadata": { 185 | "type": "Transitive", 186 | "resolved": "1.6.0", 187 | "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" 188 | }, 189 | "xunit.abstractions": { 190 | "type": "Transitive", 191 | "resolved": "2.0.3", 192 | "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" 193 | }, 194 | "xunit.analyzers": { 195 | "type": "Transitive", 196 | "resolved": "1.18.0", 197 | "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" 198 | }, 199 | "xunit.assert": { 200 | "type": "Transitive", 201 | "resolved": "2.9.3", 202 | "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" 203 | }, 204 | "xunit.core": { 205 | "type": "Transitive", 206 | "resolved": "2.9.3", 207 | "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", 208 | "dependencies": { 209 | "xunit.extensibility.core": "[2.9.3]", 210 | "xunit.extensibility.execution": "[2.9.3]" 211 | } 212 | }, 213 | "xunit.extensibility.core": { 214 | "type": "Transitive", 215 | "resolved": "2.9.3", 216 | "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", 217 | "dependencies": { 218 | "xunit.abstractions": "2.0.3" 219 | } 220 | }, 221 | "xunit.extensibility.execution": { 222 | "type": "Transitive", 223 | "resolved": "2.9.3", 224 | "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", 225 | "dependencies": { 226 | "xunit.extensibility.core": "[2.9.3]" 227 | } 228 | }, 229 | "simpleexec": { 230 | "type": "Project" 231 | }, 232 | "simpleexectester": { 233 | "type": "Project" 234 | } 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /assets/simple-exec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamralph/simple-exec/2a7887a58cb11c8fec234f152ce2c5dcc8542a66/assets/simple-exec.png -------------------------------------------------------------------------------- /assets/simple-exec.svg: -------------------------------------------------------------------------------- 1 | 2 | 23 | 25 | 44 | Artboard 11 46 | 50 | 52 | 53 | 55 | Artboard 11 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | echo "${0##*/}": Formatting... 5 | dotnet format --verify-no-changes 6 | 7 | echo "${0##*/}": Building... 8 | dotnet build --configuration Release --nologo 9 | 10 | echo "${0##*/}": Testing... 11 | dotnet test --configuration Release --no-build --nologo "${1:-}" "${2:-}" 12 | -------------------------------------------------------------------------------- /build.cmd: -------------------------------------------------------------------------------- 1 | @echo Off 2 | 3 | echo %~nx0: Formatting... 4 | dotnet format --verify-no-changes || goto :error 5 | 6 | echo %~nx0: Building... 7 | dotnet build --configuration Release --nologo || goto :error 8 | 9 | echo %~nx0: Testing... 10 | dotnet test --configuration Release --no-build --nologo %1 %2 || goto :error 11 | 12 | goto :EOF 13 | :error 14 | exit /b %errorlevel% 15 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.100", 4 | "rollForward": "latestMajor" 5 | } 6 | } 7 | --------------------------------------------------------------------------------