From c99e7174b46445a0d61eeaaa5921c0eda208863c Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 19 Aug 2026 20:20:09 +1000 Subject: [PATCH] [minor] Add CommandOptions with a working directory for the spawned process ProcessStartInfo.WorkingDirectory was never assigned, so every command inherited the host process's current directory. Callers wanting to drive a tool somewhere else were pushed onto a tool-specific flag where one exists, onto mutating the process-global Environment.CurrentDirectory, or onto shelling out through cmd /c cd, which reintroduces the quoting problems the argument-vector overloads exist to avoid. Introduce CommandOptions to carry process-shaping settings, and add three overloads on the argument-vector shape that take it. All fourteen existing overloads keep their signatures and delegate through it, so nothing existing changes behaviour. WorkingDirectory is an AbsoluteDirectoryPath rather than a string: a relative directory would have to be resolved against the caller's current directory, which is the process-global state the option exists to stop callers depending on. This adds ktsu.Semantics.Paths and ktsu.Semantics.Strings as package references, matching how AppDataStorage, SingleAppInstance and Schema consume them. Fixes #39 --- Directory.Packages.props | 2 + README.md | 40 +++++++++++++++ RunCommand.Test/RunCommand.Test.csproj | 2 + RunCommand.Test/RunCommandTests.cs | 64 ++++++++++++++++++++++++ RunCommand/CommandOptions.cs | 31 ++++++++++++ RunCommand/RunCommand.cs | 67 ++++++++++++++++++++++++-- RunCommand/RunCommand.csproj | 2 + 7 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 RunCommand/CommandOptions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index c8f6ea5..9af2ba3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,8 @@ true + + diff --git a/README.md b/README.md index 71738d6..c456b40 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 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. + +### 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 diff --git a/RunCommand.Test/RunCommand.Test.csproj b/RunCommand.Test/RunCommand.Test.csproj index 89b68ce..554d565 100644 --- a/RunCommand.Test/RunCommand.Test.csproj +++ b/RunCommand.Test/RunCommand.Test.csproj @@ -9,6 +9,8 @@ + + diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 91f1721..42093c2 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -4,6 +4,7 @@ namespace ktsu.RunCommand.Test; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using ktsu.Semantics.Paths; [TestClass] public class RunCommandTests @@ -310,6 +311,18 @@ private static (string FileName, string[] Arguments) GetReadFileCommand(string p ? ("certutil", ["-hashfile", path, "MD5"]) : ("cat", [path]); + /// + /// Returns a command that prints the directory its process was started in. + /// + 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; + /// /// Returns a command that runs for long enough to be cancelled mid-flight. /// @@ -413,4 +426,55 @@ await Assert.ThrowsAsync( $"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 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 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( + () => RunCommand.ExecuteAsync("dotnet", ["--version"], new OutputHandler(), null!)).ConfigureAwait(false); + } } diff --git a/RunCommand/CommandOptions.cs b/RunCommand/CommandOptions.cs new file mode 100644 index 0000000..7d13390 --- /dev/null +++ b/RunCommand/CommandOptions.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.RunCommand; + +using ktsu.Semantics.Paths; + +/// +/// Describes how to shape the process a command runs in, beyond the executable and its arguments. +/// +/// +/// 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. +/// +public sealed record CommandOptions +{ + /// + /// Gets the directory the process starts in, or to inherit the current + /// directory of the calling process. + /// + /// + /// 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. + /// + public AbsoluteDirectoryPath? WorkingDirectory { get; init; } + + /// + /// Gets the privilege level under which to run the command. + /// + public Elevation Elevation { get; init; } = Elevation.Default; +} diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 1947383..6b4add5 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -78,6 +78,22 @@ public static int Execute(string fileName, IEnumerable arguments) => public static int Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler) => ExecuteAsync(fileName, arguments, outputHandler).Result; + /// + /// Executes a shell command synchronously with an output handler and the given process options, + /// passing arguments individually so that no manual quoting is required. + /// + /// The executable to run. + /// The arguments to pass, each as a separate unquoted value. + /// + /// The handler for processing command output. Not invoked when + /// is on Windows because elevation requires UseShellExecute, + /// which is incompatible with output redirection. + /// + /// The options shaping the process the command runs in. + /// The exit code of the executed process. + public static int Execute(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options) => + ExecuteAsync(fileName, arguments, outputHandler, options).Result; + /// /// Executes a shell command asynchronously /// @@ -168,7 +184,7 @@ public static async Task ExecuteAsync(string command, OutputHandler outputH 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); @@ -228,21 +244,59 @@ public static async Task ExecuteAsync(string fileName, IEnumerable /// A task representing the asynchronous operation with the process exit code. /// The token was cancelled before the process exited. public static async Task ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken) + => await ExecuteAsync(fileName, arguments, outputHandler, new CommandOptions { Elevation = elevation }, cancellationToken).ConfigureAwait(false); + + /// + /// Executes a command asynchronously with an output handler and the given process options, + /// passing arguments individually so that no manual quoting is required. + /// + /// The executable to run. + /// The arguments to pass, each as a separate unquoted value. + /// + /// The handler for processing command output. Not invoked when + /// is on Windows because elevation requires UseShellExecute, + /// which is incompatible with output redirection. + /// + /// The options shaping the process the command runs in. + /// A task representing the asynchronous operation with the process exit code. + public static async Task ExecuteAsync(string fileName, IEnumerable arguments, OutputHandler outputHandler, CommandOptions options) + => await ExecuteAsync(fileName, arguments, outputHandler, options, CancellationToken.None).ConfigureAwait(false); + + /// + /// 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. + /// + /// The executable to run. + /// The arguments to pass, each as a separate unquoted value. + /// + /// The handler for processing command output. Not invoked when + /// is on Windows because elevation requires UseShellExecute, + /// which is incompatible with output redirection. + /// + /// The options shaping the process the command runs in. + /// + /// A token that, when cancelled, terminates the running process and its children. + /// + /// A task representing the asynchronous operation with the process exit code. + /// The token was cancelled before the process exited. + public static async Task ExecuteAsync(string fileName, IEnumerable 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() { @@ -250,6 +304,11 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o CreateNoWindow = true, }; + if (options.WorkingDirectory is not null) + { + startInfo.WorkingDirectory = options.WorkingDirectory.WeakString; + } + if (useElevation) { startInfo.UseShellExecute = true; diff --git a/RunCommand/RunCommand.csproj b/RunCommand/RunCommand.csproj index cf8181d..31c34ed 100644 --- a/RunCommand/RunCommand.csproj +++ b/RunCommand/RunCommand.csproj @@ -8,6 +8,8 @@ + +