feat!: refactored to handle updates to System.CommandLine

This commit is contained in:
Dylan R. E. Moonfire 2022-03-31 00:17:04 -05:00
parent 80976256fb
commit 0298ae601e
22 changed files with 416 additions and 529 deletions

View file

@ -1,4 +1,27 @@
Nitride CIL
===========
# MfGames.ToolBuilder
A static site generator based on the [Gallium ECS](https://gitlab.com/mfgames-cil/gallium-cil/).
_An opinionated library for easily creating command-line tools in C#._
ToolBuilder is a library to encapsulate the setup and running of tools, CLI components arranged with verbs (like `git`). It includes some opinionated decisions on default setup.
## Commands
This library is built on top of [System.CommandLine](https://github.com/dotnet/command-line-api) and .NET 6 generic hosting. The commands are combined together using dependency injection (see below) which allows for them to be included as parameters for the constructor or dynamically discovered.
All commands are assumed to be asynchronous and include a cancellation token in their calls.
`System.CommandLine` was chosen because it allows for composition of features instead on needing inheritance to give a tool the ability to render a table or handle additional features.
## Autofac
While most libraries should only use the included service provider infrastructure with the base library, this tool sets up [Autofac](https://autofac.org/) to handle services. This allows for both service provider usage (via `ConfigureServices`) and Autofac (via `ConfigureContainer`).
Autofac was chosen because of a number of quality of life features and personal preferences of the developer. A tool can easily ignore that functionality in favor of using only `IServiceProvider`.
## Serilog
Likewise, tools are set up to use [Serilog](https://serilog.net/) for logging instead of the built-in logging code. However, both are served by Serilog so `ILoggerFactory<T>` can be used.
Like Autofac, Serilog was given because of the variety of sinks, certain quality of life, but also the ability to log more detailed objects. It also produces some coloring to log details based on data types.
To use the Serilog functionality with `ILoggerFactory`, objects needed to be included as parameters instead of inlined with string interpolation.

3
TASKS.md Normal file
View file

@ -0,0 +1,3 @@
- [ ] Split out tables into an assembly
- [ ] Switch to GitVersion for release
- [ ] Switch to scripts instead of Node for targets

View file

@ -2,11 +2,11 @@
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1638122382,
"narHash": "sha256-sQzZzAbvKEqN9s0bzWuYmRaA03v40gaJ4+iL1LXjaeI=",
"lastModified": 1648297722,
"narHash": "sha256-W+qlPsiZd8F3XkzXOzAoR+mpFqzm3ekQkJNa+PIh1BQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "74f7e4319258e287b0f9cb95426c9853b282730b",
"rev": "0f8662f1319ad6abf89b3380dd2722369fc51ade",
"type": "github"
},
"original": {
@ -17,11 +17,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1638198142,
"narHash": "sha256-plU9b8r4St6q4U7VHtG9V7oF8k9fIpfXl/KDaZLuY9k=",
"lastModified": 1648390671,
"narHash": "sha256-u69opCeHUx3CsdIerD0wVSR+DjfDQjnztObqfk9Trqc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8a308775674e178495767df90c419425474582a1",
"rev": "ce8cbe3c01fd8ee2de526ccd84bbf9b82397a510",
"type": "github"
},
"original": {

View file

@ -11,7 +11,7 @@
let pkgs = nixpkgs.legacyPackages.${system};
in {
devShell = pkgs.mkShell {
buildInputs = [ pkgs.dotnet-sdk_5 pkgs.nodejs-16_x pkgs.nixfmt ];
buildInputs = [ pkgs.dotnet-sdk pkgs.nodejs-16_x pkgs.nixfmt ];
};
});
}

View file

@ -1,28 +0,0 @@
using System;
using System.CommandLine;
using System.Linq;
namespace MfGames.ToolBuilder
{
/// <summary>
/// Fakes the functionality of the RootCommand because of the given issue:
/// https://github.com/dotnet/command-line-api/issues/1471
/// In short, RootCommand doesn't resolve properly when working in certain
/// situations, mainly having the command line parser in a library that is
/// called by another executable.
/// </summary>
public class FakedRootCommand : Command
{
public FakedRootCommand(ToolNames names)
: base(names.GetExecutableName(), string.Empty)
{
}
public static string[] GetArguments()
{
string[] args = Environment.GetCommandLineArgs();
return args.Length == 0 ? args : args.Skip(1).ToArray();
}
}
}

View file

@ -8,15 +8,18 @@ using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
namespace MfGames.ToolBuilder.Globals
namespace MfGames.ToolBuilder.Services
{
/// <summary>
/// A utility class for handling `--config` options.
/// </summary>
public class ConfigToolGlobalService
public class ConfigToolGlobal
{
public ConfigToolGlobalService()
private readonly string configName;
public ConfigToolGlobal(string configName)
{
this.configName = configName;
this.ConfigOption = new Option<string[]>(
"--config",
"Configuration file to use for settings, otherwise a default will be used.")
@ -46,9 +49,7 @@ namespace MfGames.ToolBuilder.Globals
get
{
// If we don't have an internal name, blow up.
string? configName = this.Names?.GetConfigName();
if (string.IsNullOrWhiteSpace(configName))
if (string.IsNullOrWhiteSpace(this.configName))
{
throw new ApplicationException(
"Cannot determine the default configuration path unless the configuration name has been set.");
@ -61,24 +62,18 @@ namespace MfGames.ToolBuilder.Globals
.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string appDirectory = Path.Combine(
configDirectory,
configName);
this.configName);
string configPath = Path.Combine(appDirectory, "Settings.json");
return configPath;
}
}
/// <summary>
/// Gets or sets the name of the application. This is used to figure out
/// the name of the configuration file and what is shown on the screen.
/// </summary>
public ToolNames? Names { get; set; }
/// <summary>
/// Adds the common options to the command.
/// </summary>
/// <param name="root"></param>
public void AddOptions(Command root)
public void Attach(Command root)
{
root.AddGlobalOption(this.ConfigOption);
}
@ -107,7 +102,7 @@ namespace MfGames.ToolBuilder.Globals
}
// Otherwise, use the default files.
foreach (var config in configs)
foreach (string config in configs)
{
builder.AddJsonFile(config, true, true);
}
@ -128,26 +123,12 @@ namespace MfGames.ToolBuilder.Globals
return default;
}
var json = file.ReadAllText();
string json = file.ReadAllText();
TType result = JsonConvert.DeserializeObject<TType>(json)!;
return result;
}
/// <summary>
/// Sets the internal name of the application, used for the
/// configuration path.
/// </summary>
/// <param name="names">
/// The names object for the tool.
/// </param>
/// <returns>The service for chaining operations.</returns>
public ConfigToolGlobalService WithNames(ToolNames? names)
{
this.Names = names;
return this;
}
/// <summary>
/// Writes the given object to the default configuration file.
/// </summary>

View file

@ -8,20 +8,20 @@ using Serilog.Core;
using Serilog.Events;
using Serilog.Exceptions;
namespace MfGames.ToolBuilder.Globals
namespace MfGames.ToolBuilder.Services
{
/// <summary>
/// A service for handling logging options.
/// </summary>
public class LoggingToolGlobalService
public class LoggingToolGlobal
{
public LoggingToolGlobalService()
public LoggingToolGlobal()
{
this.LogLevelOption = new Option<string>(
"--log-level",
() => nameof(LogEventLevel.Information),
string.Format(
"Controls the verbosity of the output: {0}. Not case-sensitive and prefixes allowed.",
"Controls the verbosity of the output: {0} (defaults to Warning). Not case-sensitive and prefixes allowed.",
string.Join(", ", Enum.GetNames<LogEventLevel>())));
}
@ -34,7 +34,7 @@ namespace MfGames.ToolBuilder.Globals
/// Adds the common options to the command.
/// </summary>
/// <param name="root"></param>
public void AddOptions(Command root)
public void Attach(Command root)
{
root.AddGlobalOption(this.LogLevelOption);
}
@ -49,7 +49,7 @@ namespace MfGames.ToolBuilder.Globals
string level = GlobalOptionHelper.GetArgumentValue(
this.LogLevelOption,
arguments,
"Information");
"Warning");
LogEventLevel logLevel = level.GetEnumFuzzy<LogEventLevel>(
"log level");

View file

@ -1,14 +0,0 @@
using System.CommandLine;
namespace MfGames.ToolBuilder
{
/// <summary>
/// An interface that indicates that the given command is a top-level
/// command instead of one that is included as a sub-command inside another.
/// This is used to arrange the various sub-commands using dependency
/// injection and is purely a marker interface.
/// </summary>
public interface ITopCommand : ICommand
{
}
}

View file

@ -1,43 +1,49 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<Description>A framework for easily creating command line tools using System.CommandLine, Autofac, and Serilog.</Description>
</PropertyGroup>
<PropertyGroup>
<!-- System.CommandLine is pre-release and will be for a while. -->
<NoWarn>$(NoWarn);NU5104</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="6.3.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="7.2.0" />
<PackageReference Include="ConsoleTableExt" Version="3.1.9" />
<PackageReference Include="CsvHelper" Version="27.2.1" />
<PackageReference Include="FluentResults" Version="3.1.0" />
<PackageReference Include="FluentResults" Version="3.3.0" />
<PackageReference Include="Glob" Version="1.1.9" />
<PackageReference Include="Humanizer.Core" Version="2.13.14" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MfGames.IO" Version="1.2.3" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Roslynator.Analyzers" Version="3.3.0">
<PackageReference Include="Roslynator.Analyzers" Version="4.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="3.3.0">
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="3.3.0">
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Enrichers.Demystifier" Version="1.0.2" />
<PackageReference Include="Serilog.Exceptions" Version="8.0.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.1.0" />
<PackageReference Include="Serilog.Extensions.Autofac.DependencyInjection" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta2.21617.1" />
<PackageReference Include="System.CommandLine" Version="[2.0.0-beta3.22114.1,)" />
<PackageReference Include="System.CommandLine.Hosting" Version="0.4.0-alpha.22114.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,51 @@
using System;
using System.CommandLine.Parsing;
using System.Threading.Tasks;
using Serilog;
namespace MfGames.ToolBuilder;
/// <summary>
/// A collection of tools set up for running the command-line shell.
/// </summary>
public class ToolBox
{
private readonly string[] arguments;
private readonly Parser parser;
public ToolBox(string[] arguments, Parser parser)
{
this.arguments = arguments;
this.parser = parser;
}
/// <summary>
/// Finishes building the tool, parses the arguments, and runs the
/// command.
/// </summary>
/// <returns>An error code, 0 for successful, otherwise false.</returns>
public async Task<int> RunAsync()
{
try
{
ParseResult parseResults = this.parser.Parse(this.arguments);
int exitCode = await parseResults.InvokeAsync();
return exitCode;
}
catch (Exception exception)
{
Log.Fatal(
exception,
"There was a problem running the command: {Arguments}",
this.arguments);
return Environment.ExitCode == 0 ? 1 : Environment.ExitCode;
}
// Get the exit code and return it.
// TODO return Environment.ExitCode;
}
}

View file

@ -0,0 +1,186 @@
using System;
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Hosting;
using System.CommandLine.Parsing;
using System.IO;
using Autofac;
using Autofac.Extensions.DependencyInjection;
using MfGames.ToolBuilder.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
namespace MfGames.ToolBuilder
{
/// <summary>
/// A builder pattern for creating a tool box, a nested collection of tools.
/// This wraps much of the hosting and command-line parsing infrastructure
/// with some opinionated decisions and reduces the amount of boilerplate
/// needed to create tools.
/// </summary>
public class ToolBoxBuilder
{
private readonly string[] arguments;
private readonly ConfigToolGlobal config;
private readonly IHostBuilder hostBuilder;
private readonly LoggingToolGlobal logging;
public ToolBoxBuilder(string configName, string[] arguments)
{
// Create our various services.
this.arguments = arguments;
this.config = new ConfigToolGlobal(configName);
this.logging = new LoggingToolGlobal();
// Set up logging first so we can report the loading process. This
// sets up the Serilog.Log.Logger which means we can use that for
// everything beyond this point.
this.logging.Configure(arguments);
// Start up the basic configuration.
//
// Normally, we would use Host.CreateDefaultBuilder(args) to create
// this, but when run as a stand-alone application in someone's
// $HOME on Linux, this causes an inotify watcher to be registered
// on the entire directory tree (which takes a really long time).
//
// We also don't need most of the default features.
var serviceProviderFactory = new AutofacServiceProviderFactory();
this.hostBuilder = new HostBuilder()
.UseDefaultServiceProvider(this.ConfigureDefaultServiceProvider)
.UseConsoleLifetime()
.ConfigureAppConfiguration(this.ConfigureAppConfiguration)
.UseSerilog()
.UseServiceProviderFactory(serviceProviderFactory)
.ConfigureServices(this.ConfigureServices)
.ConfigureContainer<ContainerBuilder>(this.ConfigureContainer);
}
public static ToolBoxBuilder Create(
string configName,
string[] arguments)
{
return new ToolBoxBuilder(configName, arguments);
}
/// <summary>
/// Constructs a tool box, a collection of tools.
/// </summary>
/// <returns>The constructed toolbox.</returns>
public ToolBox Build()
{
// There is a Catch-22 with how System.CommandLine is build. It
// requires the root command to be passed into the constructor
// for the CommandLineBuilder but we can't create the root command
// until we build our host.
//
// To handle this, we build our first host builder (which doesn't
// know about the command line parser), use it to get the command
// and then pass it into the command-line version to create a
// second `HostBuilder` that we don't use.
IHost host = this.hostBuilder.Build();
ILifetimeScope container = host.Services.GetAutofacRoot()!;
// Create the root command and attach our globals to that.
RootCommand rootCommand = container.Resolve<RootCommand>();
this.config.Attach(rootCommand);
this.logging.Attach(rootCommand);
// Finish creating the command line builder so we can make the parser.
CommandLineBuilder? builder = new CommandLineBuilder(rootCommand)
.UseDefaults()
.UseHost();
// Finish building the parser, wrap it in a tool box, and return it.
Parser parser = builder.Build();
var toolBox = new ToolBox(this.arguments, parser);
return toolBox;
}
/// <summary>
/// Provides additional configuration for Autofac containers.
/// </summary>
/// <param name="configure">The configuration method.</param>
/// <returns>The builder to chain methods.</returns>
public ToolBoxBuilder ConfigureContainer(
Action<ContainerBuilder> configure)
{
this.hostBuilder.ConfigureContainer(configure);
return this;
}
/// <summary>
/// Provides additional configuration for services.
/// </summary>
/// <param name="configure">The configuration method.</param>
/// <returns>The builder to chain methods.</returns>
public ToolBoxBuilder ConfigureServices(
Action<IServiceCollection> configure)
{
this.hostBuilder.ConfigureServices(configure);
return this;
}
private void ConfigureAppConfiguration(
HostBuilderContext context,
IConfigurationBuilder builder)
{
builder.SetBasePath(Directory.GetCurrentDirectory());
this.config.Configure(builder, this.arguments);
}
private void ConfigureContainer(
HostBuilderContext context,
ContainerBuilder builder)
{
// We want to get logging up and running as soon as possible. We
// also hook up the logging to the process exit in an attempt to
// make sure the logger is properly flushed before exiting.
builder.RegisterInstance(Log.Logger).As<ILogger>().SingleInstance();
AppDomain.CurrentDomain.ProcessExit +=
(_, _) => Log.CloseAndFlush();
// Register the global services as singletons.
builder
.RegisterInstance(this.config)
.AsSelf()
.SingleInstance();
builder
.RegisterInstance(this.logging)
.AsSelf()
.SingleInstance();
// Register the components required to make the CLI work.
builder.RegisterModule<ToolBuilderModule>();
}
private void ConfigureDefaultServiceProvider(
HostBuilderContext context,
ServiceProviderOptions options)
{
bool isDevelopment = context.HostingEnvironment.IsDevelopment();
options.ValidateScopes = isDevelopment;
options.ValidateOnBuild = isDevelopment;
}
private void ConfigureServices(
HostBuilderContext context,
IServiceCollection services)
{
services.AddAutofac();
}
}
}

View file

@ -1,174 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Autofac;
using Autofac.Extensions.DependencyInjection;
using MfGames.ToolBuilder.Globals;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
namespace MfGames.ToolBuilder
{
/// <summary>
/// A builder pattern for creating the tool. This wraps much of the hosting
/// infrastructure with some opinionated decisions and reduces the amount of
/// boilerplate needed to configure the tool.
/// </summary>
public class ToolBuilder
{
private readonly string[] arguments;
private readonly ConfigToolGlobalService configService;
private readonly IHostBuilder hostBuilder;
private readonly LoggingToolGlobalService loggingService;
private readonly ToolNames names;
public ToolBuilder(
ToolNames names,
string[] arguments)
{
// Create our various services.
this.names = names;
this.arguments = arguments;
this.configService = new ConfigToolGlobalService()
.WithNames(this.names);
this.loggingService = new LoggingToolGlobalService();
// Set up logging first so we can report the loading process. This
// sets up the Serilog.Log.Logger which means we can use that for
// everything beyond this point.
this.loggingService.Configure(arguments);
// Start up the basic configuration.
//
// Normally, we would use Host.CreateDefaultBuilder(args) to create
// this, but when run as a stand-alone application in someone's
// $HOME on Linux, this causes an inotify watcher to be registered
// on the entire directory tree (which takes a really long time).
//
// We also don't need most of the default features.
this.hostBuilder =
new HostBuilder()
.UseDefaultServiceProvider(
(context, options) =>
{
bool isDevelopment =
context.HostingEnvironment.IsDevelopment();
options.ValidateScopes = isDevelopment;
options.ValidateOnBuild = isDevelopment;
})
.UseConsoleLifetime()
.ConfigureAppConfiguration(this.ConfigureAppConfiguration)
.UseSerilog()
.UseServiceProviderFactory(
new AutofacServiceProviderFactory())
.ConfigureServices(this.ConfigureServices)
.ConfigureContainer<ContainerBuilder>(
this.ConfigureContainer);
}
public static ToolBuilder Create(
ToolNames names,
string[] arguments)
{
return new ToolBuilder(names, arguments);
}
public ToolBuilder ConfigureContainer(
Action<ContainerBuilder> configure)
{
this.hostBuilder.ConfigureContainer(configure);
return this;
}
public ToolBuilder ConfigureServices(
Action<IServiceCollection> configure)
{
this.hostBuilder.ConfigureServices(configure);
return this;
}
/// <summary>
/// Finishes building the tool, parses the arguments, and runs the
/// command.
/// </summary>
/// <returns>An error code, 0 for successful, otherwise false.</returns>
public async Task<int> RunAsync()
{
try
{
await this.hostBuilder
.RunConsoleAsync()
.ConfigureAwait(false);
}
catch (Exception exception)
{
Log.Fatal(
exception,
"There was a problem running the command: {Arguments}",
this.arguments);
return Environment.ExitCode == 0 ? 1 : Environment.ExitCode;
}
// Get the exit code and return it.
return Environment.ExitCode;
}
private void ConfigureAppConfiguration(
HostBuilderContext context,
IConfigurationBuilder builder)
{
builder.SetBasePath(Directory.GetCurrentDirectory());
this.configService.Configure(builder, this.arguments);
}
private void ConfigureContainer(
HostBuilderContext context,
ContainerBuilder builder)
{
// We want to get logging up and running as soon as possible. We
// also hook up the logging to the process exit in an attempt to
// make sure the logger is properly flushed before exiting.
builder.RegisterInstance(Log.Logger).As<ILogger>().SingleInstance();
AppDomain.CurrentDomain.ProcessExit +=
(_, _) => Log.CloseAndFlush();
// Register the names as a singleton instance.
builder.RegisterInstance(this.names)
.As<ToolNames>()
.SingleInstance();
// Register the global services as singletons.
builder
.RegisterInstance(this.configService)
.AsSelf()
.SingleInstance();
builder
.RegisterInstance(this.loggingService)
.AsSelf()
.SingleInstance();
// Register the components required to make the CLI work.
builder.RegisterModule<ToolBuilderModule>();
}
private void ConfigureServices(
HostBuilderContext context,
IServiceCollection services)
{
services.AddAutofac();
services.AddHostedService<ToolService>();
}
}
}

View file

@ -1,6 +1,6 @@
using Autofac;
using MfGames.ToolBuilder.Globals;
using MfGames.ToolBuilder.Services;
namespace MfGames.ToolBuilder
{
@ -14,10 +14,8 @@ namespace MfGames.ToolBuilder
{
builder
.RegisterAssemblyTypes(this.GetType().Assembly)
.Except<ToolService>()
.Except<ToolNames>()
.Except<ConfigToolGlobalService>()
.Except<LoggingToolGlobalService>()
.Except<ConfigToolGlobal>()
.Except<LoggingToolGlobal>()
.AsSelf()
.AsImplementedInterfaces();
}

View file

@ -1,74 +0,0 @@
using System;
using System.IO;
namespace MfGames.ToolBuilder
{
/// <summary>
/// Provides the name of the tool in its various formats. This is used to
/// identify the executable name and other elements used throughout the
/// system.
/// </summary>
public class ToolNames
{
public ToolNames(string name)
{
this.Name = name ?? throw new ArgumentNullException(nameof(name));
// The name of the executing program comes in as the first parameter.
string[] args = Environment.GetCommandLineArgs();
if (args.Length > 0)
{
this.ExecutableName = Path.GetFileNameWithoutExtension(args[0]);
}
}
/// <summary>
/// Gets the name of the configuration file. This will fall back to Name
/// if not provided.
/// </summary>
public string? ConfigName { get; set; }
/// <summary>
/// Gets the human-readable name of the application, falling back to Name
/// if not provided.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
/// Gets the name of the executable. This will fall back to ConfigName
/// if this propery is null.
/// </summary>
public string? ExecutableName { get; set; }
/// <summary>
/// Gets the required name of the application. This can be human-readable
/// or not, depending on the developer.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets the configuration name using any fallbacks.
/// </summary>
public string GetConfigName()
{
return this.ConfigName ?? this.Name;
}
/// <summary>
/// Gets the display name using any fallbacks.
/// </summary>
public string GetDisplayName()
{
return this.DisplayName ?? this.Name;
}
/// <summary>
/// Gets the executable name using any fallbacks.
/// </summary>
public string GetExecutableName()
{
return this.ExecutableName ?? this.GetConfigName();
}
}
}

View file

@ -1,158 +0,0 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Threading;
using System.Threading.Tasks;
using MfGames.ToolBuilder.Globals;
using Microsoft.Extensions.Hosting;
using Serilog;
#pragma warning disable Serilog004
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
// ReSharper disable SuspiciousTypeConversion.Global
namespace MfGames.ToolBuilder
{
/// <summary>
/// Implements the command-line shell for generating the static site.
/// </summary>
public class ToolService : IHostedService
{
private readonly IList<ITopCommand> commands;
private readonly ConfigToolGlobalService configService;
private readonly IHostApplicationLifetime lifetime;
private readonly ILogger logger;
private readonly LoggingToolGlobalService loggingService;
private readonly ToolNames names;
public ToolService(
ToolNames names,
ILogger logger,
IHostApplicationLifetime lifetime,
IList<ITopCommand> commands,
ConfigToolGlobalService configService,
LoggingToolGlobalService loggingService)
{
this.names = names;
this.lifetime = lifetime;
this.commands = commands;
this.configService = configService;
this.loggingService = loggingService;
this.logger = logger.ForContext<ToolService>();
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
this.lifetime.ApplicationStarted
.Register(
() =>
{
Task.Run(
async () =>
await this.RunAsync().ConfigureAwait(false),
cancellationToken);
});
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private FakedRootCommand CreateRootCommand()
{
// Create the root command and add in the top-level commands
// underneath it. We can't use the "real" `RootCommand` here because
// it doesn't work in stripped executables (because this is a
// library) so we fake it with a "normal" command.
var root = new FakedRootCommand(this.names);
foreach (var command in this.commands)
{
root.AddCommand((Command)command);
}
// Add the universal options.
this.loggingService.AddOptions(root);
this.configService.AddOptions(root);
// Return the resulting container.
return root;
}
private void OnException(Exception exception, InvocationContext context)
{
if (exception is ToolException toolException)
{
this.logger.Fatal(toolException.Message);
foreach (var message in toolException.Messages)
{
this.logger.Fatal(message);
}
Environment.ExitCode = toolException.ExitCode;
}
else
{
this.logger.Fatal(
exception,
"Unhandled exception!");
Environment.ExitCode = Environment.ExitCode == 0
? 1
: Environment.ExitCode;
}
}
private async Task RunAsync()
{
try
{
// Build the command tree.
FakedRootCommand root = this.CreateRootCommand();
string[] args = FakedRootCommand.GetArguments();
// Execute the command.
this.logger.Verbose(
"Running the command-line arguments: {Arguments}",
args);
CommandLineBuilder builder = new CommandLineBuilder(root)
.UseDefaults()
.UseExceptionHandler(this.OnException);
Parser cli = builder.Build();
int exitCode = await cli
.InvokeAsync(args)
.ConfigureAwait(false);
if (exitCode != 0)
{
Environment.ExitCode = exitCode;
}
}
finally
{
// Stop the application once the work is done.
this.lifetime.StopApplication();
}
}
}
}

View file

@ -1,29 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.3.3" />
<PackageReference Include="coverlet.collector" Version="3.1.0">
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="JunitXml.TestLogger" Version="3.0.110" />
<PackageReference Include="MfGames.IO" Version="1.2.3" />
<PackageReference Include="MfGames.TestSetup" Version="1.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Roslynator.Analyzers" Version="3.3.0">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Roslynator.Analyzers" Version="4.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="3.3.0">
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="3.3.0">
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View file

@ -7,12 +7,12 @@ using MfGames.ToolBuilder;
namespace SampleTool
{
public class CrashTopCommand : Command, ITopCommand, ICommandHandler
public class CrashCommand : Command, ICommandHandler
{
private readonly Option<bool> messyOption;
/// <inheritdoc />
public CrashTopCommand()
public CrashCommand()
: base("crash", "Crash the application with an exception.")
{
this.Handler = this;

View file

@ -0,0 +1,67 @@
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using ILogger = Serilog.ILogger;
namespace SampleTool;
public class LogCommand : Command, ICommandHandler
{
private readonly ILogger<LogCommand> extensionLogger;
private readonly ILogger serilogLogger;
/// <inheritdoc />
public LogCommand(
ILoggerFactory loggerFactory,
ILogger serilogLogger)
: base("log", "Shows various logging messages.")
{
this.serilogLogger = serilogLogger;
this.extensionLogger = loggerFactory.CreateLogger<LogCommand>();
this.Handler = this;
}
/// <inheritdoc />
public Task<int> InvokeAsync(InvocationContext context)
{
// Show the serilog logging.
this.serilogLogger.Debug("Serilog Debug");
this.serilogLogger.Error("Serilog Error");
this.serilogLogger.Fatal("Serilog Fatal");
this.serilogLogger.Information("Serilog Information");
this.serilogLogger.Verbose("Serilog Verbose");
this.serilogLogger.Warning("Serilog Warning");
// Show the extension logging.
this.extensionLogger.LogCritical(
"System.Extension.Logging LogCritical");
this.extensionLogger.LogDebug(
"System.Extension.Logging LogDebug");
this.extensionLogger.LogError(
"System.Extension.Logging LogError");
this.extensionLogger.LogInformation(
"System.Extension.Logging LogInformation");
this.extensionLogger.LogTrace(
"System.Extension.Logging LogTrace");
this.extensionLogger.LogWarning(
"System.Extension.Logging LogWarning");
// Show Serilog working through logging extensions.
var hash = new { Number = 1, String = "String" };
this.extensionLogger.LogInformation(
"Contextual information via {Name} and {Quotes:l}",
"extension logger",
"without quotes");
this.extensionLogger.LogInformation(
"Contextual information via {@Nested}",
hash);
// We're good.
return Task.FromResult(0);
}
}

View file

@ -10,11 +10,10 @@ namespace SampleTool
{
public static async Task<int> Main(string[] args)
{
return await ToolBuilder
.Create(
new ToolNames("SampleApplication"),
args)
return await ToolBoxBuilder
.Create("SampleTool", args)
.ConfigureContainer(ConfigureContainer)
.Build()
.RunAsync();
}

View file

@ -2,11 +2,11 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta2.21617.1" />
<PackageReference Include="System.CommandLine" Version="[2.0.0-beta3.22114.1,)" />
</ItemGroup>
<ItemGroup>

View file

@ -1,4 +1,8 @@
using Autofac;
using System.CommandLine;
using Autofac;
using SampleTool;
namespace MfGames.ToolBuilder
{
@ -14,6 +18,25 @@ namespace MfGames.ToolBuilder
.RegisterAssemblyTypes(this.GetType().Assembly)
.AsSelf()
.AsImplementedInterfaces();
builder
.Register(
c =>
{
var root = new RootCommand
{
Name = "sample-tool",
Description =
"A sample tool that demonstrates functionality",
};
root.AddCommand(c.Resolve<CrashCommand>());
root.AddCommand(c.Resolve<TableCommand>());
root.AddCommand(c.Resolve<LogCommand>());
return root;
})
.AsSelf();
}
}
}

View file

@ -2,23 +2,21 @@ using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.IO;
using System.Data;
using System.Threading.Tasks;
using MfGames.ToolBuilder;
using MfGames.ToolBuilder.Tables;
namespace SampleTool
{
public class TableTopCommand : Command, ITopCommand, ICommandHandler
public class TableCommand : Command, ICommandHandler
{
private readonly DataTable table;
private readonly TableToolService tableService;
/// <inheritdoc />
public TableTopCommand(TableToolService.Factory tableService)
public TableCommand(TableToolService.Factory tableService)
: base("table", "Display a table.")
{
// Create the table structure.
@ -36,7 +34,7 @@ namespace SampleTool
"DefaultString",
"DefaultInt32",
});
// This class handles the command.
this.Handler = this;
}