From cea3d11a41267cd7142e45951fc3ad70a6ddbcf6 Mon Sep 17 00:00:00 2001 From: Aleksander Cynarski Date: Sun, 20 Apr 2025 12:38:27 +0200 Subject: [PATCH] feat: Dynamic command loader, default command with module registry --- .gitignore | 1 + Automancer.csproj | 6 +- Commands/Container/Module.cs | 26 +++++++++ Commands/Container/PsCommand.cs | 31 +++++++++++ Commands/Image/BuildCommand.cs | 34 +++++++++++ Commands/Image/Module.cs | 25 +++++++++ Commands/Image/PushCommand.cs | 30 ++++++++++ Commands/RootCommand.cs | 38 +++++++++++++ Common/CommandRegistry.cs | 13 +++++ Common/ICommandModule.cs | 15 +++++ Common/TypeRegistrar.cs | 34 +++++++++++ Common/TypeResolver.cs | 24 ++++++++ Program.cs | 99 ++++++++++++++++++++++++++++++++- 13 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 Commands/Container/Module.cs create mode 100644 Commands/Container/PsCommand.cs create mode 100644 Commands/Image/BuildCommand.cs create mode 100644 Commands/Image/Module.cs create mode 100644 Commands/Image/PushCommand.cs create mode 100644 Commands/RootCommand.cs create mode 100644 Common/CommandRegistry.cs create mode 100644 Common/ICommandModule.cs create mode 100644 Common/TypeRegistrar.cs create mode 100644 Common/TypeResolver.cs diff --git a/.gitignore b/.gitignore index bd67d36..e4a167e 100644 --- a/.gitignore +++ b/.gitignore @@ -402,3 +402,4 @@ FodyWeavers.xsd # End of https://www.toptal.com/developers/gitignore/api/csharp .DS_Store .vscode +.idea diff --git a/Automancer.csproj b/Automancer.csproj index e3fb451..f4d5391 100644 --- a/Automancer.csproj +++ b/Automancer.csproj @@ -8,7 +8,11 @@ - + + + + + diff --git a/Commands/Container/Module.cs b/Commands/Container/Module.cs new file mode 100644 index 0000000..5599757 --- /dev/null +++ b/Commands/Container/Module.cs @@ -0,0 +1,26 @@ +using Automancer.Command.Image; +using Automancer.Common; +using Spectre.Console.Cli; + +namespace Automancer.Command.Container; + +public class Module : ICommandModuleWithRegistry +{ + public void Configure(IConfigurator config) + { + // No implementation needed here + } + + public void Configure(IConfigurator config, CommandRegistry registry) + { + config.AddBranch("container", container => + { + var description = "Container operations"; + + container.SetDescription(description); + registry.Add("container", description); + + container.AddCommand("ps").WithDescription("List containers"); + }); + } +} \ No newline at end of file diff --git a/Commands/Container/PsCommand.cs b/Commands/Container/PsCommand.cs new file mode 100644 index 0000000..ef095c4 --- /dev/null +++ b/Commands/Container/PsCommand.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Automancer.Command.Image; + +public class PsSettings: CommandSettings +{ + [CommandArgument(0, "[path]")] + [Description("Path to the Dockerfile")] + public string? Path { get; set; } + + [CommandOption("-t|--tag")] + [Description("Tag to use for the image")] + public string? Tag { get; set; } + + [CommandOption("-f|--file")] + [Description("Path to the Dockerfile")] + public string? File { get; set; } + +} + +public class PsCommand: Command +{ + public override int Execute(CommandContext context, BuldSettings settings) + { + Console.WriteLine($"Building image from {settings.Path} with tag {settings.Tag}"); + + return 0; + } +} + diff --git a/Commands/Image/BuildCommand.cs b/Commands/Image/BuildCommand.cs new file mode 100644 index 0000000..1c22d32 --- /dev/null +++ b/Commands/Image/BuildCommand.cs @@ -0,0 +1,34 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Automancer.Command.Image; + +public class BuldSettings: CommandSettings +{ + [CommandArgument(0, "[path]")] + [Description("Path to the Dockerfile")] + public string? Path { get; set; } + + [CommandOption("-t|--tag")] + [Description("Tag to use for the image")] + public string? Tag { get; set; } + + [CommandOption("-f|--file")] + [Description("Path to the Dockerfile")] + public string? File { get; set; } + + [CommandOption("--push")] + [Description("Push the image after building it")] + public bool Push { get; set; } +} + +public class BuildCommand : Command +{ + public override int Execute(CommandContext context, BuldSettings settings) + { + Console.WriteLine($"Building image from {settings.Path} with tag {settings.Tag}"); + + return 0; + } +} + diff --git a/Commands/Image/Module.cs b/Commands/Image/Module.cs new file mode 100644 index 0000000..111d209 --- /dev/null +++ b/Commands/Image/Module.cs @@ -0,0 +1,25 @@ +using Automancer.Common; +using Spectre.Console.Cli; + +namespace Automancer.Command.Image; + +public class Module : ICommandModuleWithRegistry +{ + public void Configure(IConfigurator config) + { + // No implementation needed here + } + public void Configure(IConfigurator config, CommandRegistry registry) + { + config.AddBranch("image", image => { + var description = "Docker/podman image operations"; + image.SetDescription(description); + registry.Add("image", description); + + image.AddCommand("build").WithDescription("Build a docker image"); + image.AddCommand("push").WithDescription("Push a docker image"); + + }); + } + +} \ No newline at end of file diff --git a/Commands/Image/PushCommand.cs b/Commands/Image/PushCommand.cs new file mode 100644 index 0000000..0108e7d --- /dev/null +++ b/Commands/Image/PushCommand.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace Automancer.Command.Image; + +public class PushSettings: CommandSettings +{ + [CommandArgument(0, "[path]")] + [Description("Path to the Dockerfile")] + public string? Path { get; set; } + + [CommandOption("-t|--tag")] + [Description("Tag to use for the image")] + public string? Tag { get; set; } + + [CommandOption("-f|--file")] + [Description("Path to the Dockerfile")] + public string? File { get; set; } +} + +public class PushCommand : Command +{ + public override int Execute(CommandContext context, BuldSettings settings) + { + Console.WriteLine($"Building image from {settings.Path} with tag {settings.Tag}"); + + return 0; + } +} + diff --git a/Commands/RootCommand.cs b/Commands/RootCommand.cs new file mode 100644 index 0000000..96c4e3b --- /dev/null +++ b/Commands/RootCommand.cs @@ -0,0 +1,38 @@ +using Automancer.Common; +using Spectre.Console; +using Spectre.Console.Cli; +using System.Diagnostics.CodeAnalysis; + +namespace Automancer.Commands; + +public class RootCommand : Spectre.Console.Cli.Command +{ + private readonly CommandRegistry _registry; + + public RootCommand(CommandRegistry registry) + { + _registry = registry; + } + public override int Execute([NotNull] CommandContext context) + { + AnsiConsole.Write( + new FigletText("AUTOMANCER") + .Centered() + .Color(Color.Cyan1)); + + AnsiConsole.MarkupLine("[bold]Automancer[/] is a modular CLI automation tool. It is designed to be extensible and easy to use."); + AnsiConsole.MarkupLine("Use [green]--help[/] or [green] --help[/] to get started."); + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("[bold yellow]Module[/]"); + table.AddColumn("[bold yellow]Description[/]"); + + foreach (var cmd in _registry.Commands.OrderBy(c => c.Path)) + { + table.AddRow($"[green]{cmd.Path}[/]", cmd.Description ?? "[dim]n/a[/]"); + } + + AnsiConsole.Write(table); + return 0; + } +} diff --git a/Common/CommandRegistry.cs b/Common/CommandRegistry.cs new file mode 100644 index 0000000..3c4a611 --- /dev/null +++ b/Common/CommandRegistry.cs @@ -0,0 +1,13 @@ +namespace Automancer.Common; + +public record CommandInfo(string Path, string? Description); + +public class CommandRegistry +{ + public List Commands { get; } = new(); + + public void Add(string path, string? description) + { + Commands.Add(new CommandInfo(path, description)); + } +} diff --git a/Common/ICommandModule.cs b/Common/ICommandModule.cs new file mode 100644 index 0000000..1fadae4 --- /dev/null +++ b/Common/ICommandModule.cs @@ -0,0 +1,15 @@ +namespace Automancer.Common; + +using Spectre.Console.Cli; + +public interface ICommandModule +{ + + void Configure(IConfigurator config); + +} + +public interface ICommandModuleWithRegistry : ICommandModule +{ + void Configure(IConfigurator config, CommandRegistry registry); +} \ No newline at end of file diff --git a/Common/TypeRegistrar.cs b/Common/TypeRegistrar.cs new file mode 100644 index 0000000..103d6ce --- /dev/null +++ b/Common/TypeRegistrar.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace Automancer.Common; + +public sealed class TypeRegistrar : ITypeRegistrar +{ + private readonly IServiceCollection _builder; + + public TypeRegistrar(IServiceCollection builder) + { + _builder = builder; + } + + public ITypeResolver Build() + { + return new TypeResolver(_builder.BuildServiceProvider()); + } + + public void Register(Type service, Type implementation) + { + _builder.AddSingleton(service, implementation); + } + + public void RegisterInstance(Type service, object implementation) + { + _builder.AddSingleton(service, implementation); + } + + public void RegisterLazy(Type service, Func factory) + { + _builder.AddSingleton(service, _ => factory()); + } +} \ No newline at end of file diff --git a/Common/TypeResolver.cs b/Common/TypeResolver.cs new file mode 100644 index 0000000..5b504a4 --- /dev/null +++ b/Common/TypeResolver.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace Automancer.Common; + +public sealed class TypeResolver : ITypeResolver, IDisposable +{ + private readonly ServiceProvider _provider; + + public TypeResolver(ServiceProvider provider) + { + _provider = provider; + } + + public object? Resolve(Type? type) + { + return type is null ? null : _provider.GetService(type); + } + + public void Dispose() + { + _provider.Dispose(); + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index 3751555..bca5197 100644 --- a/Program.cs +++ b/Program.cs @@ -1,2 +1,97 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +namespace Automancer; + +using System; +using System.Linq; +using System.Threading.Tasks; +using Automancer.Common; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Spectre.Console.Cli; +using Spectre.Console; +using System.Diagnostics.CodeAnalysis; + +using Automancer.Commands; + +public abstract class Program +{ + public static async Task Main(string[] args) + { + CommandApp? app = null; + + var host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Information); + }) + .ConfigureAppConfiguration((hostingContext, config) => { + var env = hostingContext.HostingEnvironment; + + config.SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables(prefix: "AUTOMANCER_") + .AddCommandLine(args); + }) + .ConfigureServices((context, services) => + { + var registry = new CommandRegistry(); + services.AddSingleton(registry); + + RegisterCommandModules(services); + + var registrar = new TypeRegistrar(services); + app = new CommandApp(registrar); + app.SetDefaultCommand(); + + var provider = services.BuildServiceProvider(); + + app.Configure(config => + { + config.SetApplicationName("automancer"); + + var modules = provider.GetServices(); + var registry = provider.GetRequiredService(); + + foreach (var module in modules) + { + if (module is ICommandModuleWithRegistry withRegistry) + { + withRegistry.Configure(config, registry); + } + else + { + module.Configure(config); + } + } + }); + }) + .Build(); + + return app != null ? await app.RunAsync(args) : 1; + } + + private static void RegisterCommandModules(IServiceCollection services) + { + var moduleType = typeof(ICommandModule); + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + var moduleTypes = assemblies + .SelectMany(a => + { + try { return a.GetTypes(); } + catch { return []; } + }) + .Where(t => moduleType.IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract) + .ToList(); + + foreach (var type in moduleTypes) + { + services.AddSingleton(type); + services.AddSingleton(typeof(ICommandModule), type); + } + } +} \ No newline at end of file