Added scripting capabilities and cleaned up

This commit is contained in:
mdnapo 2024-04-26 22:52:10 +02:00
parent 68b9b0e8c8
commit b23ba99ba4
18 changed files with 216 additions and 119 deletions

View File

@ -1,6 +0,0 @@
namespace MycroForge.CLI;
public class ArgsContext
{
public string[] Args { get; init; } = Array.Empty<string>();
}

View File

@ -1,52 +0,0 @@
using System.Diagnostics;
namespace MycroForge.CLI;
public static class Bash
{
public static async Task ExecuteAsync(params string[] script)
{
var info = new ProcessStartInfo
{
FileName = "bash",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var process = Process.Start(info);
if (process is null)
throw new NullReferenceException("Could not initialize bash process.");
process.OutputDataReceived += (sender, args) =>
{
// Only print data when it's not empty to prevent noise in the shell
if (!string.IsNullOrEmpty(args.Data))
Console.WriteLine(args.Data);
};
process.BeginOutputReadLine();
process.ErrorDataReceived += (sender, args) =>
{
// Only print data when it's not empty to prevent noise in the shell
if (!string.IsNullOrEmpty(args.Data))
Console.WriteLine(args.Data);
};
process.BeginErrorReadLine();
await using var input = process.StandardInput;
foreach (var line in script)
await input.WriteLineAsync(line);
await input.FlushAsync();
input.Close();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
Console.WriteLine($"Process finished with exit code {process.ExitCode}.");
}
}

View File

@ -9,14 +9,17 @@ public partial class MycroForge
{
public class Run : Command, ISubCommandOf<Api>
{
public Run() : base("run", "Run your app")
private readonly ProjectContext _context;
public Run(ProjectContext context) : base("run", "Run your app")
{
_context = context;
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
await Bash.ExecuteAsync([
await _context.Bash([
"source .venv/bin/activate",
"uvicorn main:app --reload"
]);

View File

@ -50,13 +50,12 @@ public partial class MycroForge
// Create the config file and initialize the config
await _context.CreateFile("m4g.json", "{}");
await _context.LoadConfig(force: true);
// Create the entrypoint file
await _context.CreateFile("main.py");
// Create the venv
await Bash.ExecuteAsync($"python3 -m venv {Path.Combine(projectRoot, ".venv")}");
await _context.Bash($"python3 -m venv {Path.Combine(projectRoot, ".venv")}");
// Initialize default features
foreach (var feature in _features.Where(f => DefaultFeatures.Contains(f.Name)))
@ -77,7 +76,7 @@ public partial class MycroForge
Directory.CreateDirectory(directory);
await Bash.ExecuteAsync($"chmod -R 777 {directory}");
await _context.Bash($"chmod -R 777 {directory}");
return directory;
}

View File

@ -10,8 +10,12 @@ public partial class MycroForge
private static readonly Argument<IEnumerable<string>> PackagesArgument =
new(name: "packages", description: "The names of the packages to install");
public Install() : base("install", "Install packages and update the requirements.txt")
private readonly ProjectContext _context;
public Install(ProjectContext context) :
base("install", "Install packages and update the requirements.txt")
{
_context = context;
AddAlias("i");
AddArgument(PackagesArgument);
this.SetHandler(ExecuteAsync, PackagesArgument);
@ -19,7 +23,7 @@ public partial class MycroForge
private async Task ExecuteAsync(IEnumerable<string> packages)
{
await Bash.ExecuteAsync(
await _context.Bash(
"source .venv/bin/activate",
$"pip install {string.Join(' ', packages)}",
"pip freeze > requirements.txt"

View File

@ -14,8 +14,11 @@ public partial class MycroForge
private static readonly Argument<string> NameArgument =
new(name: "name", description: "The name of the migration");
public Migration() : base("migration", "Generate a migration")
private readonly ProjectContext _context;
public Migration(ProjectContext context) : base("migration", "Generate a migration")
{
_context = context;
AddAlias("m");
AddArgument(NameArgument);
this.SetHandler(ExecuteAsync, NameArgument);
@ -23,7 +26,7 @@ public partial class MycroForge
private async Task ExecuteAsync(string name)
{
await Bash.ExecuteAsync(
await _context.Bash(
"source .venv/bin/activate",
$"alembic revision --autogenerate -m \"{name}\" --rev-id $(date -u +\"%Y%m%d%H%M%S\")"
);

View File

@ -9,14 +9,17 @@ public partial class MycroForge
{
public class Migrate : Command, ISubCommandOf<Orm>
{
public Migrate() : base("migrate", "Apply migrations to the database")
private readonly ProjectContext _context;
public Migrate(ProjectContext context) : base("migrate", "Apply migrations to the database")
{
_context = context;
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
await Bash.ExecuteAsync([
await _context.Bash([
"source .venv/bin/activate",
"alembic upgrade head"
]);

View File

@ -9,14 +9,17 @@ public partial class MycroForge
{
public class Rollback : Command, ISubCommandOf<Orm>
{
public Rollback() : base("rollback", "Rollback the last migration")
private readonly ProjectContext _context;
public Rollback(ProjectContext context) : base("rollback", "Rollback the last migration")
{
_context = context;
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
await Bash.ExecuteAsync([
await _context.Bash([
"source .venv/bin/activate",
"alembic downgrade -1"
]);

View File

@ -0,0 +1,82 @@
using System.CommandLine;
using System.Dynamic;
using System.Text;
using IronPython.Hosting;
using MycroForge.CLI.Commands.Interfaces;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Script
{
public class Run : Command, ISubCommandOf<Script>
{
private readonly ProjectContext _context;
private static readonly Argument<string> NameArgument =
new(name: "name", description: "The name of the script");
private static readonly Argument<IEnumerable<string>> ArgsArgument =
new(name: "args", description: "The args to the script");
public Run(ProjectContext context) : base("run", "Run a script")
{
_context = context;
AddArgument(NameArgument);
AddArgument(ArgsArgument);
this.SetHandler(ExecuteAsync, NameArgument, ArgsArgument);
}
private void ExecuteAsync(string name, IEnumerable<string> args)
{
var engine = Python.CreateEngine();
using var output = new MemoryStream();
using var error = new MemoryStream();
engine.Runtime.IO.SetOutput(output, Encoding.Default);
engine.Runtime.IO.SetErrorOutput(error, Encoding.Default);
var scope = engine.CreateScope();
scope.SetVariable("args", args.ToArray());
scope.SetVariable("context", CreateScriptContext());
try
{
var scriptPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".m4g", name
);
engine.ExecuteFile(scriptPath, scope);
if (output.Length > 0)
Console.WriteLine(Encoding.Default.GetString(output.ToArray()));
if (error.Length > 0)
Console.WriteLine(Encoding.Default.GetString(error.ToArray()));
}
catch (Exception e)
{
Console.WriteLine(e);
}
finally
{
engine.Runtime.Shutdown();
}
}
private dynamic CreateScriptContext()
{
var createFile = _context.CreateFile;
var readFile = _context.ReadFile;
var writeFile = _context.WriteFile;
var bash = _context.Bash;
dynamic context = new ExpandoObject();
context.create_file = createFile;
context.read_file = readFile;
context.write_file = writeFile;
context.bash = bash;
return context;
}
}
}
}

View File

@ -0,0 +1,18 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Interfaces;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Script : Command, ISubCommandOf<MycroForge>
{
public Script(IEnumerable<ISubCommandOf<Script>> subCommands) :
base("script", "Script related commands")
{
AddAlias("s");
foreach (var subCommandOf in subCommands.Cast<Command>())
AddCommand(subCommandOf);
}
}
}

View File

@ -10,8 +10,11 @@ public partial class MycroForge
private static readonly Argument<IEnumerable<string>> PackagesArgument =
new(name: "packages", description: "The names of the packages to uninstall");
public Uninstall() : base("uninstall", "Uninstall packages and update the requirements.txt")
private readonly ProjectContext _context;
public Uninstall(ProjectContext context) : base("uninstall", "Uninstall packages and update the requirements.txt")
{
_context = context;
AddAlias("u");
AddArgument(PackagesArgument);
this.SetHandler(ExecuteAsync, PackagesArgument);
@ -19,7 +22,7 @@ public partial class MycroForge
private async Task ExecuteAsync(IEnumerable<string> packages)
{
await Bash.ExecuteAsync(
await _context.Bash(
"source .venv/bin/activate",
$"pip uninstall {string.Join(' ', packages)}",
"pip freeze > requirements.txt"

View File

@ -6,14 +6,8 @@ namespace MycroForge.CLI.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddServices(this IServiceCollection services, string[] args)
public static IServiceCollection AddServices(this IServiceCollection services)
{
services.AddScoped<ArgsContext>(_ =>
new ArgsContext
{
// Make sure to display the help text when no args are passed
Args = args.Length == 0 ? ["--help"] : args
});
services.AddScoped<ProjectContext>();
services.AddScoped<IFeature, Git>();
services.AddScoped<IFeature, Api>();
@ -42,12 +36,15 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm>, Commands.MycroForge.Orm.Rollback>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm>, Commands.MycroForge.Orm.Generate>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm.Generate>, Commands.MycroForge.Orm.Generate.Entity>();
services
.AddScoped<ISubCommandOf<Commands.MycroForge.Orm.Generate>, Commands.MycroForge.Orm.Generate.Migration>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm.Generate>, Commands.MycroForge.Orm.Generate.Migration>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm>, Commands.MycroForge.Orm.Link>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm.Link>, Commands.MycroForge.Orm.Link.One>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm.Link>, Commands.MycroForge.Orm.Link.Many>();
// Register "m4g script"
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Script>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Script>, Commands.MycroForge.Script.Run>();
return services;
}
}

View File

@ -34,13 +34,15 @@ public sealed class Api : IFeature
public async Task ExecuteAsync(ProjectContext context)
{
if (context.Config.Features.Contains(FeatureName))
var config = await context.LoadConfig();
if (config.Features.Contains(FeatureName))
{
Console.WriteLine($"Feature {FeatureName} has already been initialized.");
return;
}
await Bash.ExecuteAsync(
await context.Bash(
"source .venv/bin/activate",
"python3 -m pip install fastapi uvicorn[standard]",
"python3 -m pip freeze > requirements.txt"
@ -52,6 +54,8 @@ public sealed class Api : IFeature
main = string.Join('\n', Main) + main;
await context.WriteFile("main.py", main);
context.Config.Features.Add(FeatureName);
config.Features.Add(FeatureName);
await context.SaveConfig(config);
}
}

View File

@ -55,6 +55,6 @@ public class Git : IFeature
public async Task ExecuteAsync(ProjectContext context)
{
await context.CreateFile(".gitignore", GitIgnore);
await Bash.ExecuteAsync($"git -c init.defaultBranch=main init {context.RootDirectory}");
await context.Bash($"git -c init.defaultBranch=main init {context.RootDirectory}");
}
}

View File

@ -58,13 +58,15 @@ public sealed class Orm : IFeature
public async Task ExecuteAsync(ProjectContext context)
{
if (context.Config.Features.Contains(FeatureName))
var config = await context.LoadConfig();
if (config.Features.Contains(FeatureName))
{
Console.WriteLine($"Feature {FeatureName} has already been initialized.");
return;
}
await Bash.ExecuteAsync(
await context.Bash(
"source .venv/bin/activate",
"python3 -m pip install asyncmy sqlalchemy alembic",
"python3 -m pip freeze > requirements.txt",
@ -84,6 +86,8 @@ public sealed class Orm : IFeature
await context.CreateFile("orm/entities/user.py", User);
context.Config.Features.Add(FeatureName);
config.Features.Add(FeatureName);
await context.SaveConfig(config);
}
}

View File

@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="Antlr4.Runtime" Version="4.6.6" />
<PackageReference Include="Humanizer" Version="2.14.1" />
<PackageReference Include="IronPython" Version="3.4.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />

View File

@ -9,17 +9,14 @@ using var host = Host
.ConfigureServices((_, services) =>
{
services
.AddServices(args)
.AddServices()
.AddCommands();
})
.Build();
try
{
var ctx = host.Services.GetRequiredService<ProjectContext>();
await ctx.LoadConfig();
await host.Services.GetRequiredService<MycroForge.CLI.Commands.MycroForge>().InvokeAsync(args);
await ctx.SaveConfig();
}
catch(Exception e)
{

View File

@ -1,4 +1,5 @@
using System.Text.Json;
using System.Diagnostics;
using System.Text.Json;
using MycroForge.CLI.Extensions;
namespace MycroForge.CLI;
@ -6,33 +7,23 @@ namespace MycroForge.CLI;
public class ProjectContext
{
public string RootDirectory { get; private set; } = Environment.CurrentDirectory;
public string ConfigPath => Path.Combine(RootDirectory, "m4g.json");
public ProjectConfig Config { get; private set; } = default!;
private string ConfigPath => Path.Combine(RootDirectory, "m4g.json");
private readonly ArgsContext _argsContext;
public ProjectContext(ArgsContext argsContext)
public async Task<ProjectConfig> LoadConfig()
{
_argsContext = argsContext;
}
public async Task LoadConfig(bool force = false)
{
if (_argsContext.Args
is ["init", ..]
or ["-?", ..] or [.., "-?"]
or ["-h", ..] or [.., "-h"]
or ["--help"] or [.., "--help"]
or ["--version"] && !force)
return;
if (!File.Exists(ConfigPath))
throw new FileNotFoundException($"File {ConfigPath} does not exist.");
Config = (await JsonSerializer.DeserializeAsync<ProjectConfig>(
var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(
File.OpenRead(ConfigPath),
Shared.DefaultJsonSerializerOptions.CamelCasePrettyPrint
))!;
);
if (config is null)
throw new Exception($"Could not load {ConfigPath}");
return config;
}
public void ChangeDirectory(string path)
@ -50,7 +41,7 @@ public class ProjectContext
Directory.CreateDirectory(fileInfo.Directory!.FullName);
await File.WriteAllTextAsync(fullPath, string.Join("\n", content));
await Bash.ExecuteAsync($"chmod 777 {fullPath}");
await Bash($"chmod 777 {fullPath}");
}
public async Task<string> ReadFile(string path)
@ -72,12 +63,55 @@ public class ProjectContext
await File.WriteAllTextAsync(fullPath, string.Join("\n", content));
}
public async Task SaveConfig()
public async Task Bash(params string[] script)
{
if (Config is not null)
var info = new ProcessStartInfo
{
var json = await Config.SerializeAsync(Shared.DefaultJsonSerializerOptions.CamelCasePrettyPrint);
FileName = "bash",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var process = Process.Start(info);
if (process is null)
throw new NullReferenceException("Could not initialize bash process.");
process.OutputDataReceived += (sender, args) =>
{
// Only print data when it's not empty to prevent noise in the shell
if (!string.IsNullOrEmpty(args.Data))
Console.WriteLine(args.Data);
};
process.BeginOutputReadLine();
process.ErrorDataReceived += (sender, args) =>
{
// Only print data when it's not empty to prevent noise in the shell
if (!string.IsNullOrEmpty(args.Data))
Console.WriteLine(args.Data);
};
process.BeginErrorReadLine();
await using var input = process.StandardInput;
foreach (var line in script)
await input.WriteLineAsync(line);
await input.FlushAsync();
input.Close();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
Console.WriteLine($"Process finished with exit code {process.ExitCode}.");
}
public async Task SaveConfig(ProjectConfig config)
{
var json = await config.SerializeAsync(Shared.DefaultJsonSerializerOptions.CamelCasePrettyPrint);
await File.WriteAllTextAsync(ConfigPath, json);
}
}
}