// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Diagnostics; namespace ReleaseBuilder; /// /// Helper methods for executing a commandline program /// public static class ProcessHelper { /// /// Starts a commandline program and waits for it to complete /// /// /// The working directory to run in; null means current directory /// The cancellation token /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console /// Function to write to stdin /// An awaitable task public static async Task Execute(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); var executable = command.First(); if (string.IsNullOrWhiteSpace(executable)) throw new ArgumentException("Executable name cannot be empty", nameof(command)); workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) Directory.CreateDirectory(workingDirectory); codeIsError ??= (x) => x != 0; var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrEmpty(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, RedirectStandardError = !suppressStdErr, RedirectStandardOutput = false, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); // Forward error messages to stderr var t = suppressStdErr ? Task.CompletedTask : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken); if (writeStdIn != null) await writeStdIn(p.StandardInput).ConfigureAwait(false); await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {executable} gave error code {p.ExitCode}"); await t.ConfigureAwait(false); } /// /// Runs all commandline tasks in sequence /// /// The commands to run /// The working directory to run in; null means current directory /// The cancellation token /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console /// An awaitable task public static async Task ExecuteAll(IEnumerable> commands, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false) { foreach (var c in commands) await Execute(c, workingDirectory, cancellationToken, codeIsError, suppressStdErr).ConfigureAwait(false); } /// /// Starts a commandline program and returns the contents of stdout /// /// /// The working directory to run in; null means current directory /// The cancellation token /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console /// Function to write to stdin /// The output from stdout public static async Task ExecuteWithOutput(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); var executable = command.First(); if (string.IsNullOrWhiteSpace(executable)) throw new ArgumentException("Executable name cannot be empty", nameof(command)); workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) Directory.CreateDirectory(workingDirectory); codeIsError ??= (x) => x != 0; var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrEmpty(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, RedirectStandardError = !suppressStdErr, RedirectStandardOutput = true, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); var tstdout = p.StandardOutput.ReadToEndAsync(cancellationToken); var tstderr = suppressStdErr ? Task.CompletedTask : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken); if (writeStdIn != null) await writeStdIn(p.StandardInput).ConfigureAwait(false); await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {executable} gave error code {p.ExitCode}"); await tstderr.ConfigureAwait(false); return await tstdout.ConfigureAwait(false); } /// /// Starts a commandline program and returns the contents of stdout /// /// /// The stream to write the output to /// The working directory to run in; null means current directory /// The cancellation token /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// Default value is null which will treat anything non-zero as an error /// If true, stderr is not forwarded to the console /// Function to write to stdin /// The output from stdout public static async Task ExecuteWithOutput(IEnumerable command, Stream stdout, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, bool suppressStdErr = false, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); var executable = command.First(); if (string.IsNullOrWhiteSpace(executable)) throw new ArgumentException("Executable name cannot be empty", nameof(command)); workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) Directory.CreateDirectory(workingDirectory); codeIsError ??= (x) => x != 0; var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, RedirectStandardError = !suppressStdErr, RedirectStandardOutput = true, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); var tstdout = p.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken); var tstderr = suppressStdErr ? Task.CompletedTask : p.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError(), cancellationToken); if (writeStdIn != null) await writeStdIn(p.StandardInput).ConfigureAwait(false); await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {executable} gave error code {p.ExitCode}"); await tstderr.ConfigureAwait(false); await tstdout.ConfigureAwait(false); } /// /// Starts a commandline program and waits for it to complete /// /// /// The working directory to run in; null means current directory /// The cancellation token /// Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error. /// The folder where the log files are written /// Function to create custom filenames for the log files /// Default value is null which will treat anything non-zero as an error /// Function to write to stdin /// The output from stdout public static async Task ExecuteWithLog(IEnumerable command, string? workingDirectory = null, CancellationToken cancellationToken = default, Func? codeIsError = null, string? logFolder = null, Func? logFilename = null, Func? writeStdIn = null) { if (!command.Any()) throw new ArgumentException("Needs at least one command", nameof(command)); var executable = command.First(); if (string.IsNullOrWhiteSpace(executable)) throw new ArgumentException("Executable name cannot be empty", nameof(command)); workingDirectory ??= Environment.CurrentDirectory; if (!Directory.Exists(workingDirectory)) Directory.CreateDirectory(workingDirectory); logFolder ??= workingDirectory; codeIsError ??= (x) => x != 0; var p = Process.Start(new ProcessStartInfo(executable, command.Skip(1).Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!)) { WindowStyle = ProcessWindowStyle.Hidden, WorkingDirectory = workingDirectory, RedirectStandardError = true, RedirectStandardOutput = true, RedirectStandardInput = writeStdIn != null, UseShellExecute = false, }) ?? throw new Exception($"Failed to launch process {executable}, null returned"); logFilename ??= (pid, isStdOut) => $"{executable}-{p.Id}.{(isStdOut ? "stdout" : "stderr")}.log"; using var logstdout = File.Create(Path.Combine(logFolder, logFilename(p.Id, true))); using var logstderr = File.Create(Path.Combine(logFolder, logFilename(p.Id, false))); var t1 = p.StandardOutput.BaseStream.CopyToAsync(logstdout, cancellationToken); var t2 = p.StandardError.BaseStream.CopyToAsync(logstderr, cancellationToken); if (writeStdIn != null) await writeStdIn(p.StandardInput).ConfigureAwait(false); await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (codeIsError(p.ExitCode)) throw new Exception($"Execution of {executable} gave error code {p.ExitCode}, see log file {Path.Combine(logFolder, logFilename(p.Id, true))}"); await t1.ConfigureAwait(false); await t2.ConfigureAwait(false); } }