Fixed exceptions and added constraints to commands

This commit is contained in:
mdnapo 2024-07-23 21:46:11 +02:00
parent d210c6ac7c
commit 5ccb40bb44
14 changed files with 183 additions and 25 deletions

View File

@ -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}");
}
}

View File

@ -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}");
}
}

View File

@ -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.");
}
}

View File

@ -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<MycroForge>
{
public Api(IEnumerable<ISubCommandOf<Api>> commands) :
public Api(IEnumerable<ISubCommandOf<Api>> commands) :
base("api", "API related commands")
{
foreach (var command in commands)

View File

@ -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<MycroForge>
{
public Db(IEnumerable<ISubCommandOf<Db>> commands)

View File

@ -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<MycroForge>
{
private readonly ProjectContext _context;
public Hydrate(ProjectContext context)
public Hydrate(ProjectContext context)
: base("hydrate", "Initialize venv and install dependencies from requirements.txt")
{
_context = context;

View File

@ -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;
}

View File

@ -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<Plugin>
{
public enum TargetPlatform

View File

@ -27,9 +27,17 @@ public partial class MycroForge
private async Task ExecuteAsync(IEnumerable<string> 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"
);
}

View File

@ -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<RequiresFeatureAttribute>();
private static RequiresFileAttribute? GetRequiresFileAttribute(this Command command) =>
command.GetType().GetCustomAttribute<RequiresFileAttribute>();
private static RequiresPluginAttribute? GetRequiresPluginAttribute(this Command command) =>
command.GetType().GetCustomAttribute<RequiresPluginAttribute>();
private static List<Command> GetCommandChain(this InvocationContext context)
{
var chain = new List<Command>();
/*
* 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;
}
}

View File

@ -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<ProjectContext>();

View File

@ -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<MycroForge.CLI.Commands.MycroForge>()
.InvokeAsync(args.Length == 0 ? ["--help"] : args);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
var command = host.Services.GetRequiredService<RootCommand>();
await command.ExecuteAsync(args.Length == 0 ? ["--help"] : args);

View File

@ -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<string> 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)

View File

@ -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.
- Theme the site with a custom color scheme and icon/logos
-