Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="ktsu.Semantics.Paths" Version="3.0.1" />
<PackageVersion Include="ktsu.Semantics.Strings" Version="3.0.1" />
<PackageVersion Include="Polyfill" Version="11.2.0" />
<PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ class Program

On non-Windows platforms `Elevation.Elevated` is a no-op — prefix your command with `sudo` yourself if you need elevation there.

## Process Options

`CommandOptions` shapes the process a command runs in. Pass it alongside an executable and its arguments:

```csharp
using ktsu.RunCommand;
using ktsu.Semantics.Paths;

class Program
{
static async Task Main()
{
int exitCode = await RunCommand.ExecuteAsync(
fileName: "git",
arguments: ["status", "--short"],
outputHandler: new LineOutputHandler(onStandardOutput: Console.WriteLine),
options: new()
{
WorkingDirectory = AbsoluteDirectoryPath.Create(@"C:\repos\my project"),
});

Console.WriteLine($"Process exited with code: {exitCode}");
}
}
```

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.

`CommandOptions.Elevation` carries the privilege level, so a single options object replaces the separate `Elevation` argument.

## 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:
Expand Down Expand Up @@ -180,6 +212,14 @@ class Program
- `ExecuteAsync(string command, OutputHandler outputHandler)`: Executes a command asynchronously with custom output handling and returns a task with the process exit code.
- `ExecuteAsync(string command, Elevation elevation)`: Executes a command asynchronously at the given elevation level.
- `ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation)`: Executes a command asynchronously with custom output handling at the given elevation level.
- `Execute(string fileName, IEnumerable<string> 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<string> arguments, OutputHandler outputHandler, CommandOptions options)`: The asynchronous equivalent.
- `ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken)`: As above, terminating the process and its children if the token is signalled.

### CommandOptions Record

- `WorkingDirectory`: An `AbsoluteDirectoryPath` naming the directory the process starts in, or `null` to inherit the caller's current directory.
- `Elevation`: The privilege level under which to run the command. Defaults to `Elevation.Default`.

### Elevation Enum

Expand Down
2 changes: 2 additions & 0 deletions RunCommand.Test/RunCommand.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="ktsu.Semantics.Paths" />
<PackageReference Include="ktsu.Semantics.Strings" />
<ProjectReference Include="..\RunCommand\RunCommand.csproj" />
</ItemGroup>

Expand Down
64 changes: 64 additions & 0 deletions RunCommand.Test/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace ktsu.RunCommand.Test;

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ktsu.Semantics.Paths;

[TestClass]
public class RunCommandTests
Expand Down Expand Up @@ -310,6 +311,18 @@ private static (string FileName, string[] Arguments) GetReadFileCommand(string p
? ("certutil", ["-hashfile", path, "MD5"])
: ("cat", [path]);

/// <summary>
/// Returns a command that prints the directory its process was started in.
/// </summary>
private static (string FileName, string[] Arguments) GetPrintWorkingDirectoryCommand() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? ("cmd", ["/c", "cd"])
: ("pwd", []);

// The caller name keeps each test on its own directory, since tests run in parallel.
private static string CreateDirectoryForTest([CallerMemberName] string caller = "") =>
Directory.CreateDirectory(Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)} {caller}")).FullName;

/// <summary>
/// Returns a command that runs for long enough to be cancelled mid-flight.
/// </summary>
Expand Down Expand Up @@ -413,4 +426,55 @@ await Assert.ThrowsAsync<OperationCanceledException>(
$"Attempt {attempt} returned an exit code instead of throwing.").ConfigureAwait(false);
}
}

