From 18a25767ca3c840eada1e452b7f0a60cf5fefbd9 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 19 Aug 2026 21:09:03 +1000 Subject: [PATCH] docs: refresh README, CLAUDE.md, DESCRIPTION and TAGS for the 1.5.0 API The documentation predated CommandOptions, cancellation, elevation and the argument-vector overloads, and the API reference listed the three CommandOptions overloads under the Obsolete heading, where they do not belong. README gains Introduction and Features sections, the three-part installation block, a cancellation example, and an API reference in table form covering every non-obsolete overload, CommandOptions, both output handlers and the Elevation enum. The generic Acknowledgements section is dropped as it carried no specific content. CLAUDE.md is rewritten against the current code. It records that dotnet test reports "Zero tests ran" against this MSTest.Sdk and MTP combination and that the test executable has to be run directly with VSTest-style filters, why the cancellation re-check in RunAsync must not be removed, why ktsu.Semantics.Strings is referenced explicitly, and which build warnings that dependency chain is expected to produce. DESCRIPTION and TAGS are expanded to cover the process-shaping and cancellation capabilities. --- CLAUDE.md | 195 ++++++++++++++++++++++-------------- DESCRIPTION.md | 2 +- README.md | 261 ++++++++++++++++++++++++++++++++----------------- TAGS.md | 2 +- 4 files changed, 293 insertions(+), 167 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47cd7da..fc26f27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,118 +2,163 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project Overview +## Build Commands -ktsu.RunCommand is a .NET library that provides an easy way to execute shell commands and handle output via delegates. It supports both synchronous and asynchronous execution with customizable output handling. - -## Build and Test Commands - -### Building ```bash +# Restore, build, and test (standard workflow) +dotnet restore dotnet build + +# Build specific configuration +dotnet build -c Release + +# Create the NuGet package +dotnet pack ``` ### Running Tests + +The test project uses MSTest.Sdk with the Microsoft Testing Platform (MTP). `dotnet test` reports +`Zero tests ran` here even though the tests build and discover correctly — run the produced test +executable directly instead: + ```bash # Run all tests -dotnet test +./RunCommand.Test/bin/Debug/net10.0/ktsu.RunCommand.Test.exe # Run a single test -dotnet test --filter "FullyQualifiedName~RunCommandTests.ExecuteShouldExecuteCommandAndReturnExitCode" -``` +./RunCommand.Test/bin/Debug/net10.0/ktsu.RunCommand.Test.exe --filter "FullyQualifiedName~ExecuteAsyncShouldStartTheProcessInTheGivenWorkingDirectory" -### Creating NuGet Package -```bash -dotnet pack +# List the discovered tests +./RunCommand.Test/bin/Debug/net10.0/ktsu.RunCommand.Test.exe --list-tests ``` -### Using the Build Automation -The project uses a custom PowerShell build module (PSBuild) for CI/CD: -```powershell -Import-Module ./scripts/PSBuild.psm1 -$buildConfig = Get-BuildConfiguration ... -Invoke-CIPipeline -BuildConfiguration $buildConfig -``` +The executable takes VSTest-style `--filter` expressions. The MTP-native `--filter-method` and +`--treenode-filter` options are not accepted by this test host. + +Two elevation tests self-skip on Windows (`Assert.Inconclusive`) to avoid raising a UAC prompt, so +a clean run reports skips rather than failures. + +## Project Structure + +This is a .NET library (`ktsu.RunCommand`) that executes external commands and delivers their +output through delegates. The solution uses: + +- **ktsu.Sdk** - Custom SDK providing shared build configuration +- **MSTest.Sdk** - Test project SDK with Microsoft Testing Platform +- Multi-targeting: `net10.0`, `net9.0`, `net8.0`, `net7.0`, `net6.0`, `net5.0`, `netstandard2.0`, `netstandard2.1` + +The test project targets `net10.0` only. + +### Key Files + +- `RunCommand/RunCommand.cs` - Static class holding the whole public execution API and the private `CreateStartInfo`/`RunAsync`/`TryKill` core +- `RunCommand/CommandOptions.cs` - Record carrying process-shaping settings (working directory, environment variables, elevation) +- `RunCommand/OutputHandler.cs` - Base output handler delivering raw chunks +- `RunCommand/LineOutputHandler.cs` - Derived handler that buffers chunks into complete lines +- `RunCommand/AsyncProcessStreamReader.cs` - Internal concurrent reader for stdout and stderr +- `RunCommand/Elevation.cs` - Enum selecting the privilege level + +### Dependencies + +- **ktsu.Semantics.Paths** - Supplies `AbsoluteDirectoryPath` for `CommandOptions.WorkingDirectory` +- **ktsu.Semantics.Strings** - Referenced explicitly because `WeakString` is declared there; the SDK's `KTSU0006` analyzer rejects using it transitively through Paths +- **Polyfill** (`PrivateAssets="all"`) - Supplies `Ensure.NotNull` and newer-framework APIs on older targets +- **System.Memory**, **System.Threading.Tasks.Extensions** - `netstandard2.0`/`netstandard2.1` only + +Note that the Semantics packages pull `System.Text.Json`, `System.IO.Pipelines` and +`System.Text.Encodings.Web` in transitively. On `net5.0`–`net7.0` those emit "doesn't support +" MSBuild warnings. They come from targets files rather than the compiler, so +`TreatWarningsAsErrors` does not escalate them and the build stays green. ## Architecture -### Core Components +### Public API shape + +Two entry shapes exist, both returning the process exit code: + +- **Argument vector** — `Execute`/`ExecuteAsync(string fileName, IEnumerable arguments, ...)`. Preferred. The executable is passed separately, so nothing has to be quoted. +- **Command string** — `Execute`/`ExecuteAsync(string command, ...)`. **Obsolete.** Splits on the first space, so an executable path containing a space is mis-split. Kept working for compatibility; do not add new overloads to this shape. + +Optional settings arrive through `CommandOptions` rather than through new parameters, so adding a +setting costs no new overloads. The older overloads taking a bare `Elevation` delegate through +`new CommandOptions { Elevation = elevation }`. + +### Key design patterns -**[RunCommand.cs](RunCommand/RunCommand.cs)** - Main static class providing the public API: -- `Execute(string command)` - Synchronous command execution -- `Execute(string command, OutputHandler outputHandler)` - Synchronous with output handling -- `ExecuteAsync(string command)` - Asynchronous command execution -- `ExecuteAsync(string command, OutputHandler outputHandler)` - Asynchronous with output handling -- All methods return process exit codes -- Commands are parsed by splitting on first space: filename and arguments +1. **Async over sync**: The synchronous `Execute` methods call `ExecuteAsync().Result`, making the async implementation the single source of truth. Note this means argument-null exceptions surface wrapped in `AggregateException` from the synchronous overloads. + +2. **Strategy pattern**: `OutputHandler` and `LineOutputHandler` plug different output processing strategies into the same execution core. + +3. **Template method**: `OutputHandler` exposes virtual `HandleStandardOutputData`/`HandleStandardErrorData` that `LineOutputHandler` overrides. + +4. **Buffering strategy**: `LineOutputHandler` keeps separate `outputBuffer`/`errorBuffer` fields so an incomplete line spanning two chunk reads is reassembled rather than raised twice. + +## Important Implementation Notes -**[OutputHandler.cs](RunCommand/OutputHandler.cs)** - Base class for handling command output: -- Processes output in raw, undelimited chunks as they arrive from the process -- Provides `OnStandardOutput` and `OnStandardError` delegates -- Supports custom encoding (defaults to UTF-8) -- Virtual methods `HandleStandardOutputData` and `HandleStandardErrorData` for extensibility +### Cancellation -**[LineOutputHandler.cs](RunCommand/LineOutputHandler.cs)** - Derived output handler for line-by-line processing: -- Inherits from OutputHandler -- Buffers incoming chunks and splits by newlines -- Maintains separate buffers (`outputBuffer`, `errorBuffer`) for incomplete lines -- Uses `Environment.NewLine` after normalizing line endings with `ReplaceLineEndings()` -- Invokes delegates for each complete line +`RunAsync` delivers cancellation two ways at once: a `CancellationTokenRegistration` kills the +process, and `WaitForExitAsync(cancellationToken)` separately observes the token. Killing the +process makes it exit fast enough that the normal-exit path can win that race, which would return +the killed process's exit code (`-1` on Windows) and throw nothing. -**[AsyncProcessStreamReader.cs](RunCommand/AsyncProcessStreamReader.cs)** - Internal async stream reader: -- Reads from both stdout and stderr concurrently using 4096-character buffers -- Continuously reads while process is running, then performs final read after exit -- Uses `Task.WhenAny` to poll streams efficiently -- Invokes OutputHandler methods for each chunk of data received +`RunAsync` therefore calls `cancellationToken.ThrowIfCancellationRequested()` after the await and +before returning. **Do not remove this** — without it a cancelled command is indistinguishable from +a genuine failure of the underlying tool. The regression test repeats a 1 ms cancellation 50 times, +because a single attempt still throws most of the time even when the bug is present. -### Key Design Patterns +Process-tree termination requires .NET Core 3.0 or later; the `netstandard2.0`/`netstandard2.1` +builds can only kill the process itself. -1. **Async Over Sync**: The synchronous `Execute` methods call `ExecuteAsync().Result`, making the async implementation the source of truth. +### Elevation constraints -2. **Strategy Pattern**: OutputHandler and LineOutputHandler allow different output processing strategies to be plugged in. +Elevation forces `UseShellExecute = true`, which is incompatible with both output redirection and +setting an environment. Consequently an `OutputHandler` is silently not invoked under elevation +(documented behaviour), while combining `EnvironmentVariables` with elevation throws +`ArgumentException` up front rather than failing opaquely inside `Process.Start`. -3. **Template Method**: OutputHandler provides virtual methods that LineOutputHandler overrides to customize behavior. +### Argument escaping -4. **Buffering Strategy**: LineOutputHandler demonstrates how to buffer incomplete data across multiple chunk reads to reconstruct complete lines. +`ProcessStartInfo.ArgumentList` is unavailable on `netstandard2.0`, so that target alone falls back +to a hand-written `EscapeArgument` implementing the `CommandLineToArgvW` quoting rules. This is one +of the few places conditional compilation is warranted. -### Multi-Targeting +### Stream reading -The library targets multiple .NET versions: -- .NET 9.0 -- .NET 8.0 -- .NET 7.0 -- .NET 6.0 -- .NET 5.0 -- .NET Standard 2.1 -- .NET Standard 2.0 +`AsyncProcessStreamReader` reads stdout and stderr concurrently with 4096-character buffers and +performs a final read after process exit, which is what ensures short-lived processes do not lose +buffered output. -Uses `ktsu.Sdk` for standardized project configuration. +### Process configuration + +On Windows, `LoadUserProfile` is set to true for proper environment variable expansion. ## Testing -Tests are located in [RunCommand.Test/](RunCommand.Test/) using MSTest framework: -- [RunCommandTests.cs](RunCommand.Test/RunCommandTests.cs) - Tests for main execution methods -- [LineOutputHandlerTests.cs](RunCommand.Test/LineOutputHandlerTests.cs) - Tests for line buffering logic -- Tests target .NET 9.0 only -- Uses MSTest.Sdk for test execution +Tests live in `RunCommand.Test/` and use MSTest: + +- `RunCommandTests.cs` - Execution, output capture, elevation, working directory, environment variables, and cancellation +- `LineOutputHandlerTests.cs` - Line buffering across chunk boundaries + +Patterns worth preserving: + +- Commands are chosen per-platform through helpers (`GetSleepCommand`, `GetPrintWorkingDirectoryCommand`, `GetPrintEnvironmentVariableCommand`) rather than hard-coded, so the suite runs on Windows and Unix. +- Tests run in parallel at method level, so anything touching process-global or filesystem state derives a unique name from `[CallerMemberName]`. +- The tests covering the obsolete command-string overloads sit inside a single `#pragma warning disable CS0618` region that ends after the last of them. Keep the region tight; do not promote it to file scope. ## Version Management -The project uses semantic versioning with git-based version calculation: +Semantic versioning with git-based version calculation: + - Version tags in commit messages: `[major]`, `[minor]`, `[patch]`, `[pre]` - Public API changes are automatically detected and trigger minor version bumps -- VERSION.md, CHANGELOG.md, and other metadata files are auto-generated by PSBuild module - -## Important Implementation Notes +- VERSION.md, CHANGELOG.md, and LICENSE.md are auto-generated — never edit them manually -### Command Parsing -Commands are split on the first space character. The first part becomes the filename, the rest becomes arguments. Be aware this simple parsing doesn't handle quoted strings specially. +## CI/CD -### Process Configuration -On Windows, `LoadUserProfile` is set to true for proper environment variable expansion. +Uses `scripts/PSBuild.psm1` PowerShell module for CI pipeline. Version increments are controlled by commit message tags: `[major]`, `[minor]`, `[patch]`, `[pre]`. -### Stream Reading -The AsyncProcessStreamReader performs a final read after process exit to ensure all buffered data is captured. This is crucial for short-lived processes. +## Code Quality -### Encoding -All input/output streams use UTF-8 by default but can be customized via OutputHandler constructor. +Do not add global suppressions for warnings. Use explicit suppression attributes with justifications when needed, with preprocessor defines only as fallback. Make the smallest, most targeted suppressions possible. diff --git a/DESCRIPTION.md b/DESCRIPTION.md index e20b86b..7ac091e 100644 --- a/DESCRIPTION.md +++ b/DESCRIPTION.md @@ -1 +1 @@ -A library that provides an easy way to execute shell commands and handle the output via delegates with both synchronous and asynchronous support. +A .NET library for executing external commands and handling their output through delegates, with both synchronous and asynchronous APIs. Delivers standard output and standard error as raw chunks or complete lines as the process produces them, passes arguments as a vector so paths with spaces need no quoting, and shapes the spawned process with a working directory, an environment variable overlay, Windows elevation, and cancellation that terminates the process tree. diff --git a/README.md b/README.md index 66a5c5a..e67f772 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ktsu.RunCommand -A library that provides an easy way to execute a shell command and handle the output via delegates. It supports both synchronous and asynchronous execution with customizable output handling. +> A .NET library for executing external commands and handling their output through delegates, with synchronous and asynchronous APIs, cancellation, and control over the spawned process. [![License](https://img.shields.io/github/license/ktsu-dev/RunCommand.svg?label=License&logo=nuget)](LICENSE.md) [![NuGet Version](https://img.shields.io/nuget/v/ktsu.RunCommand?label=Stable&logo=nuget)](https://nuget.org/packages/ktsu.RunCommand) @@ -10,21 +10,50 @@ A library that provides an easy way to execute a shell command and handle the ou [![GitHub contributors](https://img.shields.io/github/contributors/ktsu-dev/RunCommand?label=Contributors&logo=github)](https://github.com/ktsu-dev/RunCommand/graphs/contributors) [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ktsu-dev/RunCommand/dotnet.yml?branch=main&label=Build&logo=github)](https://github.com/ktsu-dev/RunCommand/actions) +## Introduction + +`ktsu.RunCommand` runs an external command and hands you its output as it arrives, instead of making you assemble `Process`, `ProcessStartInfo`, redirected streams and exit-code plumbing yourself. Output is delivered through delegates — either as raw chunks exactly as the process emits them, or buffered into complete lines — and every method returns the process exit code. + +Arguments are passed as a vector rather than as one string, so a path containing spaces needs no manual quoting and cannot be mis-split. The process itself can be shaped through a working directory and an environment variable overlay, run elevated on Windows, and terminated along with its children through a cancellation token. + +## Features + +- **Delegate-based output**: Receive standard output and standard error through `Action` delegates as the process produces them, rather than waiting for it to exit. +- **Raw or line-buffered**: `OutputHandler` delivers undelimited chunks exactly as they arrive; `LineOutputHandler` buffers across chunks and raises one call per complete line. +- **Synchronous and asynchronous**: Every operation is available as both `Execute` and `ExecuteAsync`, with the asynchronous implementation as the single source of truth. +- **Quote-free arguments**: Pass the executable and each argument separately, so spaces in paths and arguments are handled by the platform rather than by string concatenation. +- **Working directory**: Start the process in a specific directory without mutating the process-global current directory. +- **Environment variables**: Apply an overlay over the inherited environment for a single call, adding, overriding, or removing individual variables. +- **Cancellation**: A signalled `CancellationToken` terminates the process and always surfaces as an `OperationCanceledException`, never as a synthetic exit code. +- **Windows elevation**: Launch through the `runas` verb for a UAC-elevated process. +- **Custom encoding**: Decode the output streams with any `Encoding`; defaults to UTF-8. +- **Broad target support**: .NET Standard 2.0 and 2.1 through .NET 10. + ## Installation -To install RunCommand, you can use the .NET CLI: +### Package Manager Console + +```powershell +Install-Package ktsu.RunCommand +``` + +### .NET CLI ```bash dotnet add package ktsu.RunCommand ``` -Or you can use the NuGet Package Manager in Visual Studio to search for and install the ktsu.RunCommand package. +### Package Reference -## Usage +```xml + +``` + +## Usage Examples -### Basic Execution +### Basic Example -The simplest way to execute a command is to use the `Execute` method, passing the executable and its arguments separately. All methods return the process exit code: +Pass the executable and its arguments separately. All methods return the process exit code: ```csharp using ktsu.RunCommand; @@ -47,35 +76,9 @@ class Program } ``` -### Deprecated: single command strings - -The overloads taking one `command` string are obsolete. They separate the executable from its arguments by splitting on the **first space**, which cannot represent an executable path that itself contains a space — on Windows that includes anything under `C:\Program Files\`: - -```csharp -// Obsolete, and broken: splits into "C:\Program" plus "Files\Git\bin\git.exe --version" -await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe --version"); - -// Correct -await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe", ["--version"]); -``` - -Quoting does not rescue it, because the split happens before any quote handling. The string form is inherently ambiguous — no parse handles every combination of spaces and quotes without adopting a shell's full grammar — so rather than grow a half-grammar that moves the surprise elsewhere, these overloads are deprecated in favour of the argument-list ones, which have no such ambiguity because the executable is passed separately. - -Migration is mechanical: split the string yourself at the boundaries you meant. - -| Obsolete | Replacement | -| --- | --- | -| `Execute(command)` | `Execute(fileName, arguments)` | -| `Execute(command, outputHandler)` | `Execute(fileName, arguments, outputHandler)` | -| `Execute(command, elevation)` | `Execute(fileName, arguments, outputHandler, options)` | -| `ExecuteAsync(command)` | `ExecuteAsync(fileName, arguments)` | -| `ExecuteAsync(command, outputHandler)` | `ExecuteAsync(fileName, arguments, outputHandler)` | -| `ExecuteAsync(command, cancellationToken)` | `ExecuteAsync(fileName, arguments, outputHandler, cancellationToken)` | -| `ExecuteAsync(command, outputHandler, elevation, cancellationToken)` | `ExecuteAsync(fileName, arguments, outputHandler, options, cancellationToken)` | - ### Custom Output Handling -To handle the output of the command, you can provide delegates to the `OutputHandler` class: +To handle the output of the command, provide delegates to the `OutputHandler` class: ```csharp using ktsu.RunCommand; @@ -98,11 +101,11 @@ class Program } ``` -> **_NOTE:_** _When using the default OutputHandler, the delegates will receive undelimited chunks of output. This gives you the flexibility to receive exactly the output the command produces, including whitespace and non-printable characters, and handle it as you see fit._ +> **_NOTE:_** _When using the default `OutputHandler`, the delegates receive undelimited chunks of output. This gives you exactly what the command produces, including whitespace and non-printable characters, to handle as you see fit._ ### Line-by-Line Output Handling -If you prefer to handle the output line by line, you can use the `LineOutputHandler` class: +To handle the output one line at a time, use the `LineOutputHandler` class: ```csharp using ktsu.RunCommand; @@ -127,7 +130,7 @@ class Program ### Asynchronous Execution -All of the above examples can be executed asynchronously by using the `ExecuteAsync` method: +All of the above examples can be run asynchronously with `ExecuteAsync`: ```csharp using ktsu.RunCommand; @@ -138,43 +141,45 @@ class Program { int exitCode = await RunCommand.ExecuteAsync("dotnet", ["--version"]); - if (exitCode == 0) - { - Console.WriteLine("Command executed successfully!"); - } - else - { - Console.WriteLine($"Command failed with exit code: {exitCode}"); - } + Console.WriteLine($"Process exited with code: {exitCode}"); } } ``` -## Elevation (Windows) +### Cancellation -If you need to run a command with elevated privileges, pass `Elevation.Elevated`. On Windows this launches the process with the `runas` verb, which triggers a UAC prompt: +Passing a `CancellationToken` terminates the process when the token is signalled: ```csharp using ktsu.RunCommand; class Program { - static void Main() + static async Task Main() { - int exitCode = RunCommand.Execute( - fileName: "powershell", - arguments: ["-Command", "Get-Service"], - outputHandler: new(), - options: new() { Elevation = Elevation.Elevated }); + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(30)); - Console.WriteLine($"Process exited with code: {exitCode}"); + try + { + int exitCode = await RunCommand.ExecuteAsync( + fileName: "dotnet", + arguments: ["build"], + outputHandler: new LineOutputHandler(onStandardOutput: Console.WriteLine), + cancellationToken: cancellation.Token); + + Console.WriteLine($"Process exited with code: {exitCode}"); + } + catch (OperationCanceledException) + { + Console.WriteLine("The command was cancelled."); + } } } ``` -> **_NOTE:_** _Output redirection is incompatible with `runas`, so an `OutputHandler` passed alongside `Elevation.Elevated` will **not** be invoked. You still get the process exit code._ +A cancelled call always throws `OperationCanceledException` — it never returns the killed process's exit code — so cancellation cannot be mistaken for a genuine failure of the command. -On non-Windows platforms `Elevation.Elevated` is a no-op — prefix your command with `sudo` yourself if you need elevation there. +On .NET Core 3.0 and later the entire process tree is terminated. On .NET Standard 2.0 and 2.1 only the process itself can be terminated, so any grandchildren it spawned are left running. ## Process Options @@ -207,8 +212,16 @@ class Program } ``` +`CommandOptions.Elevation` carries the privilege level too, so a single options object replaces the separate `Elevation` argument. + +### Working Directory + Without a `WorkingDirectory` the process inherits the current directory of the calling process, which is what commands did before this option existed. +The type is `AbsoluteDirectoryPath` rather than a string on purpose. A relative directory would have to be resolved against the caller's current directory — the process-global state this option exists to avoid depending on, since it is shared by every thread and races with concurrent calls. + +### Environment Variables + `EnvironmentVariables` is an overlay on the inherited environment, not a replacement: a name you do not list keeps whatever the calling process had. A `null` value removes a variable, which is how you unset something the parent had set: ```csharp @@ -222,13 +235,35 @@ Environment variables are the only control surface some tools expose, so this co > **_NOTE:_** _`EnvironmentVariables` cannot be combined with `Elevation.Elevated` on Windows. Elevation requires `UseShellExecute`, which offers nowhere to pass an environment, so the call throws `ArgumentException` rather than silently dropping the variables._ -The type is `AbsoluteDirectoryPath` rather than a string on purpose. A relative directory would have to be resolved against the caller's current directory — the process-global state this option exists to avoid depending on, since it is shared by every thread and races with concurrent calls. +## Elevation (Windows) + +To run a command with elevated privileges, set `Elevation.Elevated`. On Windows this launches the process with the `runas` verb, which triggers a UAC prompt: + +```csharp +using ktsu.RunCommand; + +class Program +{ + static void Main() + { + int exitCode = RunCommand.Execute( + fileName: "powershell", + arguments: ["-Command", "Get-Service"], + outputHandler: new(), + options: new() { Elevation = Elevation.Elevated }); -`CommandOptions.Elevation` carries the privilege level, so a single options object replaces the separate `Elevation` argument. + Console.WriteLine($"Process exited with code: {exitCode}"); + } +} +``` + +> **_NOTE:_** _Output redirection is incompatible with `runas`, so an `OutputHandler` passed alongside `Elevation.Elevated` will **not** be invoked. You still get the process exit code._ + +On non-Windows platforms `Elevation.Elevated` is a no-op — prefix your command with `sudo` yourself if you need elevation there. ## Encoding -By default, the library uses the UTF-8 encoding for the input and output streams. If you need to use a different encoding, you can specify it in the `OutputHandler` or `LineOutputHandler` constructor: +By default the library decodes the output streams as UTF-8. To use a different encoding, specify it in the `OutputHandler` or `LineOutputHandler` constructor: ```csharp using System.Text; @@ -251,59 +286,105 @@ class Program } ``` +## Deprecated: Single Command Strings + +The overloads taking one `command` string are obsolete. They separate the executable from its arguments by splitting on the **first space**, which cannot represent an executable path that itself contains a space — on Windows that includes anything under `C:\Program Files\`: + +```csharp +// Obsolete, and broken: splits into "C:\Program" plus "Files\Git\bin\git.exe --version" +await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe --version"); + +// Correct +await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe", ["--version"]); +``` + +Quoting does not rescue it, because the split happens before any quote handling. The string form is inherently ambiguous — no parse handles every combination of spaces and quotes without adopting a shell's full grammar — so rather than grow a half-grammar that moves the surprise elsewhere, these overloads are deprecated in favour of the argument-list ones, which have no such ambiguity because the executable is passed separately. + +Migration is mechanical: split the string yourself at the boundaries you meant. + +| Obsolete | Replacement | +| --- | --- | +| `Execute(command)` | `Execute(fileName, arguments)` | +| `Execute(command, outputHandler)` | `Execute(fileName, arguments, outputHandler)` | +| `Execute(command, elevation)` | `Execute(fileName, arguments, outputHandler, options)` | +| `ExecuteAsync(command)` | `ExecuteAsync(fileName, arguments)` | +| `ExecuteAsync(command, outputHandler)` | `ExecuteAsync(fileName, arguments, outputHandler)` | +| `ExecuteAsync(command, cancellationToken)` | `ExecuteAsync(fileName, arguments, outputHandler, cancellationToken)` | +| `ExecuteAsync(command, outputHandler, elevation, cancellationToken)` | `ExecuteAsync(fileName, arguments, outputHandler, options, cancellationToken)` | + ## API Reference -### RunCommand Class +### `RunCommand` -Passing the executable and its arguments separately: +Static class providing the command execution API. Every method returns the process exit code. -- `Execute(string fileName, IEnumerable arguments)`: Executes a command synchronously and returns the process exit code. -- `Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler)`: Executes a command synchronously with custom output handling. -- `ExecuteAsync(string fileName, IEnumerable arguments)`: The asynchronous equivalent. -- `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler)`: The asynchronous equivalent with custom output handling. -- `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CancellationToken cancellationToken)`: As above, terminating the process and its children if the token is signalled. +#### Methods -**Obsolete** — see [Deprecated: single command strings](#deprecated-single-command-strings): +| Name | Return Type | Description | +|------|-------------|-------------| +| `Execute(string fileName, IEnumerable arguments)` | `int` | Executes a command synchronously. | +| `Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler)` | `int` | Executes a command synchronously with custom output handling. | +| `Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options)` | `int` | Executes a command synchronously with the given process options. | +| `ExecuteAsync(string fileName, IEnumerable arguments)` | `Task` | Executes a command asynchronously. | +| `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler)` | `Task` | Executes a command asynchronously with custom output handling. | +| `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CancellationToken cancellationToken)` | `Task` | As above, terminating the process and its children if the token is signalled. | +| `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken)` | `Task` | As above, at the given elevation level. | +| `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options)` | `Task` | Executes a command asynchronously with the given process options. | +| `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken)` | `Task` | As above, terminating the process and its children if the token is signalled. | -- `Execute(string command)`, `Execute(string command, OutputHandler outputHandler)`, `Execute(string command, Elevation elevation)`, `Execute(string command, OutputHandler outputHandler, Elevation elevation)` -- `ExecuteAsync(string command)`, `ExecuteAsync(string command, OutputHandler outputHandler)`, `ExecuteAsync(string command, Elevation elevation)`, `ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation)`, `ExecuteAsync(string command, CancellationToken cancellationToken)`, `ExecuteAsync(string command, OutputHandler outputHandler, CancellationToken cancellationToken)`, `ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken)` -- `Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options)`: Executes a command synchronously with the given process options, passing arguments individually so no manual quoting is required. -- `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options)`: The asynchronous equivalent. -- `ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken)`: As above, terminating the process and its children if the token is signalled. +The overloads taking a single `command` string — four `Execute` and seven `ExecuteAsync` — are **obsolete**. See [Deprecated: Single Command Strings](#deprecated-single-command-strings) for the migration table. -### CommandOptions Record +### `CommandOptions` -- `WorkingDirectory`: An `AbsoluteDirectoryPath` naming the directory the process starts in, or `null` to inherit the caller's current directory. -- `EnvironmentVariables`: An `IReadOnlyDictionary` applied over the inherited environment, or `null` to inherit it unchanged. A `null` value removes a variable. -- `Elevation`: The privilege level under which to run the command. Defaults to `Elevation.Default`. +Record describing how to shape the process a command runs in. Every member defaults to the behaviour commands had before the type existed, so an instance with nothing set is equivalent to not passing one at all. -### Elevation Enum +#### Properties -- `Elevation.Default`: Run with the current process's privileges (output is captured). -- `Elevation.Elevated`: On Windows, launch via the `runas` verb (UAC prompt); output is **not** captured. No-op on non-Windows. +| Name | Type | Description | +|------|------|-------------| +| `WorkingDirectory` | `AbsoluteDirectoryPath?` | The directory the process starts in, or `null` to inherit the caller's current directory. | +| `EnvironmentVariables` | `IReadOnlyDictionary?` | Variables applied over the inherited environment, or `null` to inherit it unchanged. A `null` value removes a variable. | +| `Elevation` | `Elevation` | The privilege level under which to run the command. Defaults to `Elevation.Default`. | -- ### OutputHandler Class +### `OutputHandler` -Processes output in raw chunks: +Processes output in raw, undelimited chunks as they arrive from the process. -- `OutputHandler(onStandardOutput, onStandardError)`: Constructor with handlers for output and error streams. +#### Constructor -### LineOutputHandler Class +| Name | Description | +|------|-------------| +| `OutputHandler(Action? onStandardOutput = null, Action? onStandardError = null, Encoding? encoding = null)` | Creates a handler with delegates for the output and error streams. `encoding` defaults to UTF-8. | -Processes output line by line: +#### Properties -- `LineOutputHandler(onStandardOutput, onStandardError)`: Constructor with handlers for output and error streams. +| Name | Type | Description | +|------|------|-------------| +| `Encoding` | `Encoding` | The encoding used to decode the process's output streams. | -> **_NOTE:_** _The `OutputHandler` classes receive undelimited chunks of output directly from the process stream. The `LineOutputHandler` buffers this output and splits it by newline characters, invoking the delegates for each complete line._ +### `LineOutputHandler` -## License +Inherits from `OutputHandler` and buffers incoming chunks, invoking the delegates once per complete line. Incomplete trailing data is held until the rest of the line arrives. + +#### Constructor + +| Name | Description | +|------|-------------| +| `LineOutputHandler(Action? onStandardOutput = null, Action? onStandardError = null, Encoding? encoding = null)` | Creates a line-buffering handler with delegates for the output and error streams. | -This project is licensed under the MIT License. See the [LICENSE](LICENSE.md) file for details. +### `Elevation` + +Enum specifying the privilege level under which a command runs. + +| Name | Description | +|------|-------------| +| `Default` | Run with the current process's privileges. Standard output and standard error are captured. | +| `Elevated` | On Windows, launch through the `runas` verb, prompting for UAC consent; output is **not** captured. No effect on non-Windows platforms. | ## Contributing -Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. +Contributions are welcome! Feel free to open issues or submit pull requests. -## Acknowledgements +## License -Thanks to the .NET community and ktsu.dev contributors for their support. +This project is licensed under the MIT License. See the [LICENSE.md](LICENSE.md) file for details. diff --git a/TAGS.md b/TAGS.md index 2deb5e3..9781c85 100644 --- a/TAGS.md +++ b/TAGS.md @@ -1 +1 @@ -run command;shell command;process execution;output handling;async execution;cli;dotnet;csharp \ No newline at end of file +.NET;C#;dotnet;csharp;run command;shell command;command line;process;process execution;subprocess;child process;output handling;stdout;stderr;output redirection;async execution;asynchronous;cancellation;working directory;environment variables;elevation;uac;cli;exit code;delegates