From 487ef12609fd6014748aa603c86a8b8cd89a85fb Mon Sep 17 00:00:00 2001 From: Dom Slee Date: Wed, 10 Jun 2026 18:48:08 +1000 Subject: [PATCH 1/2] Update packages, migrate to System.CommandLine 2.0.9 stable * System.CommandLine 2.0.0-beta4 -> 2.0.9: migrate Program.cs to the GA API (SetAction/ParseResult, mutable collections, DefaultValueFactory; middleware replaced with try/catch around Invoke) * Serilog 4.3.1, Serilog.Sinks.Console 6.1.1, BenchmarkDotNet 0.15.8, Microsoft.NET.Test.Sdk 18.6.0, Moq 4.20.72, System.Reactive 6.1.0, xunit.runner.visualstudio 3.1.5 Co-Authored-By: Claude Fable 5 --- .../ForceOps.Benchmarks.csproj | 2 +- ForceOps.Lib/ForceOps.Lib.csproj | 4 +- ForceOps.Test/ForceOps.Test.csproj | 8 +- ForceOps/ForceOps.csproj | 2 +- ForceOps/src/Program.cs | 78 +++++++++---------- 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/ForceOps.Benchmarks/ForceOps.Benchmarks.csproj b/ForceOps.Benchmarks/ForceOps.Benchmarks.csproj index 6e7e282..16322a9 100644 --- a/ForceOps.Benchmarks/ForceOps.Benchmarks.csproj +++ b/ForceOps.Benchmarks/ForceOps.Benchmarks.csproj @@ -4,7 +4,7 @@ - + diff --git a/ForceOps.Lib/ForceOps.Lib.csproj b/ForceOps.Lib/ForceOps.Lib.csproj index 9979817..dcb8b83 100644 --- a/ForceOps.Lib/ForceOps.Lib.csproj +++ b/ForceOps.Lib/ForceOps.Lib.csproj @@ -15,7 +15,7 @@ - - + + \ No newline at end of file diff --git a/ForceOps.Test/ForceOps.Test.csproj b/ForceOps.Test/ForceOps.Test.csproj index be89e41..4127f47 100644 --- a/ForceOps.Test/ForceOps.Test.csproj +++ b/ForceOps.Test/ForceOps.Test.csproj @@ -5,11 +5,11 @@ - - - + + + - + runtime; build; native; contentfiles; analyzers all diff --git a/ForceOps/ForceOps.csproj b/ForceOps/ForceOps.csproj index f9b46ef..7eced39 100644 --- a/ForceOps/ForceOps.csproj +++ b/ForceOps/ForceOps.csproj @@ -26,7 +26,7 @@ - + diff --git a/ForceOps/src/Program.cs b/ForceOps/src/Program.cs index 24d98bc..ce99659 100644 --- a/ForceOps/src/Program.cs +++ b/ForceOps/src/Program.cs @@ -1,6 +1,4 @@ -using System.CommandLine; -using System.CommandLine.Builder; -using System.CommandLine.Parsing; +using System.CommandLine; using System.Runtime.Versioning; namespace ForceOps; @@ -21,48 +19,42 @@ public ForceOpsRunner(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 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."); + rootCommand.Subcommands.Add(CreateDeleteCommand()); + rootCommand.Subcommands.Add(CreateListCommand()); - var parser = new CommandLineBuilder(rootCommand) - .UseDefaults() - .AddMiddleware(async (context, next) => + try + { + return rootCommand.Parse(_forceOps.args).Invoke(new InvocationConfiguration { - 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(); + EnableDefaultExceptionHandler = false + }); + } + catch (Exception ex) + { + _forceOps.caughtException = ex; - return parser.Invoke(_forceOps.args); + if (ex is FileNotFoundException) + { + _forceOps.forceOpsContext.environmentExit.Exit((int)ExitCode.FileNotFound, ex.Message); + } + Console.Error.WriteLine(ex.ToString()); + return 1; + } } Command CreateDeleteCommand() { - var filesToDeleteArgument = new Argument("files", "Files or directories to delete.") + var filesToDeleteArgument = new Argument("files") { + Description = "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 forceOption = new Option("--force", "-f") { Description = "Ignore nonexistent files and arguments." }; + var disableElevate = new Option("--disable-elevate", "-e") { Description = "Do not attempt to elevate if the file can't be deleted." }; + var retryDelay = new Option("--retry-delay", "-d") { DefaultValueFactory = _ => 50, Description = "Delay when retrying to delete a file, after deleting processes holding a lock." }; + var maxRetries = new Option("--max-retries", "-n") { DefaultValueFactory = _ => 10, Description = "Number of retries when deleting a locked file." }; var deleteCommand = new Command("delete", "Delete files or a directories recursively.") { @@ -73,20 +65,28 @@ Command CreateDeleteCommand() maxRetries }; - deleteCommand.AddAlias("rm"); - deleteCommand.AddAlias("remove"); - deleteCommand.SetHandler(_forceOps.DeleteCommand, filesToDeleteArgument, forceOption, disableElevate, retryDelay, maxRetries); + deleteCommand.Aliases.Add("rm"); + deleteCommand.Aliases.Add("remove"); + deleteCommand.SetAction(parseResult => _forceOps.DeleteCommand( + parseResult.GetValue(filesToDeleteArgument)!, + parseResult.GetValue(forceOption), + parseResult.GetValue(disableElevate), + parseResult.GetValue(retryDelay), + parseResult.GetValue(maxRetries))); return deleteCommand; } Command CreateListCommand() { - var fileOrDirectoryArgument = new Argument("fileOrDirectory", "File or directory to get the locks of."); + var fileOrDirectoryArgument = new Argument("fileOrDirectory") + { + Description = "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); + listCommand.SetAction(parseResult => _forceOps.ListCommand(parseResult.GetValue(fileOrDirectoryArgument)!)); return listCommand; } } From 64a851bb29ee14e1b1a4f40c69672eccb1057df7 Mon Sep 17 00:00:00 2001 From: Dom Slee Date: Wed, 10 Jun 2026 19:12:32 +1000 Subject: [PATCH 2/2] Keep "forceops" as the root command name in usage text The GA API derives the root command name from the executable, which shows "ForceOps" in usage lines. Use a named Command with explicit HelpOption/VersionOption to preserve the previous lowercase name. Co-Authored-By: Claude Fable 5 --- ForceOps/src/Program.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ForceOps/src/Program.cs b/ForceOps/src/Program.cs index ce99659..ea1f8b4 100644 --- a/ForceOps/src/Program.cs +++ b/ForceOps/src/Program.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.CommandLine.Help; using System.Runtime.Versioning; namespace ForceOps; @@ -19,7 +20,11 @@ public ForceOpsRunner(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."); + // A named Command is used instead of RootCommand so the usage text shows + // "forceops" rather than the executable name ("ForceOps"). + var rootCommand = new Command("forceops", "By hook or by crook, perform operations on files and directories. If they are in use by a process, kill the process."); + rootCommand.Options.Add(new HelpOption()); + rootCommand.Options.Add(new VersionOption()); rootCommand.Subcommands.Add(CreateDeleteCommand()); rootCommand.Subcommands.Add(CreateListCommand());