[TestMethod]
public async Task ExecuteAsyncShouldStartTheProcessInTheGivenWorkingDirectory()
{
string directory = CreateDirectoryForTest();
(string fileName, string[] arguments) = GetPrintWorkingDirectoryCommand();
List<string> output = [];

int exitCode = await RunCommand.ExecuteAsync(
fileName,
arguments,
new LineOutputHandler(onStandardOutput: output.Add),
new CommandOptions { WorkingDirectory = AbsoluteDirectoryPath.Create(directory) }).ConfigureAwait(false);

Assert.AreEqual(0, exitCode, "Expected the command to run successfully.");

// Comparing only the final segment keeps this robust where the temporary directory is
// reached through a symlink, as it is on macOS, and the process reports the resolved path
// rather than the one it was handed.
Assert.AreEqual(
Path.GetFileName(directory),
Path.GetFileName(string.Concat(output).Trim()),
"Expected the process to start in the directory it was given.");
}

[TestMethod]
public async Task ExecuteAsyncShouldInheritTheCurrentDirectoryWhenNoWorkingDirectoryIsGiven()
{
(string fileName, string[] arguments) = GetPrintWorkingDirectoryCommand();
List<string> output = [];

int exitCode = await RunCommand.ExecuteAsync(
fileName,
arguments,
new LineOutputHandler(onStandardOutput: output.Add),
new CommandOptions()).ConfigureAwait(false);

Assert.AreEqual(0, exitCode, "Expected the command to run successfully.");
Assert.AreEqual(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(Environment.CurrentDirectory)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(string.Concat(output).Trim())),
ignoreCase: RuntimeInformation.IsOSPlatform(OSPlatform.Windows),
"Expected an unset working directory to leave the previous behaviour untouched.");
}

[TestMethod]
public async Task ExecuteAsyncShouldThrowArgumentNullExceptionWhenOptionsAreNull()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => RunCommand.ExecuteAsync("dotnet", ["--version"], new OutputHandler(), null!)).ConfigureAwait(false);
}
}
31 changes: 31 additions & 0 deletions RunCommand/CommandOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.RunCommand;

using ktsu.Semantics.Paths;

/// <summary>
/// Describes how to shape the process a command runs in, beyond the executable and its arguments.
/// </summary>
/// <remarks>
/// Every member defaults to the behaviour commands had before this type existed, so an instance
/// with nothing set is equivalent to not passing one at all.
/// </remarks>
public sealed record CommandOptions
{
/// <summary>
/// Gets the directory the process starts in, or <see langword="null"/> to inherit the current
/// directory of the calling process.
/// </summary>
/// <remarks>
/// The type is deliberately absolute. A relative directory would have to be resolved against the
/// calling process's current directory, which is the process-global state this property exists
/// to stop callers depending on in the first place.
/// </remarks>
public AbsoluteDirectoryPath? WorkingDirectory { get; init; }

/// <summary>
/// Gets the privilege level under which to run the command.
/// </summary>
public Elevation Elevation { get; init; } = Elevation.Default;
}
67 changes: 63 additions & 4 deletions RunCommand/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@
public static int Execute(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler) =>
ExecuteAsync(fileName, arguments, outputHandler).Result;

/// <summary>
/// Executes a shell command synchronously with an output handler and the given process options,
/// passing arguments individually so that no manual quoting is required.
/// </summary>
/// <param name="fileName">The executable to run.</param>
/// <param name="arguments">The arguments to pass, each as a separate unquoted value.</param>
/// <param name="outputHandler">
/// The handler for processing command output. Not invoked when <see cref="CommandOptions.Elevation"/>
/// is <see cref="Elevation.Elevated"/> on Windows because elevation requires <c>UseShellExecute</c>,
/// which is incompatible with output redirection.
/// </param>
/// <param name="options">The options shaping the process the command runs in.</param>
/// <returns>The exit code of the executed process.</returns>
public static int Execute(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options) =>
ExecuteAsync(fileName, arguments, outputHandler, options).Result;

/// <summary>
/// Executes a shell command asynchronously
/// </summary>
Expand Down Expand Up @@ -168,7 +184,7 @@
string filename = commandParts[0];
string arguments = commandParts.Length > 1 ? commandParts[1] : string.Empty;

