diff --git a/ForceOps.Lib/src/FileAndDirectoryDeleter.cs b/ForceOps.Lib/src/FileAndDirectoryDeleter.cs index 54b22bd..9015807 100644 --- a/ForceOps.Lib/src/FileAndDirectoryDeleter.cs +++ b/ForceOps.Lib/src/FileAndDirectoryDeleter.cs @@ -61,7 +61,7 @@ internal void DeleteFile(FileInfo file) { try { - return GetLockingProcessInfos(new[] { file.FullName }); + return GetLockingProcessInfos([file.FullName]); } catch (Win32Exception e) { diff --git a/ForceOps.Lib/src/ForceOpsContext/RelaunchHelpers.cs b/ForceOps.Lib/src/ForceOpsContext/RelaunchHelpers.cs index d1315dc..470630a 100644 --- a/ForceOps.Lib/src/ForceOpsContext/RelaunchHelpers.cs +++ b/ForceOps.Lib/src/ForceOpsContext/RelaunchHelpers.cs @@ -15,7 +15,7 @@ public static void RunWithRelaunchAsElevated(Action action, Func> b { var args = buildArgsForRelaunch(); var childOutputFile = GetChildOutputFile(); - args.AddRange(new[] { "2>&1", ">", childOutputFile }); + args.AddRange(["2>&1", ">", childOutputFile]); logger.Information($"Unable to perform operation as an unelevated process. Retrying as elevated and logging to \"{childOutputFile}\"."); var childProcessExitCode = forceOpsContext.relaunchAsElevated.RelaunchAsElevated(args, childOutputFile); if (childProcessExitCode != 0) diff --git a/ForceOps.Test/src/ProgramTest.cs b/ForceOps.Test/src/ProgramTest.cs index 1750da7..499c570 100644 --- a/ForceOps.Test/src/ProgramTest.cs +++ b/ForceOps.Test/src/ProgramTest.cs @@ -24,8 +24,8 @@ public void ExceptionThrownIfChildFails() var testContext = new TestContext(); testContext.forceOpsContext.processKiller = new Mock().Object; - var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext); - Assert.Equal(1, forceOps.Run()); + var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext); + Assert.Equal(1, new ForceOpsRunner(forceOps).Run()); Assert.IsType(forceOps.caughtException); testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny>(), It.IsAny()), Times.Once()); @@ -39,8 +39,8 @@ public void SuccessfulChildDoesntThrowException() testContext.relaunchAsElevatedMock.Setup(t => t.RelaunchAsElevated(It.IsAny>(), It.IsAny())).Returns(0); testContext.forceOpsContext.processKiller = new Mock().Object; - var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext); - Assert.Equal(0, forceOps.Run()); + var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext); + Assert.Equal(0, new ForceOpsRunner(forceOps).Run()); testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny>(), It.IsAny()), Times.Once()); } @@ -53,13 +53,30 @@ public void RetryDelayAndMaxRetriesWork() testContext.relaunchAsElevatedMock.Setup(t => t.RelaunchAsElevated(It.IsAny>(), It.IsAny())).Returns(0); testContext.forceOpsContext.processKiller = new Mock().Object; - var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath, "--retry-delay", "33", "--max-retries", "8" }, testContext.forceOpsContext); - Assert.Equal(0, forceOps.Run()); + var forceOps = new ForceOps(["delete", tempDirectoryPath, "--retry-delay", "33", "--max-retries", "8"], testContext.forceOpsContext); + Assert.Equal(0, new ForceOpsRunner(forceOps).Run()); testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny>(), It.IsAny()), Times.Once()); Assert.Contains("Beginning retry 1/8 in 33ms.", testContext.fakeLoggerFactory.GetAllLogsString()); } + [Fact] + public void DeleteMultipleFiles() + { + Directory.CreateDirectory(tempDirectoryPath); + var file1 = Path.Join(tempDirectoryPath, "file1"); + var file2 = Path.Join(tempDirectoryPath, "file2"); + File.Create(file1).Close(); + File.Create(file2).Close(); + Assert.True(File.Exists(file1), "file1 should exist"); + Assert.True(File.Exists(file2), "file2 should exist"); + + var forceOps = new ForceOps(["delete", file1, file2]); + Assert.Equal(0, new ForceOpsRunner(forceOps).Run()); + Assert.False(File.Exists(file1), "file1 should be deleted"); + Assert.False(File.Exists(file2), "file2 should be deleted"); + } + [Fact] public void ExceptionThrownIfAlreadyElevated() { @@ -68,8 +85,8 @@ public void ExceptionThrownIfAlreadyElevated() testContext.forceOpsContext.processKiller = new Mock().Object; testContext.elevateUtilsMock.Setup(t => t.IsProcessElevated()).Returns(true); - var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext); - Assert.Equal(1, forceOps.Run()); + var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext); + Assert.Equal(1, new ForceOpsRunner(forceOps).Run()); Assert.IsType(forceOps.caughtException); testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(It.IsAny>(), It.IsAny()), Times.Never()); @@ -84,9 +101,9 @@ public void RelaunchedProgramWorks() string exeNameOverride = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "forceops.exe"); testContext.forceOpsContext.relaunchAsElevated = new RelaunchAsElevated() { verb = "", exeNameOverride = exeNameOverride }; - var forceOps = new ForceOps(new[] { "delete", tempDirectoryPath }, testContext.forceOpsContext); + var forceOps = new ForceOps(["delete", tempDirectoryPath], testContext.forceOpsContext); var stdoutString = GetStdoutString(stdoutStringBuilder); - Assert.True(0 == forceOps.Run(), BuildFailMessage(testContext, forceOps, stdoutString)); + Assert.True(0 == new ForceOpsRunner(forceOps).Run(), BuildFailMessage(testContext, forceOps, stdoutString)); Assert.True(!Directory.Exists(tempDirectoryPath), "Deleted by relaunch"); Assert.Contains("Unable to perform operation as an unelevated process. Retrying as elevated and logging to", testContext.fakeLoggerFactory.GetAllLogsString()); } @@ -148,10 +165,10 @@ public void RelaunchedProgramUsesForceDelete() string exeNameOverride = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "forceops.exe"); testContext.forceOpsContext.relaunchAsElevated = new RelaunchAsElevated() { verb = "", exeNameOverride = exeNameOverride }; - var forceOps = new ForceOps(new[] { "delete", pathThatCanBeDeleted, tempDirectoryPath }, testContext.forceOpsContext); + var forceOps = new ForceOps(["delete", pathThatCanBeDeleted, tempDirectoryPath], testContext.forceOpsContext); forceOps.extraRelaunchArgs = new List() { "--disable-elevate" }; var stdoutString = GetStdoutString(stdoutStringBuilder); - Assert.True(0 == forceOps.Run(), BuildFailMessage(testContext, forceOps, stdoutString)); + Assert.True(0 == new ForceOpsRunner(forceOps).Run(), BuildFailMessage(testContext, forceOps, stdoutString)); Assert.True(!Directory.Exists(tempDirectoryPath), "Deleted by relaunch"); Assert.Contains("Unable to perform operation as an unelevated process. Retrying as elevated and logging to", testContext.fakeLoggerFactory.GetAllLogsString()); } @@ -160,8 +177,8 @@ public void RelaunchedProgramUsesForceDelete() public void DeleteNonExistingFileThrowsMessage() { var testContext = new TestContext(); - var forceOps = new ForceOps(new[] { "delete", @"C:\C:\C:\" }, testContext.forceOpsContext); - forceOps.Run(); + var forceOps = new ForceOps(["delete", @"C:\C:\C:\"], testContext.forceOpsContext); + new ForceOpsRunner(forceOps).Run(); Assert.Equal(ExitCode.FileNotFound, testContext.friendlyExitCode); Assert.Equal(@"Cannot remove 'C:\C:\C:\'. No such file or directory", testContext.friendlyExitMessage); } @@ -170,8 +187,8 @@ public void DeleteNonExistingFileThrowsMessage() public void ListNonExistingFileThrowsMessage() { var testContext = new TestContext(); - var forceOps = new ForceOps(new[] { "list", @"C:\C:\C:\" }, testContext.forceOpsContext); - forceOps.Run(); + var forceOps = new ForceOps(["list", @"C:\C:\C:\"], testContext.forceOpsContext); + new ForceOpsRunner(forceOps).Run(); Assert.Equal(ExitCode.FileNotFound, testContext.friendlyExitCode); Assert.Equal(@"Cannot list locks of 'C:\C:\C:\'. No such file or directory", testContext.friendlyExitMessage); } @@ -206,7 +223,7 @@ public void RmGetListThrowsError5() fileInfo.SetAccessControl(fileSecurity); var forceOps = new ForceOps(["delete", temporaryFile], testContext.forceOpsContext); - forceOps.Run(); + new ForceOpsRunner(forceOps).Run(); Assert.True(File.Exists(temporaryFile), "Deleting the file should fail"); Assert.Contains("Unable to perform operation as an unelevated process. Retrying as elevated and logging to", testContext.fakeLoggerFactory.GetAllLogsString()); diff --git a/ForceOps/src/ForceOps.cs b/ForceOps/src/ForceOps.cs index 346891e..2f4e5dd 100644 --- a/ForceOps/src/ForceOps.cs +++ b/ForceOps/src/ForceOps.cs @@ -1,87 +1,24 @@ -using System.CommandLine; -using System.CommandLine.Builder; -using System.CommandLine.Parsing; -using ForceOps.Lib; +using ForceOps.Lib; using Serilog; namespace ForceOps; internal class ForceOps { - readonly internal ForceOpsContext forceOpsContext = new(); + internal readonly string[] args; + internal readonly ForceOpsContext forceOpsContext = new(); readonly ILogger logger; - readonly string[] args; internal Exception? caughtException = null; internal List? extraRelaunchArgs = null; public ForceOps(string[] args, ForceOpsContext? forceOpsContext = null, ILogger? logger = null) { + this.args = args; this.forceOpsContext = forceOpsContext ?? new ForceOpsContext(); this.logger = logger ?? this.forceOpsContext.loggerFactory.CreateLogger(); - this.args = args; - } - - public int Run() - { - var rootCommand = new RootCommand("By hook or by crook, perform operations on files and directories. If they are in use by a process, kill the process.") - { - Name = "forceops" - }; - rootCommand.AddCommand(CreateDeleteCommand()); - rootCommand.AddCommand(CreateListCommand()); - - var parser = new CommandLineBuilder(rootCommand) - .UseDefaults() - .AddMiddleware(async (context, next) => - { - try - { - await next(context); - } - catch (Exception ex) - { - caughtException = ex; - - if (ex is FileNotFoundException fileNotFoundEx) - { - forceOpsContext.environmentExit.Exit((int)ExitCode.FileNotFound, ex.Message); - } - throw; - } - }) - .Build(); - - return parser.Invoke(args); - } - - Command CreateDeleteCommand() - { - var filesToDeleteArgument = new Argument("files", "Files or directories to delete.") - { - Arity = ArgumentArity.OneOrMore - }; - - var forceOption = new Option(new[] { "-f", "--force" }, "Ignore nonexistent files and arguments."); - var disableElevate = new Option(new[] { "-e", "--disable-elevate" }, "Do not attempt to elevate if the file can't be deleted."); - var retryDelay = new Option(new[] { "-d", "--retry-delay" }, () => 50, "Delay when retrying to delete a file, after deleting processes holding a lock."); - var maxRetries = new Option(new[] { "-n", "--max-retries" }, () => 10, "Number of retries when deleting a locked file."); - - var deleteCommand = new Command("delete", "Delete files or a directories recursively.") - { - filesToDeleteArgument, - forceOption, - disableElevate, - retryDelay, - maxRetries - }; - - deleteCommand.AddAlias("rm"); - deleteCommand.AddAlias("remove"); - deleteCommand.SetHandler(DeleteCommand, filesToDeleteArgument, forceOption, disableElevate, retryDelay, maxRetries); - return deleteCommand; } - void DeleteCommand(string[] filesOrDirectoriesToDelete, bool force, bool disableElevate, int retryDelay, int maxRetries) + public void DeleteCommand(string[] filesOrDirectoriesToDelete, bool force, bool disableElevate, int retryDelay, int maxRetries) { RelaunchHelpers.RunWithRelaunchAsElevated(() => { @@ -96,18 +33,7 @@ void DeleteCommand(string[] filesOrDirectoriesToDelete, bool force, bool disable }, BuildArgsForRelaunch, forceOpsContext, logger, disableElevate); } - Command CreateListCommand() - { - var fileOrDirectoryArgument = new Argument("fileOrDirectory", "File or directory to get the locks of."); - var listCommand = new Command("list", "Uses LockCheck to output processes using a file or directory.") - { - fileOrDirectoryArgument - }; - listCommand.SetHandler(ListCommand, fileOrDirectoryArgument); - return listCommand; - } - - void ListCommand(string fileOrDirectory) + public void ListCommand(string fileOrDirectory) { new ListFileOrDirectoryLocks(forceOpsContext).PrintLocks(fileOrDirectory); } diff --git a/ForceOps/src/Program.cs b/ForceOps/src/Program.cs index 637f9b5..24d98bc 100644 --- a/ForceOps/src/Program.cs +++ b/ForceOps/src/Program.cs @@ -1,12 +1,92 @@ -using System.Runtime.Versioning; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Parsing; +using System.Runtime.Versioning; namespace ForceOps; [SupportedOSPlatform("windows")] public class Program { - public static int Main(string[] args) + public static int Main(string[] args) => new ForceOpsRunner(new ForceOps(args)).Run(); +} + +internal class ForceOpsRunner +{ + readonly ForceOps _forceOps; + public ForceOpsRunner(ForceOps forceOps) + { + _forceOps = forceOps; + } + + internal int Run() + { + var rootCommand = new RootCommand("By hook or by crook, perform operations on files and directories. If they are in use by a process, kill the process.") + { + Name = "forceops" + }; + rootCommand.AddCommand(CreateDeleteCommand()); + rootCommand.AddCommand(CreateListCommand()); + + var parser = new CommandLineBuilder(rootCommand) + .UseDefaults() + .AddMiddleware(async (context, next) => + { + try + { + await next(context); + } + catch (Exception ex) + { + _forceOps.caughtException = ex; + + if (ex is FileNotFoundException fileNotFoundEx) + { + _forceOps.forceOpsContext.environmentExit.Exit((int)ExitCode.FileNotFound, ex.Message); + } + throw; + } + }) + .Build(); + + return parser.Invoke(_forceOps.args); + } + + Command CreateDeleteCommand() + { + var filesToDeleteArgument = new Argument("files", "Files or directories to delete.") + { + Arity = ArgumentArity.OneOrMore + }; + + var forceOption = new Option(["-f", "--force"], "Ignore nonexistent files and arguments."); + var disableElevate = new Option(["-e", "--disable-elevate"], "Do not attempt to elevate if the file can't be deleted."); + var retryDelay = new Option(["-d", "--retry-delay"], () => 50, "Delay when retrying to delete a file, after deleting processes holding a lock."); + var maxRetries = new Option(["-n", "--max-retries"], () => 10, "Number of retries when deleting a locked file."); + + var deleteCommand = new Command("delete", "Delete files or a directories recursively.") + { + filesToDeleteArgument, + forceOption, + disableElevate, + retryDelay, + maxRetries + }; + + deleteCommand.AddAlias("rm"); + deleteCommand.AddAlias("remove"); + deleteCommand.SetHandler(_forceOps.DeleteCommand, filesToDeleteArgument, forceOption, disableElevate, retryDelay, maxRetries); + return deleteCommand; + } + + Command CreateListCommand() { - return new ForceOps(args).Run(); + var fileOrDirectoryArgument = new Argument("fileOrDirectory", "File or directory to get the locks of."); + var listCommand = new Command("list", "Uses LockCheck to output processes using a file or directory.") + { + fileOrDirectoryArgument + }; + listCommand.SetHandler(_forceOps.ListCommand, fileOrDirectoryArgument); + return listCommand; } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 6adc78c..e8c8cb9 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,31 @@ Uses [LockChecker](https://github.com/domsleee/LockCheck) to find processes lock ## Installation +### Install with [`scoop`](http://scoop.sh/) (recommended) +It has a faster startup because it uses [Native AOT](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/): + ```shell -dotnet tool install -g forceops +scoop install https://gist.github.com/domsleee/f765105f512ec607ee0a6e3ee5debd6d/raw/forceops.json ``` To update: +```shell +scoop update forceops --force ``` -dotnet tool update -g forceops + +### Alternative - install with [`dotnet`](https://dotnet.microsoft.com/en-us/download) + +```shell +dotnet tool install -g forceops ``` -Alternatively, the executable is available for download in [the latest release]([releases](https://github.com/domsleee/ForceOps/releases/atest)). +To update: +```shell +dotnet tool update -g forceops +``` +### Alternative - install from releases +Download the latest exe from [releases](https://github.com/domsleee/ForceOps/releases). ## Usage: As a CLI ### Deleting when a process owned by the current user is using it diff --git a/installLocal.ps1 b/installLocal.ps1 index 7cb2b46..96a74d8 100644 --- a/installLocal.ps1 +++ b/installLocal.ps1 @@ -5,5 +5,5 @@ pwsh -NoProfile -Command { { dotnet tool uninstall -g ForceOps } - dotnet tool install -g ForceOps --prerelease --configfile "../scripts/LocalNuGet.config" --no-cache + dotnet tool install -g ForceOps --configfile "../scripts/LocalNuGet.config" --no-cache } \ No newline at end of file