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 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); this.SetHandler(ExecuteAsync);
} }
private async Task ExecuteAsync() private async Task ExecuteAsync()
{ {
await Bash.ExecuteAsync([ await _context.Bash([
"source .venv/bin/activate", "source .venv/bin/activate",
"uvicorn main:app --reload" "uvicorn main:app --reload"
]); ]);

View File

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

View File

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

View File

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

View File

@ -9,14 +9,17 @@ public partial class MycroForge
{ {
public class Rollback : Command, ISubCommandOf<Orm> 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); this.SetHandler(ExecuteAsync);
} }
private async Task ExecuteAsync() private async Task ExecuteAsync()
{ {
await Bash.ExecuteAsync([ await _context.Bash([
"source .venv/bin/activate", "source .venv/bin/activate",
"alembic downgrade -1" "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 = private static readonly Argument<IEnumerable<string>> PackagesArgument =
new(name: "packages", description: "The names of the packages to uninstall"); 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"); AddAlias("u");
AddArgument(PackagesArgument); AddArgument(PackagesArgument);
this.SetHandler(ExecuteAsync, PackagesArgument); this.SetHandler(ExecuteAsync, PackagesArgument);
@ -19,7 +22,7 @@ public partial class MycroForge
private async Task ExecuteAsync(IEnumerable<string> packages) private async Task ExecuteAsync(IEnumerable<string> packages)
{ {
await Bash.ExecuteAsync( await _context.Bash(
"source .venv/bin/activate", "source .venv/bin/activate",
$"pip uninstall {string.Join(' ', packages)}", $"pip uninstall {string.Join(' ', packages)}",
"pip freeze > requirements.txt" "pip freeze > requirements.txt"

View File

@ -6,14 +6,8 @@ namespace MycroForge.CLI.Extensions;
public static class ServiceCollectionExtensions 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<ProjectContext>();
services.AddScoped<IFeature, Git>(); services.AddScoped<IFeature, Git>();
services.AddScoped<IFeature, Api>(); 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.Rollback>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm>, Commands.MycroForge.Orm.Generate>(); 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.Entity>();
services services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm.Generate>, Commands.MycroForge.Orm.Generate.Migration>();
.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>, 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.One>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Orm.Link>, Commands.MycroForge.Orm.Link.Many>(); 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; return services;
} }
} }

View File

@ -34,13 +34,15 @@ public sealed class Api : IFeature
public async Task ExecuteAsync(ProjectContext context) 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."); Console.WriteLine($"Feature {FeatureName} has already been initialized.");
return; return;
} }
await Bash.ExecuteAsync( await context.Bash(
"source .venv/bin/activate", "source .venv/bin/activate",
"python3 -m pip install fastapi uvicorn[standard]", "python3 -m pip install fastapi uvicorn[standard]",
"python3 -m pip freeze > requirements.txt" "python3 -m pip freeze > requirements.txt"
@ -52,6 +54,8 @@ public sealed class Api : IFeature
main = string.Join('\n', Main) + main; main = string.Join('\n', Main) + main;
await context.WriteFile("main.py", 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) public async Task ExecuteAsync(ProjectContext context)
{ {
await context.CreateFile(".gitignore", GitIgnore); 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) 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."); Console.WriteLine($"Feature {FeatureName} has already been initialized.");
return; return;
} }
await Bash.ExecuteAsync( await context.Bash(
"source .venv/bin/activate", "source .venv/bin/activate",
"python3 -m pip install asyncmy sqlalchemy alembic", "python3 -m pip install asyncmy sqlalchemy alembic",
"python3 -m pip freeze > requirements.txt", "python3 -m pip freeze > requirements.txt",
@ -84,6 +86,8 @@ public sealed class Orm : IFeature
await context.CreateFile("orm/entities/user.py", User); 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> <ItemGroup>
<PackageReference Include="Antlr4.Runtime" Version="4.6.6" /> <PackageReference Include="Antlr4.Runtime" Version="4.6.6" />
<PackageReference Include="Humanizer" Version="2.14.1" /> <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" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" 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" /> <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />

View File

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

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Diagnostics;
using System.Text.Json;
using MycroForge.CLI.Extensions; using MycroForge.CLI.Extensions;
namespace MycroForge.CLI; namespace MycroForge.CLI;
@ -6,33 +7,23 @@ namespace MycroForge.CLI;
public class ProjectContext public class ProjectContext
{ {
public string RootDirectory { get; private set; } = Environment.CurrentDirectory; public string RootDirectory { get; private set; } = Environment.CurrentDirectory;
public string ConfigPath => Path.Combine(RootDirectory, "m4g.json"); private string ConfigPath => Path.Combine(RootDirectory, "m4g.json");
public ProjectConfig Config { get; private set; } = default!;
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)) if (!File.Exists(ConfigPath))
throw new FileNotFoundException($"File {ConfigPath} does not exist."); throw new FileNotFoundException($"File {ConfigPath} does not exist.");
Config = (await JsonSerializer.DeserializeAsync<ProjectConfig>( var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(
File.OpenRead(ConfigPath), File.OpenRead(ConfigPath),
Shared.DefaultJsonSerializerOptions.CamelCasePrettyPrint Shared.DefaultJsonSerializerOptions.CamelCasePrettyPrint
))!; );
if (config is null)
throw new Exception($"Could not load {ConfigPath}");
return config;
} }
public void ChangeDirectory(string path) public void ChangeDirectory(string path)
@ -50,7 +41,7 @@ public class ProjectContext
Directory.CreateDirectory(fileInfo.Directory!.FullName); Directory.CreateDirectory(fileInfo.Directory!.FullName);
await File.WriteAllTextAsync(fullPath, string.Join("\n", content)); 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) public async Task<string> ReadFile(string path)
@ -72,12 +63,55 @@ public class ProjectContext
await File.WriteAllTextAsync(fullPath, string.Join("\n", content)); 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); await File.WriteAllTextAsync(ConfigPath, json);
} }
}
} }