diff --git a/MycroForge.CLI/Commands/Attributes/RequiresFeatureAttribute.cs b/MycroForge.CLI/Commands/Attributes/RequiresFeatureAttribute.cs new file mode 100644 index 0000000..f6b0502 --- /dev/null +++ b/MycroForge.CLI/Commands/Attributes/RequiresFeatureAttribute.cs @@ -0,0 +1,17 @@ +namespace MycroForge.CLI.Commands.Attributes; + +public class RequiresFeatureAttribute : Attribute +{ + public string FeatureName { get; } + + public RequiresFeatureAttribute(string featureName) + { + FeatureName = featureName; + } + + public void RequireFeature(string command) + { + if (!Directory.Exists(Path.Join(Environment.CurrentDirectory, FeatureName))) + throw new($"Command '{command}' requires feature {FeatureName}"); + } +} \ No newline at end of file diff --git a/MycroForge.CLI/Commands/Attributes/RequiresFileAttribute.cs b/MycroForge.CLI/Commands/Attributes/RequiresFileAttribute.cs new file mode 100644 index 0000000..4568865 --- /dev/null +++ b/MycroForge.CLI/Commands/Attributes/RequiresFileAttribute.cs @@ -0,0 +1,17 @@ +namespace MycroForge.CLI.Commands.Attributes; + +public class RequiresFileAttribute : Attribute +{ + public string FilePath { get; } + + public RequiresFileAttribute(string filePath) + { + FilePath = filePath; + } + + public void RequireFile(string command) + { + if (!File.Exists(Path.Join(Environment.CurrentDirectory, FilePath))) + throw new($"Command '{command}' requires file {FilePath}"); + } +} \ No newline at end of file diff --git a/MycroForge.CLI/Commands/Attributes/RequiresPluginAttribute.cs b/MycroForge.CLI/Commands/Attributes/RequiresPluginAttribute.cs new file mode 100644 index 0000000..3efa19f --- /dev/null +++ b/MycroForge.CLI/Commands/Attributes/RequiresPluginAttribute.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; + +namespace MycroForge.CLI.Commands.Attributes; + +public class RequiresPluginAttribute : Attribute +{ + public void RequirePluginProject(string command) + { + var matcher = new Matcher() + .AddInclude("*.csproj") + .Execute(new DirectoryInfoWrapper(new DirectoryInfo(Environment.CurrentDirectory))); + + if (!matcher.HasMatches) + throw new($"Command '{command}' must be run in a command plugin project."); + + var csprojFileName = $"{Path.GetDirectoryName(Environment.CurrentDirectory)}.csproj"; + bool IsCsprojFile(FilePatternMatch file) => Path.GetFileNameWithoutExtension(file.Path).Equals(csprojFileName); + var hasCsprojFile = matcher.Files.Any(IsCsprojFile); + + if (!hasCsprojFile) + throw new($"File '{csprojFileName}' was not found, make sure you're in a command plugin project."); + } +} \ No newline at end of file diff --git a/MycroForge.CLI/Commands/MycroForge.Api.cs b/MycroForge.CLI/Commands/MycroForge.Api.cs index fba1c87..8ad0220 100644 --- a/MycroForge.CLI/Commands/MycroForge.Api.cs +++ b/MycroForge.CLI/Commands/MycroForge.Api.cs @@ -1,13 +1,15 @@ using System.CommandLine; +using MycroForge.CLI.Commands.Attributes; using MycroForge.Core.Contract; namespace MycroForge.CLI.Commands; public partial class MycroForge { + [RequiresFeature(Features.Api.FeatureName)] public partial class Api : Command, ISubCommandOf { - public Api(IEnumerable> commands) : + public Api(IEnumerable> commands) : base("api", "API related commands") { foreach (var command in commands) diff --git a/MycroForge.CLI/Commands/MycroForge.Db.cs b/MycroForge.CLI/Commands/MycroForge.Db.cs index 7b8cc99..f2b680b 100644 --- a/MycroForge.CLI/Commands/MycroForge.Db.cs +++ b/MycroForge.CLI/Commands/MycroForge.Db.cs @@ -1,10 +1,12 @@ using System.CommandLine; +using MycroForge.CLI.Commands.Attributes; using MycroForge.Core.Contract; namespace MycroForge.CLI.Commands; public partial class MycroForge { + [RequiresFeature(Features.Db.FeatureName)] public partial class Db : Command, ISubCommandOf { public Db(IEnumerable> commands) diff --git a/MycroForge.CLI/Commands/MycroForge.Hydrate.cs b/MycroForge.CLI/Commands/MycroForge.Hydrate.cs index bdbbd9d..3ed0e8f 100644 --- a/MycroForge.CLI/Commands/MycroForge.Hydrate.cs +++ b/MycroForge.CLI/Commands/MycroForge.Hydrate.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using MycroForge.CLI.Commands.Attributes; using MycroForge.Core; using MycroForge.Core.Contract; @@ -6,11 +7,12 @@ namespace MycroForge.CLI.Commands; public partial class MycroForge { + [RequiresFile("requirements.txt")] public class Hydrate : Command, ISubCommandOf { private readonly ProjectContext _context; - - public Hydrate(ProjectContext context) + + public Hydrate(ProjectContext context) : base("hydrate", "Initialize venv and install dependencies from requirements.txt") { _context = context; diff --git a/MycroForge.CLI/Commands/MycroForge.Install.cs b/MycroForge.CLI/Commands/MycroForge.Install.cs index 29e8b4c..f50aa75 100644 --- a/MycroForge.CLI/Commands/MycroForge.Install.cs +++ b/MycroForge.CLI/Commands/MycroForge.Install.cs @@ -28,7 +28,7 @@ public partial class MycroForge if (packs.Length == 0) { - Console.WriteLine("m4g install requires at least one package."); + Console.WriteLine("'m4g install' requires at least one package."); return; } diff --git a/MycroForge.CLI/Commands/MycroForge.Plugin.Install.cs b/MycroForge.CLI/Commands/MycroForge.Plugin.Install.cs index d6f75db..1ad3ebc 100644 --- a/MycroForge.CLI/Commands/MycroForge.Plugin.Install.cs +++ b/MycroForge.CLI/Commands/MycroForge.Plugin.Install.cs @@ -2,6 +2,7 @@ using Humanizer; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using MycroForge.CLI.Commands.Attributes; using MycroForge.Core; using MycroForge.Core.Contract; @@ -11,6 +12,7 @@ public partial class MycroForge { public partial class Plugin { + [RequiresPlugin] public class Install : Command, ISubCommandOf { public enum TargetPlatform diff --git a/MycroForge.CLI/Commands/MycroForge.Uninstall.cs b/MycroForge.CLI/Commands/MycroForge.Uninstall.cs index 2328f32..cb62329 100644 --- a/MycroForge.CLI/Commands/MycroForge.Uninstall.cs +++ b/MycroForge.CLI/Commands/MycroForge.Uninstall.cs @@ -27,9 +27,17 @@ public partial class MycroForge private async Task ExecuteAsync(IEnumerable packages, bool yes) { + var packs = packages.ToArray(); + + if (packs.Length == 0) + { + Console.WriteLine("'m4g uninstall' requires at least one package."); + return; + } + await _context.Bash( "source .venv/bin/activate", - $"pip uninstall{(yes ? " --yes " : " ")}{string.Join(' ', packages)}", + $"pip uninstall{(yes ? " --yes " : " ")}{string.Join(' ', packs)}", "pip freeze > requirements.txt" ); } diff --git a/MycroForge.CLI/Extensions/CommandExtensions.cs b/MycroForge.CLI/Extensions/CommandExtensions.cs new file mode 100644 index 0000000..dcde067 --- /dev/null +++ b/MycroForge.CLI/Extensions/CommandExtensions.cs @@ -0,0 +1,90 @@ +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using System.Reflection; +using MycroForge.CLI.Commands.Attributes; + +namespace MycroForge.CLI.Extensions; + +public static class CommandExtensions +{ + public static async Task ExecuteAsync(this Commands.MycroForge rootCommand, string[] args) + { + var parser = new CommandLineBuilder(rootCommand) + .AddMiddleware() + .UseDefaults() + .UseExceptionHandler((ex, ctx) => + { + /* + * Use a custom ExceptionHandler to prevent the System.CommandLine library from printing the StackTrace by default. + */ + Console.WriteLine(ex.Message); + + /* + * Set the exit code to a non-zero value to indicate to the shell that the process has failed. + */ + Environment.ExitCode = -1; + }) + .Build(); + + await parser.InvokeAsync(args); + } + + private static CommandLineBuilder AddMiddleware(this CommandLineBuilder builder) + { + builder.AddMiddleware(async (context, next) => + { + var commandChain = context.GetCommandChain(); + var commandText = string.Join(' ', commandChain.Select(cmd => cmd.Name)); + + foreach (var _command in commandChain) + { + if (_command.GetRequiresFeatureAttribute() is RequiresFeatureAttribute requiresFeatureAttribute) + requiresFeatureAttribute.RequireFeature(commandText); + + else if (_command.GetRequiresFileAttribute() is RequiresFileAttribute requiresFileAttribute) + requiresFileAttribute.RequireFile(commandText); + + else if (_command.GetRequiresPluginAttribute() is RequiresPluginAttribute requiresPluginAttribute) + requiresPluginAttribute.RequirePluginProject(commandText); + } + + await next(context); + }); + + return builder; + } + + private static RequiresFeatureAttribute? GetRequiresFeatureAttribute(this Command command) => + command.GetType().GetCustomAttribute(); + + private static RequiresFileAttribute? GetRequiresFileAttribute(this Command command) => + command.GetType().GetCustomAttribute(); + + private static RequiresPluginAttribute? GetRequiresPluginAttribute(this Command command) => + command.GetType().GetCustomAttribute(); + + private static List GetCommandChain(this InvocationContext context) + { + var chain = new List(); + /* + * The CommandResult property refers to the last command in the chain. + * So if the command is 'm4g api run' the CommandResult will refer to 'run'. + */ + SymbolResult? cmd = context.ParseResult.CommandResult; + + while (cmd is CommandResult result) + { + chain.Add(result.Command); + cmd = cmd.Parent; + } + + /* + * Reverse the chain to reflect the actual order of the commands. + */ + chain.Reverse(); + + return chain; + } +} \ No newline at end of file diff --git a/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs b/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs index 9cd9d7b..8f224a7 100644 --- a/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs +++ b/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ namespace MycroForge.CLI.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection RegisterCommandDefaults(this IServiceCollection services) + public static IServiceCollection RegisterDefaultCommands(this IServiceCollection services) { // Register ProjectContext, OptionsContainer & features services.AddScoped(); diff --git a/MycroForge.CLI/Program.cs b/MycroForge.CLI/Program.cs index cca964f..b36d080 100644 --- a/MycroForge.CLI/Program.cs +++ b/MycroForge.CLI/Program.cs @@ -1,25 +1,18 @@ -using System.CommandLine; -using MycroForge.CLI.Extensions; +using MycroForge.CLI.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using RootCommand = MycroForge.CLI.Commands.MycroForge; using var host = Host .CreateDefaultBuilder() .ConfigureServices((_, services) => { services - .RegisterCommandDefaults() + .RegisterDefaultCommands() .RegisterCommandPlugins() ; }) .Build(); -try -{ - await host.Services.GetRequiredService() - .InvokeAsync(args.Length == 0 ? ["--help"] : args); -} -catch (Exception e) -{ - Console.WriteLine(e.Message); -} \ No newline at end of file +var command = host.Services.GetRequiredService(); +await command.ExecuteAsync(args.Length == 0 ? ["--help"] : args); diff --git a/MycroForge.Core/ProjectContext.cs b/MycroForge.Core/ProjectContext.cs index 753b227..4269464 100644 --- a/MycroForge.Core/ProjectContext.cs +++ b/MycroForge.Core/ProjectContext.cs @@ -59,6 +59,7 @@ public class ProjectContext Directory.CreateDirectory(fileInfo.Directory!.FullName); await File.WriteAllTextAsync(fullPath, string.Join("\n", content)); await Bash($"chmod 777 {fullPath}"); + Console.WriteLine($"Created file {path}"); } public async Task ReadFile(string path) @@ -78,6 +79,7 @@ public class ProjectContext var fileInfo = new FileInfo(fullPath); Directory.CreateDirectory(fileInfo.Directory!.FullName); await File.WriteAllTextAsync(fullPath, string.Join("\n", content)); + Console.WriteLine($"Modified file {path}"); } public async Task Bash(params string[] script) @@ -114,16 +116,15 @@ public class ProjectContext process.BeginErrorReadLine(); await using var input = process.StandardInput; - foreach (var line in script) - await input.WriteLineAsync(line); + // Concat with '&&' operator to make sure that script does not continue on failure. + await input.WriteAsync(string.Join(" && ", script)); await input.FlushAsync(); input.Close(); await process.WaitForExitAsync(); - if (process.ExitCode != 0) - Console.WriteLine($"Process finished with exit code {process.ExitCode}."); + Environment.ExitCode = process.ExitCode; } public async Task SaveConfig(ProjectConfig config) diff --git a/README.md b/README.md index 1dab471..5e64def 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ dotnet nuget add source --name devdisciples --username username --password passw ### TODO - Fix `-c` option for `m4g db generate entity` -- Research if System.CommandLine middleware can be used to safeguard commands like `m4g add` or `m4g api`. -- Fix up exception handling +- Add a CLI UI library - Clean up README files -- Print the path of generated files. \ No newline at end of file +- Theme the site with a custom color scheme and icon/logos +- \ No newline at end of file