├── .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 | 
4 |
5 | _[](https://www.nuget.org/packages/SimpleExec)_
6 |
7 | _[](https://github.com/adamralph/simple-exec/actions/workflows/ci.yml?query=branch%3Amain)_
8 | _[](https://github.com/adamralph/simple-exec/actions/workflows/codeql-analysis.yml?query=branch%3Amain)_
9 | _[](https://github.com/adamralph/simple-exec/actions/workflows/infer-sharp.yml?query=branch%3Amain)_
10 | _[](https://github.com/adamralph/simple-exec/actions/workflows/lint.yml?query=branch%3Amain)_
11 | _[](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 |
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 |
--------------------------------------------------------------------------------