ProcessStartInfo startInfo = CreateStartInfo(filename, outputHandler, elevation, out bool useElevation);
ProcessStartInfo startInfo = CreateStartInfo(filename, outputHandler, new CommandOptions { Elevation = elevation }, out bool useElevation);
startInfo.Arguments = arguments;

return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -228,28 +244,71 @@
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
/// <exception cref="OperationCanceledException">The token was cancelled before the process exited.</exception>
public static async Task<int> ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken)
=> await ExecuteAsync(fileName, arguments, outputHandler, new CommandOptions { Elevation = elevation }, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Executes a command asynchronously with an output handler and the given process options,
/// passing arguments individually so that no manual quoting is required.
/// </summary>
/// <param name="fileName">The executable to run.</param>
/// <param name="arguments">The arguments to pass, each as a separate unquoted value.</param>
/// <param name="outputHandler">
/// The handler for processing command output. Not invoked when <see cref="CommandOptions.Elevation"/>
/// is <see cref="Elevation.Elevated"/> on Windows because elevation requires <c>UseShellExecute</c>,
/// which is incompatible with output redirection.
/// </param>
/// <param name="options">The options shaping the process the command runs in.</param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
public static async Task<int> ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options)
=> await ExecuteAsync(fileName, arguments, outputHandler, options, CancellationToken.None).ConfigureAwait(false);

/// <summary>
/// Executes a command asynchronously with an output handler and the given process options,
/// passing arguments individually so that no manual quoting is required, and cancelling the
/// process if the token is signalled.
/// </summary>
/// <param name="fileName">The executable to run.</param>
/// <param name="arguments">The arguments to pass, each as a separate unquoted value.</param>
/// <param name="outputHandler">
/// The handler for processing command output. Not invoked when <see cref="CommandOptions.Elevation"/>
/// is <see cref="Elevation.Elevated"/> on Windows because elevation requires <c>UseShellExecute</c>,
/// which is incompatible with output redirection.
/// </param>
/// <param name="options">The options shaping the process the command runs in.</param>
/// <param name="cancellationToken">
/// A token that, when cancelled, terminates the running process and its children.
/// </param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
/// <exception cref="OperationCanceledException">The token was cancelled before the process exited.</exception>
public static async Task<int> ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken)
{
Ensure.NotNull(fileName);
Ensure.NotNull(arguments);
Ensure.NotNull(outputHandler);
Ensure.NotNull(options);

ProcessStartInfo startInfo = CreateStartInfo(fileName, outputHandler, elevation, out bool useElevation);
ProcessStartInfo startInfo = CreateStartInfo(fileName, outputHandler, options, out bool useElevation);
SetArguments(startInfo, arguments);

return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false);
}

private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, Elevation elevation, out bool useElevation)
private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, CommandOptions options, out bool useElevation)
{
bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
useElevation = elevation == Elevation.Elevated && isWindows;
useElevation = options.Elevation == Elevation.Elevated && isWindows;

ProcessStartInfo startInfo = new()
{
FileName = fileName,
CreateNoWindow = true,
};

if (options.WorkingDirectory is not null)
{
startInfo.WorkingDirectory = options.WorkingDirectory.WeakString;
}

if (useElevation)
{
startInfo.UseShellExecute = true;
Expand Down Expand Up @@ -310,7 +369,7 @@
while (i < argument.Length && argument[i] == '\\')
{
backslashes++;
i++;

Check warning on line 372 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 372 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 372 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 372 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 372 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 372 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.
}

if (i == argument.Length)
Expand Down
2 changes: 2 additions & 0 deletions RunCommand/RunCommand.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="ktsu.Semantics.Paths" />
<PackageReference Include="ktsu.Semantics.Strings" />
<PackageReference Include="Polyfill" PrivateAssets="all" />
</ItemGroup>
</Project>