using System.Diagnostics; using System.Text; using NLog; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Helper; namespace StabilityMatrix.Core.Processes; public record struct ProcessResult(int ExitCode, string? StandardOutput, string? StandardError); public static class ProcessRunner { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); /// /// Opens the given URL in the default browser. /// /// URL as string public static void OpenUrl(string url) { Logger.Debug($"Opening URL '{url}'"); Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } /// /// Opens the given URL in the default browser. /// /// URI, using AbsoluteUri component public static void OpenUrl(Uri url) { OpenUrl(url.AbsoluteUri); } /// /// Starts and tracks a process. /// private static Process StartTrackedProcess(Process process) { process.Start(); // Currently only supported on Windows if (Compat.IsWindows) { // Supress errors here since the process may have already exited try { ProcessTracker.AddProcess(process); } catch (InvalidOperationException) { } } return process; } public static async Task GetProcessOutputAsync(string fileName, string arguments) { Logger.Debug($"Starting process '{fileName}' with arguments '{arguments}'"); var info = new ProcessStartInfo { FileName = fileName, Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true }; using var process = new Process(); process.StartInfo = info; StartTrackedProcess(process); var output = await process.StandardOutput.ReadToEndAsync(); await process.WaitForExitAsync(); return output; } public static Process StartProcess( string fileName, string arguments, string? workingDirectory = null, Action? outputDataReceived = null, Dictionary? environmentVariables = null) { Logger.Debug($"Starting process '{fileName}' with arguments '{arguments}'"); var info = new ProcessStartInfo { FileName = fileName, Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; if (environmentVariables != null) { foreach (var (key, value) in environmentVariables) { info.EnvironmentVariables[key] = value; } } if (workingDirectory != null) { info.WorkingDirectory = workingDirectory; } var process = new AnsiProcess(info); StartTrackedProcess(process); if (outputDataReceived != null) { process.BeginAnsiRead(outputDataReceived); } return process; } public static Process StartProcess( string fileName, IEnumerable arguments, string? workingDirectory = null, Action? outputDataReceived = null, Dictionary? environmentVariables = null) { // Quote arguments containing spaces var args = string.Join(" ", arguments.Select(Quote)); return StartProcess(fileName, args, workingDirectory, outputDataReceived, environmentVariables); } public static async Task RunBashCommand(string command, string workingDirectory = "") { var processInfo = new ProcessStartInfo("bash", "-c \"" + command + "\"") { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = workingDirectory, }; using var process = new Process(); process.StartInfo = processInfo; var stdout = new StringBuilder(); var stderr = new StringBuilder(); process.OutputDataReceived += (_, args) => stdout.Append(args.Data); process.ErrorDataReceived += (_, args) => stderr.Append(args.Data); StartTrackedProcess(process); process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync(); return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString()); } public static Task RunBashCommand( IEnumerable commands, string workingDirectory = "") { // Quote arguments containing spaces var args = string.Join(" ", commands.Select(Quote)); return RunBashCommand(args, workingDirectory); } /// /// Quotes argument with double quotes if it contains spaces, /// and does not already start and end with double quotes. /// public static string Quote(string argument) { var inner = argument.Trim('"'); return inner.Contains(' ') ? $"\"{inner}\"" : argument; } /// /// Check if the process exited with the expected exit code. /// /// Process to check. /// Expected exit code. /// Process stdout. /// Process stderr. /// Thrown if exit code does not match expected value. // ReSharper disable once MemberCanBePrivate.Global public static Task ValidateExitConditionAsync(Process process, int expectedExitCode = 0, string? stdout = null, string? stderr = null) { var exitCode = process.ExitCode; if (exitCode == expectedExitCode) { return Task.CompletedTask; } var pName = process.StartInfo.FileName; var msg = $"Process {pName} failed with exit-code {exitCode}. stdout: '{stdout}', stderr: '{stderr}'"; Logger.Error(msg); throw new ProcessException(msg); } /// /// Waits for process to exit, then validates exit code. /// /// Process to check. /// Expected exit code. /// Cancellation token. /// Thrown if exit code does not match expected value. public static async Task WaitForExitConditionAsync(Process process, int expectedExitCode = 0, CancellationToken cancelToken = default) { var stdout = new StringBuilder(); var stderr = new StringBuilder(); process.OutputDataReceived += (_, args) => stdout.Append(args.Data); process.ErrorDataReceived += (_, args) => stderr.Append(args.Data); await process.WaitForExitAsync(cancelToken); await ValidateExitConditionAsync(process, expectedExitCode, stdout.ToString(), stderr.ToString()); } }