Compare commits

..

No commits in common. "main" and "plugin-refactor" have entirely different histories.

134 changed files with 17259 additions and 1060 deletions

View File

@ -1,19 +0,0 @@
# directories
**/bin/
**/obj/
**/out/
.git
.idea
docs
.gitignore
.dockerignore
MycroForge.sln.DotSettings.user
nuget.docker-compose.yml
README.md
# files
Dockerfile*
**/*.md
#MycroForge.PluginTemplate
#MycroForge.PluginTemplate.Package

View File

@ -1,31 +0,0 @@
name: Build and publish MycroForge.CLI
run-name: ${{ gitea.actor }} triggered a build for the MycroForge.CLI package
on: [ push ]
jobs:
build:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Build and publish NuGet package"
run: |
# Build the NuGet package
cd MycroForge.CLI
dotnet pack -v m
# Add the NuGet source
dotnet nuget add source --name devdisciples \
--username ${{ secrets.NUGET_USER }} \
--password ${{ secrets.NUGET_PASS }} \
--store-password-in-clear-text \
https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
# Get the path to the package
PACKAGE="nupkg/$(ls nupkg)"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

View File

@ -1,33 +0,0 @@
name: Build and publish MycroForge.Core
run-name: ${{ gitea.actor }} triggered a build for the MycroForge.Core package
on: [ workflow_dispatch ]
jobs:
build:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Build and publish NuGet package"
run: |
# Build the NuGet package
cd MycroForge.Core
dotnet publish
dotnet pack -v m
# Add the NuGet source
dotnet nuget add source --name devdisciples \
--username ${{ secrets.NUGET_USER }} \
--password ${{ secrets.NUGET_PASS }} \
--store-password-in-clear-text \
https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
# Set the path to the package
VERSION=$(grep '<Version>' < MycroForge.Core.csproj | sed 's/.*<Version>\(.*\)<\/Version>/\1/' | xargs)
PACKAGE="bin/Release/MycroForge.Core.$VERSION.nupkg"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

View File

@ -1,33 +0,0 @@
name: Build and publish MycroForge.PluginTemplate package
run-name: ${{ gitea.actor }} triggered a build for the MycroForge.PluginTemplate package
on: [ workflow_dispatch ]
jobs:
build:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Build and publish NuGet package"
run: |
# Build the NuGet package
cd MycroForge.PluginTemplate.Package
dotnet publish
dotnet pack -v m
# Add the NuGet source
dotnet nuget add source --name devdisciples \
--username ${{ secrets.NUGET_USER }} \
--password ${{ secrets.NUGET_PASS }} \
--store-password-in-clear-text \
https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
# Set the path to the package
VERSION=$(grep '<PackageVersion>' < MycroForge.PluginTemplate.Package.csproj | sed 's/.*<PackageVersion>\(.*\)<\/PackageVersion>/\1/' | xargs)
PACKAGE="bin/Release/MycroForge.PluginTemplate.Package.$VERSION.nupkg"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

View File

@ -1,20 +0,0 @@
name: Test MycroForge.CLI
run-name: ${{ gitea.actor }} triggered a test for the MycroForge.CLI
on: [ push ]
jobs:
test:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/develop'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Reference MycroForge.Core in MycroForge.PluginTemplate"
# The MycroForge.PluginTemplate project references MycroForge.Core as a package and not as a reference.
# This allows the 'm4g plugin init' command to pull in the core package from a package repository.
# To prevent the test command from trying to pull from the package repository, we reference the local project.
run: dotnet add MycroForge.PluginTemplate reference MycroForge.Core
- name: "Run MycroForge.CLI.Tests"
run: dotnet test

View File

@ -1,30 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim
# Copy the files to the /tool directory.
WORKDIR /tool
COPY . .
# Manually add a reference to MycroForge.Core in the MycroForge.PluginTemplate project,
# otherwise it will try to fetch it from a nuget repository.
RUN dotnet add MycroForge.PluginTemplate reference MycroForge.Core
# Install the tools required for testing
RUN apt update -y && \
apt upgrade -y && \
apt install -y git && \
apt install -y bash && \
apt install -y python3 && \
apt install -y python3-pip && \
apt install -y python3-venv
# Publish the CLI as a global tool and add the .dotnet/tools folder the the PATH variable.
WORKDIR /tool/MycroForge.CLI
RUN ./scripts/publish-tool.sh
ENV PATH="$PATH:/root/.dotnet/tools"
WORKDIR /test
SHELL ["/bin/bash", "-c"]
ENV PATH="$PATH:/root/.dotnet/tools"
CMD ["sleep", "infinity"]

View File

@ -1,11 +0,0 @@
using DotNet.Testcontainers.Containers;
namespace MycroForge.CLI.Tests.Extensions;
internal static class ContainerInterfaceExtensions
{
public static Task<byte[]> ReadFileFromRootAsync(this IContainer container, string file)
{
return container.ReadFileAsync($"/test/todo/{file}");
}
}

View File

@ -1 +0,0 @@
global using NUnit.Framework;

View File

@ -1,222 +0,0 @@
using System.Text;
using System.Text.Json;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Images;
using MycroForge.CLI.Tests.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Tests;
public class InitCommandTests
{
private IFutureDockerImage Image = null!;
private IContainer Container = null!;
private async Task CreateImage()
{
Image = new ImageFromDockerfileBuilder()
.WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty)
.WithDockerfile("Dockerfile")
.WithCleanUp(true)
.Build();
await Image.CreateAsync();
}
private async Task CreateContainer()
{
Container = new ContainerBuilder()
.WithImage(Image)
.WithCleanUp(true)
.Build();
await Container.StartAsync();
}
[OneTimeSetUp]
public async Task SetupOnce()
{
await CreateImage();
await CreateContainer();
await Container.ExecAsync(["m4g", "init", "todo"]);
}
[Test]
public async Task Creates_m4g_json()
{
var bytes = await Container.ReadFileFromRootAsync("m4g.json");
using var stream = new MemoryStream(bytes);
var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(stream, DefaultJsonSerializerOptions.Default);
Assert.That(config, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(config!.Api, Is.Not.Null);
Assert.That(config.Api.Port, Is.EqualTo(8000));
Assert.That(config.Db, Is.Not.Null);
Assert.That(config.Db.DbhPort, Is.EqualTo(5050));
Assert.That(config.Db.DbuPort, Is.EqualTo(5051));
});
}
[Test]
public async Task Creates_main_py()
{
var bytes = await Container.ReadFileFromRootAsync("main.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from fastapi import FastAPI"));
Assert.That(lines, Does.Contain("from api.routers import hello"));
Assert.That(lines, Does.Contain("app = FastAPI()"));
Assert.That(lines, Does.Contain("app.include_router(prefix=\"/hello\", router=hello.router)"));
});
}
[Test]
public async Task Creates_gitignore()
{
var bytes = await Container.ReadFileFromRootAsync(".gitignore");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain(".venv"));
Assert.That(lines, Does.Contain("__pycache__/"));
});
}
[Test]
public async Task Creates_alembic_ini()
{
var bytes = await Container.ReadFileFromRootAsync("alembic.ini");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("[alembic]"));
Assert.That(lines, Does.Contain("sqlalchemy.url = driver://user:pass@localhost/dbname"));
});
}
[Test]
public async Task Creates_db_docker_compose_yml()
{
var bytes = await Container.ReadFileFromRootAsync("db.docker-compose.yml");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain(" todo_mariadb:"));
Assert.That(lines, Does.Contain(" container_name: 'todo_mariadb'"));
Assert.That(lines, Does.Contain(" - '${DBH_PORT}:3306'"));
Assert.That(lines, Does.Contain(" MYSQL_DATABASE: 'todo'"));
Assert.That(lines, Does.Contain(" - 'todo_mariadb:/var/lib/mysql'"));
Assert.That(lines, Does.Contain(" todo_phpmyadmin:"));
Assert.That(lines, Does.Contain(" container_name: todo_phpmyadmin"));
Assert.That(lines, Does.Contain(" PMA_HOST: todo_mariadb"));
Assert.That(lines, Does.Contain(" - todo_mariadb"));
Assert.That(lines, Does.Contain(" todo_mariadb:"));
});
}
[Test]
public async Task Creates_api__router_hello_py()
{
var bytes = await Container.ReadFileFromRootAsync("api/routers/hello.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from fastapi import APIRouter"));
Assert.That(lines, Does.Contain("from fastapi.responses import JSONResponse"));
Assert.That(lines, Does.Contain("from fastapi.encoders import jsonable_encoder"));
Assert.That(lines, Does.Contain("router = APIRouter()"));
Assert.That(lines, Does.Contain("@router.get(\"/{name}\")"));
Assert.That(lines, Does.Contain("async def hello(name: str):"));
Assert.That(lines, Does.Contain("\treturn JSONResponse(status_code=200, content=jsonable_encoder({'greeting': f\"Hello, {name}!\"}))"));
});
}
[Test]
public async Task Creates_db__engine__async_session_py()
{
var bytes = await Container.ReadFileFromRootAsync("db/engine/async_session.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, AsyncSession"));
Assert.That(lines, Does.Contain("from db.settings import DbSettings"));
Assert.That(lines, Does.Contain("async_engine: AsyncEngine = create_async_engine(DbSettings.get_connectionstring())"));
Assert.That(lines, Does.Contain("def async_session() -> AsyncSession:"));
Assert.That(lines, Does.Contain("\treturn AsyncSession(async_engine, expire_on_commit=False)"));
});
}
[Test]
public async Task Creates_db__entities__entity_base_py()
{
var bytes = await Container.ReadFileFromRootAsync("db/entities/entity_base.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from sqlalchemy.orm import DeclarativeBase"));
Assert.That(lines, Does.Contain("class EntityBase(DeclarativeBase):"));
Assert.That(lines, Does.Contain("\tpass"));
});
}
[Test]
public async Task Creates_db__settings_py()
{
var bytes = await Container.ReadFileFromRootAsync("db/settings.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("class DbSettings:"));
Assert.That(lines, Does.Contain("\tdef get_connectionstring() -> str:"));
Assert.That(lines, Does.Contain("\t\tconnectionstring = \"mysql+asyncmy://root:password@localhost:5050/todo\""));
Assert.That(lines, Does.Contain("\t\treturn connectionstring"));
});
}
}

View File

@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="NUnit" Version="3.13.3"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1"/>
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="Testcontainers" Version="3.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MycroForge.Core\MycroForge.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -1,5 +1,4 @@
using Humanizer;
using MycroForge.CLI.Commands;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
@ -7,8 +6,6 @@ namespace MycroForge.CLI.CodeGen;
public class CrudRouterGenerator
{
#region Templates
private static readonly string[] Template =
[
"from typing import Annotated",
@ -82,8 +79,6 @@ public class CrudRouterGenerator
"\t\treturn JSONResponse(status_code=500, content=str(ex))",
];
#endregion
private readonly ProjectContext _context;
public CrudRouterGenerator(ProjectContext context)
@ -91,49 +86,65 @@ public class CrudRouterGenerator
_context = context;
}
public async Task Generate(FullyQualifiedName fqn)
public async Task Generate(string path, string entity)
{
var serviceClassName = $"{fqn.PascalizedName}Service";
var entityRoutePrefix = fqn.PascalizedName.Kebaberize().Pluralize().ToLower();
var entitySnakeCaseName = entity.Underscore().ToLower();
var entityClassName = entity.Pascalize();
var serviceClassName = $"{entityClassName}Service";
var entityRoutePrefix = entity.Kebaberize().Pluralize().ToLower();
var serviceFilePath = Path.Join(
Features.Api.FeatureName, "services", fqn.Namespace, $"{fqn.SnakeCasedName}_service"
);
var serviceImportPath = serviceFilePath.SlashesToDots();
var routerFolderPath = Path.Join(Features.Api.FeatureName, "routers", fqn.Namespace);
var routerFilePath = Path.Join(routerFolderPath, $"{fqn.SnakeCasedName}");
var routerImportPath = routerFolderPath.SlashesToDots();
var requestsFolderPath = Path.Join(Features.Api.FeatureName, "requests", fqn.Namespace);
var servicesFolderPath = $"{Features.Api.FeatureName}/services/{path}";
var serviceFilePath = $"{servicesFolderPath}/{entitySnakeCaseName}_service.py";
var serviceImportPath = serviceFilePath
.Replace('/', '.')
.Replace('\\', '.')
.Replace(".py", string.Empty)
.DeduplicateDots()
.Trim();
var createRequestImportPath = Path.Join(requestsFolderPath, $"Create{fqn.PascalizedName}Request")
.SlashesToDots()
var routersFolderPath = $"{Features.Api.FeatureName}/routers/{path}";
var routerFilePath = $"{routersFolderPath}/{entitySnakeCaseName}.py";
var routerImportPath = routersFolderPath
.Replace('/', '.')
.Replace('\\', '.')
.Replace(".py", "")
.DeduplicateDots()
.Trim();
var requestsFolderPath = $"{Features.Api.FeatureName}/requests/{path}";
var createRequestImportPath = $"{requestsFolderPath}/Create{entityClassName}Request"
.Replace('/', '.')
.Replace('\\', '.')
.DeduplicateDots()
.Underscore()
.ToLower();
var createRequestClassName = $"Create{fqn.PascalizedName}Request";
var createRequestClassName = $"Create{entityClassName}Request";
var updateRequestImportPath = Path.Join(requestsFolderPath, $"Update{fqn.PascalizedName}Request")
.SlashesToDots()
var updateRequestImportPath = $"{requestsFolderPath}/Update{entityClassName}Request"
.Replace('/', '.')
.Replace('\\', '.')
.DeduplicateDots()
.Underscore()
.ToLower();
var updateRequestClassName = $"Update{fqn.PascalizedName}Request";
var updateRequestClassName = $"Update{entityClassName}Request";
var router = string.Join("\n", Template)
.Replace("%service_import_path%", serviceImportPath)
.Replace("%entity_class_name%", fqn.PascalizedName)
.Replace("%entity_class_name%", entityClassName)
.Replace("%service_class_name%", serviceClassName)
.Replace("%create_entity_request_import_path%", createRequestImportPath)
.Replace("%create_entity_request_class_name%", createRequestClassName)
.Replace("%update_entity_request_import_path%", updateRequestImportPath)
.Replace("%update_entity_request_class_name%", updateRequestClassName);
await _context.CreateFile($"{routerFilePath}.py", router);
await _context.CreateFile(routerFilePath, router);
var main = await _context.ReadFile("main.py");
main = new MainModifier(main).Initialize()
.Import(from: routerImportPath, import: fqn.SnakeCasedName)
.IncludeRouter(prefix: entityRoutePrefix, router: fqn.SnakeCasedName)
.Import(from: routerImportPath, import: entitySnakeCaseName)
.IncludeRouter(prefix: entityRoutePrefix, router: entitySnakeCaseName)
.Rewrite();
await _context.WriteFile("main.py", main);

View File

@ -1,4 +1,5 @@
using MycroForge.CLI.Commands;
using Humanizer;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.CodeGen;
@ -64,15 +65,26 @@ public class CrudServiceGenerator
_context = context;
}
public async Task Generate(FullyQualifiedName fqn)
public async Task Generate(string path, string entity)
{
var entityImportPath = fqn.GetImportPath(root: [Features.Db.FeatureName, "entities"]);
var entitySnakeCaseName = entity.Underscore().ToLower();
var entityClassName = entity.Pascalize();
var serviceFilePath = Path.Join(Features.Api.FeatureName, "services", $"{fqn.FilePath}_service.py");
var entitiesFolderPath = $"{Features.Db.FeatureName}/entities/{path}";
var entityFilePath = $"{entitiesFolderPath}/{entitySnakeCaseName}.py";
var entityImportPath = entityFilePath
.Replace('/', '.')
.Replace('\\', '.')
.Replace(".py", string.Empty)
.DeduplicateDots()
.Trim();
var servicesFolderPath = $"{Features.Api.FeatureName}/services/{path}";
var serviceFilePath = $"{servicesFolderPath}/{entity.Underscore().ToLower()}_service.py";
var service = string.Join("\n", Template)
.Replace("%entity_import_path%", entityImportPath)
.Replace("%entity_class_name%", fqn.PascalizedName)
.Replace("%entity_class_name%", entityClassName)
;
await _context.CreateFile(serviceFilePath, service);

View File

@ -96,18 +96,14 @@ public partial class EntityLinker
var left = await LoadEntity(_left);
var right = await LoadEntity(_right);
var associationTable = string.Join('\n', AssociationTable)
var associationTable = string.Join('\n', AssociationTable);
associationTable = associationTable
.Replace("%left_entity%", left.ClassName.Underscore().ToLower())
.Replace("%right_entity%", right.ClassName.Underscore().ToLower())
.Replace("%left_table%", left.TableName)
.Replace("%right_table%", right.TableName);
var associationTablePath = Path.Join(
Features.Db.FeatureName,
"entities",
"associations",
$"{left.TableName.Singularize()}_{right.TableName.Singularize()}_mapping.py"
);
var associationTablePath =
$"{Features.Db.FeatureName}/entities/associations/{left.TableName.Singularize()}_{right.TableName.Singularize()}_mapping.py";
await _context.CreateFile(associationTablePath, associationTable);
@ -140,25 +136,21 @@ public partial class EntityLinker
var env = await _context.ReadFile($"{Features.Db.FeatureName}/env.py");
env = new DbEnvModifier(env, associationTableImportPath, associationTableImportName).Rewrite();
await _context.WriteFile($"{Features.Db.FeatureName}/env.py", env);
var main = await _context.ReadFile("main.py");
main = new MainModifier(main)
.Initialize()
.Import(associationTableImportPath, associationTableImportName)
.Rewrite();
var main = await _context.ReadFile("main.py");
main = new MainModifier(main).Initialize().Import(associationTableImportPath, associationTableImportName).Rewrite();
await _context.WriteFile("main.py", main);
}
private async Task<EntityModel> LoadEntity(string name)
{
var fqn = new FullyQualifiedName(name);
var path = Path.Join(Features.Db.FeatureName, "entities");
var path = $"{Features.Db.FeatureName}/entities";
if (fqn.HasNamespace)
path = Path.Join(path, fqn.Namespace);
if (fqn.HasPath)
path = Path.Combine(path, fqn.Path);
path = Path.Join(path, $"{fqn.SnakeCasedName}.py");
path = Path.Combine(path, $"{fqn.SnakeCasedName}.py");
var entity = new EntityModel(fqn.PascalizedName, path, await _context.ReadFile(path));
entity.Initialize();
return entity;

View File

@ -38,14 +38,12 @@ public class MainModifier
public MainModifier IncludeRouter(string prefix, string router)
{
_routerIncludeBuffer.Add($"app.include_router(prefix=\"/{prefix}\", router={router}.router)");
_routerIncludeBuffer.Add($"\napp.include_router(prefix=\"/{prefix}\", router={router}.router)\n");
return this;
}
public string Rewrite()
{
// Make sure to insert the includes before the imports, if done the other way around,
// the insertions of the includes will change the indexes of the imports.
InsertIncludes();
InsertImports();
@ -56,7 +54,7 @@ public class MainModifier
private void InsertImports()
{
if (_importsBuffer.Count == 0) return;
if (_lastImport is not null)
{
_source.InsertMultiLine(_lastImport.EndIndex, _importsBuffer.ToArray());
@ -71,17 +69,15 @@ public class MainModifier
{
if (_routerIncludeBuffer.Count == 0) return;
// Prepend an empty string to the router include buffer,
// this will ensure that the new entries are all on separate lines.
var content = _routerIncludeBuffer.Prepend(string.Empty).ToArray();
if (_lastRouterInclude is not null)
{
_source.InsertMultiLine(_lastRouterInclude.EndIndex, content);
_source.InsertMultiLine(
_lastRouterInclude.EndIndex, _routerIncludeBuffer.ToArray()
);
}
else
{
_source.InsertMultiLineAtEnd(content);
_source.InsertMultiLineAtEnd(_routerIncludeBuffer.ToArray());
}
}
}

View File

@ -1,22 +1,19 @@
using System.Text.RegularExpressions;
using Humanizer;
using MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.CodeGen;
public class RequestClassGenerator
{
private static readonly List<string> PythonTypingImports = ["Any", "Dict", "List", "Optional"];
private record Import(string Name, List<string> Types)
public record Import(string Name, List<string> Types)
{
public bool Match(string type) => Types.Any(t => type == t || type.StartsWith(t));
public string FindType(string type) => Types.First(t => type == t || type.StartsWith(t));
};
private record Field(string Name, string Type);
public record Field(string Name, string Type);
public enum Type
{
@ -43,28 +40,29 @@ public class RequestClassGenerator
_context = context;
}
public async Task Generate(FullyQualifiedName fqn, Type type)
public async Task Generate(string path, string entity, Type type)
{
var entityFilePath = Path.Join(Features.Db.FeatureName, "entities", $"{fqn.FilePath}.py");
var entitySnakeCaseName = entity.Underscore().ToLower();
var entityClassName = entity.Pascalize();
var entitiesFolderPath = $"{Features.Db.FeatureName}/entities/{path}";
var entityFilePath = $"{entitiesFolderPath}/{entitySnakeCaseName}.py";
var entitySource = await _context.ReadFile(entityFilePath);
var fieldInfo = ReadFields(entitySource);
var fields = string.Join('\n', fieldInfo.Select(x => ToFieldString(x, type)));
var requestFilePath = Path.Join(
Features.Api.FeatureName,
"requests",
fqn.Namespace,
$"{type.ToString().ToLower()}_{fqn.SnakeCasedName}_request.py"
);
var requestsFolderPath = $"{Features.Api.FeatureName}/requests/{path}";
var updateRequestFilePath =
$"{requestsFolderPath}/{type.ToString().ToLower()}_{entitySnakeCaseName}_request.py";
var service = string.Join("\n", Template)
.Replace("%imports%", GetImportString(entitySource, fieldInfo, type))
.Replace("%request_type%", type.ToString().Pascalize())
.Replace("%entity_class_name%", fqn.PascalizedName)
.Replace("%entity_class_name%", entityClassName)
.Replace("%fields%", fields)
;
await _context.CreateFile(requestFilePath, service);
await _context.CreateFile(updateRequestFilePath, service);
}
private string ToFieldString(Field field, Type type)
@ -102,12 +100,11 @@ public class RequestClassGenerator
.Replace(" ", "");
Console.WriteLine(str); // = "List,Dict,str,Any"
*/
var dissectedTypes = field.Type
.Replace("[", ",")
var dissectedTypes = field.Type.Replace("[", ",")
.Replace("]", "")
.Replace(" ", "")
.Split(',');
.Split();
foreach (var dissectedType in dissectedTypes)
{
if (imports.FirstOrDefault(i => i.Match(dissectedType)) is Import import)
@ -167,16 +164,16 @@ public class RequestClassGenerator
.Split(',')
.Select(s => s.Trim())
.ToArray();
imports.Add(new Import(name, new List<string>(types)));
imports.Add(new Import(name, [..types]));
}
if (imports.FirstOrDefault(i => i.Name == "typing") is Import typingImport)
{
typingImport.Types.AddRange(PythonTypingImports);
typingImport.Types.AddRange(["Any", "Dict", "List", "Optional"]);
}
else
{
imports.Add(new Import("typing", PythonTypingImports));
imports.Add(new("typing", ["Any", "Dict", "List", "Optional"]));
}
return imports;

View File

@ -1,17 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,30 +0,0 @@
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresPluginAttribute : Attribute
{
public void RequirePluginProject(string command)
{
var currentDirectoryInfo = new DirectoryInfo(Environment.CurrentDirectory);
var matcher = new Matcher()
.AddInclude("*.csproj")
.Execute(new DirectoryInfoWrapper(currentDirectoryInfo));
if (!matcher.HasMatches)
throw new($"Command '{command}' must be run in a command plugin project.");
var csprojFileName = $"{new DirectoryInfo(Environment.CurrentDirectory).Name}.csproj";
bool IsCsprojFile(FilePatternMatch file)
{
return Path.GetFileName(file.Path) == 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,12 +0,0 @@
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresVenvAttribute : Attribute
{
public string Path => System.IO.Path.Join(Environment.CurrentDirectory, ".venv");
public void RequireVenv(string command)
{
if (!File.Exists(System.IO.Path.Join(Environment.CurrentDirectory, Path)))
throw new($"Command '{command}' requires directory {Path}");
}
}

View File

@ -1,48 +1,28 @@
using Humanizer;
using MycroForge.CLI.Extensions;
namespace MycroForge.CLI.Commands;
public class FullyQualifiedName
{
public string Namespace { get; }
public string Path { get; }
public string PascalizedName { get; }
public string SnakeCasedName { get; }
public string FilePath =>
string.IsNullOrEmpty(Namespace.Trim())
? SnakeCasedName
: Path.Join(Namespace, SnakeCasedName);
public bool HasNamespace => Namespace.Length > 0;
public bool HasPath => Path.Length > 0;
public FullyQualifiedName(string name)
{
var path = string.Empty;
if (name.Split(':').Select(s => s.Trim()).ToArray() is { Length: 2 } fullName)
{
path = fullName[0];
name = fullName[1];
}
Namespace = path;
Path = path;
PascalizedName = name.Pascalize();
SnakeCasedName = SnakeCase(name);
SnakeCasedName = name.Underscore().ToLower();
}
public string GetImportPath(params string[] root)
{
if (root.Length == 0)
return string.Join('.', FilePath).SlashesToDots();
var importRoot = string.Join('.', root);
return string.Join('.', SnakeCase(importRoot), FilePath).SlashesToDots();
}
private static string SnakeCase(string value) => value.Underscore().ToLower();
// private static string SlashesToDots(string value) => value.Replace('\\', '.').Replace('/', '.');
}
}

View File

@ -21,11 +21,6 @@ public partial class MycroForge
description: "The database UI port"
);
private static readonly Option<ProjectConfig.DbConfig.DbuPlatformOptions> DbuPlatformOption = new(
aliases: ["--database-ui-platform", "--dbu-platform"],
description: "The docker platform for the PhpMyAdmin image"
);
private readonly ProjectContext _context;
private readonly OptionsContainer _optionsContainer;
private readonly List<IFeature> _features;
@ -39,22 +34,12 @@ public partial class MycroForge
AddOption(DbhPortOption);
AddOption(DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, DbhPortOption, DbuPortOption, DbuPlatformOption);
this.SetHandler(ExecuteAsync, DbhPortOption, DbuPortOption);
}
private async Task ExecuteAsync(
int dbhPort,
int dbuPort,
ProjectConfig.DbConfig.DbuPlatformOptions dbuPlatform
)
private async Task ExecuteAsync(int dbhPort, int dbuPort)
{
_optionsContainer.Set(new Features.Db.Options
{
DbhPort = dbhPort,
DbuPort = dbuPort,
DbuPlatform = dbuPlatform
});
_optionsContainer.Set(new Features.Db.Options { DbhPort = dbhPort, DbuPort = dbuPort });
var feature = _features.First(f => f.Name == Features.Db.FeatureName);
await feature.ExecuteAsync(_context);
}

View File

@ -29,10 +29,11 @@ public partial class MycroForge
private async Task ExecuteAsync(string entity)
{
var fqn = new FullyQualifiedName(entity);
await new CrudServiceGenerator(_context).Generate(fqn);
await new RequestClassGenerator(_context).Generate(fqn, RequestClassGenerator.Type.Create);
await new RequestClassGenerator(_context).Generate(fqn, RequestClassGenerator.Type.Update);
await new CrudRouterGenerator(_context).Generate(fqn);
await new CrudServiceGenerator(_context).Generate(fqn.Path, fqn.PascalizedName);
await new RequestClassGenerator(_context).Generate(fqn.Path, fqn.PascalizedName, RequestClassGenerator.Type.Create);
await new RequestClassGenerator(_context).Generate(fqn.Path, fqn.PascalizedName, RequestClassGenerator.Type.Update);
await new CrudRouterGenerator(_context).Generate(fqn.Path, fqn.PascalizedName);
}
}
}

View File

@ -2,6 +2,7 @@ using System.CommandLine;
using Humanizer;
using MycroForge.CLI.CodeGen;
using MycroForge.Core.Contract;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
@ -43,17 +44,19 @@ public partial class MycroForge
private async Task ExecuteAsync(string name)
{
var fqn = new FullyQualifiedName(name);
var routersFolderPath = Path.Join(Features.Api.FeatureName, "routers");
var folderPath = $"{Features.Api.FeatureName}/routers";
if (fqn.HasNamespace)
routersFolderPath = Path.Join(routersFolderPath, fqn.Namespace);
_context.AssertDirectoryExists(folderPath);
if (fqn.HasPath)
folderPath = Path.Combine(folderPath, fqn.Path);
var fileName = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Join(routersFolderPath, fileName);
var filePath = Path.Combine(folderPath, fileName);
await _context.CreateFile(filePath, Template);
var moduleImportPath = routersFolderPath
var moduleImportPath = folderPath
.Replace('\\', '.')
.Replace('/', '.');

View File

@ -1,19 +1,17 @@
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>> subCommands) :
base("api", "API related commands")
{
foreach (var command in commands)
AddCommand((command as Command)!);
foreach (var subCommandOf in subCommands)
AddCommand((subCommandOf as Command)!);
}
}
}

View File

@ -1,8 +1,8 @@
using System.CommandLine;
using System.Text.RegularExpressions;
using Humanizer;
using MycroForge.CLI.CodeGen;
using MycroForge.Core.Contract;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
@ -15,65 +15,11 @@ public partial class MycroForge
{
public class Entity : Command, ISubCommandOf<Generate>
{
#region Hidden region
private static string[] SqlAlchemyTypes =
[
"BigInteger",
"Boolean",
"Date",
"DateTime",
"Enum",
"Double",
"Float",
"Integer",
"Interval",
"LargeBinary",
"MatchType",
"Numeric",
"PickleType",
"SchemaType",
"SmallInteger",
"String",
"Text",
"Time",
"Unicode",
"UnicodeText",
"Uuid",
"ARRAY",
"BIGINT",
"BINARY",
"BLOB",
"BOOLEAN",
"CHAR",
"CLOB",
"DATE",
"DATETIME",
"DECIMAL",
"DOUBLE",
"DOUBLE_PRECISION",
"FLOAT",
"INT",
"JSON",
"INTEGER",
"NCHAR",
"NVARCHAR",
"NUMERIC",
"REAL",
"SMALLINT",
"TEXT",
"TIME",
"TIMESTAMP",
"UUID",
"VARBINARY",
"VARCHAR"
];
private static readonly Regex SqlAlchemyTypeRegex = new(@".*\(.*\)");
private record ColumnDefinition(string Name, string NativeType, string OrmType);
private static readonly string[] Template =
[
"from sqlalchemy import %sqlalchemy_imports%",
"from sqlalchemy import %type_imports%",
"from sqlalchemy.orm import Mapped, mapped_column",
$"from {Features.Db.FeatureName}.entities.entity_base import EntityBase",
"",
@ -110,10 +56,6 @@ public partial class MycroForge
"\tfirst_name:str:String(255)",
])) { AllowMultipleArgumentsPerToken = true };
#endregion
private record ColumnDefinition(string Name, string NativeType, string SqlAlchemyType);
private readonly ProjectContext _context;
public Entity(ProjectContext context) : base("entity", "Generate and database entity")
@ -128,27 +70,25 @@ public partial class MycroForge
private async Task ExecuteAsync(string name, IEnumerable<string> columns)
{
var fqn = new FullyQualifiedName(name);
var folderPath = Path.Join(Features.Db.FeatureName, "entities");
var folderPath = $"{Features.Db.FeatureName}/entities";
if (fqn.HasNamespace)
folderPath = Path.Join(folderPath, fqn.Namespace);
var sqlAlchemyColumn = GetColumnDefinitions(columns.ToArray());
var distinctSqlAlchemyColumnTypes = sqlAlchemyColumn
.Select(c => c.SqlAlchemyType.Split('(').First())
.Distinct();
_context.AssertDirectoryExists(Features.Db.FeatureName);
var sqlAlchemyImport = string.Join(", ", distinctSqlAlchemyColumnTypes);
var columnDefinitions = string.Join("\n ", sqlAlchemyColumn.Select(ColumnToString));
if (fqn.HasPath)
folderPath = Path.Combine(folderPath, fqn.Path);
var _columns = GetColumnDefinitions(columns.ToArray());
var typeImports = string.Join(", ", _columns.Select(c => c.OrmType.Split('(').First()).Distinct());
var columnDefinitions = string.Join("\n\t", _columns.Select(ColumnToString));
var code = string.Join('\n', Template);
code = code.Replace("%sqlalchemy_imports%", sqlAlchemyImport);
code = code.Replace("%type_imports%", typeImports);
code = code.Replace("%class_name%", fqn.PascalizedName);
code = code.Replace("%table_name%", fqn.SnakeCasedName.Pluralize());
code = code.Replace("%column_definitions%", columnDefinitions);
var fileName = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Join(folderPath, fileName);
var filePath = Path.Combine(folderPath, fileName);
await _context.CreateFile(filePath, code);
var importPathParts = new[] { folderPath, fileName.Replace(".py", "") }
@ -169,58 +109,25 @@ public partial class MycroForge
await _context.WriteFile("main.py", main);
}
private List<ColumnDefinition> GetColumnDefinitions(string[] columns)
private List<ColumnDefinition> GetColumnDefinitions(string[] fields)
{
var definitions = new List<ColumnDefinition>();
foreach (var column in columns)
foreach (var field in fields)
{
if (column.Split(':') is not { Length: 3 } definition)
throw new Exception($"Column definition {column} is invalid.");
if (field.Split(':') is not { Length: 3 } definition)
throw new Exception($"Field definition {field} is invalid.");
definitions.Add(new ColumnDefinition(definition[0], definition[1], definition[2]));
}
ValidateSqlAlchemyColumnTypes(definitions);
return definitions;
}
private static void ValidateSqlAlchemyColumnTypes(List<ColumnDefinition> definitions)
private static string ColumnToString(ColumnDefinition definition)
{
foreach (var column in definitions)
{
if (!SqlAlchemyTypeRegex.IsMatch(column.SqlAlchemyType))
{
var message = new[]
{
$"SQLAlchemy column definition {column.SqlAlchemyType} was not properly defined.",
"Add parentheses and specify parameters if required, an example is provided below.",
" String(255)",
"",
"Available options are:",
string.Join(Environment.NewLine, SqlAlchemyTypes.Select(type => $" - {type}"))
};
throw new(string.Join(Environment.NewLine, message));
}
var type = column.SqlAlchemyType.Split('(').First();
if (!SqlAlchemyTypes.Contains(type))
{
var message = string.Join(Environment.NewLine, [
$"SQLAlchemy column type '{column.SqlAlchemyType}' is not valid, available options are:",
string.Join(Environment.NewLine, SqlAlchemyTypes.Select(type => $" - {type}"))
]);
throw new(message);
}
}
return $"{definition.Name}: Mapped[{definition.NativeType}] = mapped_column({definition.OrmType})";
}
private static string ColumnToString(ColumnDefinition definition) =>
$"{definition.Name}: Mapped[{definition.NativeType}] = mapped_column({definition.SqlAlchemyType})";
}
}
}

View File

@ -27,6 +27,8 @@ public partial class MycroForge
private async Task ExecuteAsync(string name)
{
_context.AssertDirectoryExists($"{Features.Db.FeatureName}/versions");
await _context.Bash(
"source .venv/bin/activate",
$"alembic revision --autogenerate -m \"{name}\" --rev-id $(date -u +\"%Y%m%d%H%M%S\")"

View File

@ -23,8 +23,7 @@ public partial class MycroForge
{
var config = await _context.LoadConfig();
var env = $"DBH_PORT={config.Db.DbhPort} DBU_PORT={config.Db.DbuPort}";
var command = $"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml up -d";
await _context.Bash(command);
await _context.Bash($"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml up -d");
}
}
}

View File

@ -21,10 +21,9 @@ public partial class MycroForge
private async Task ExecuteAsync()
{
await _context.Bash(
// Set the log level to ERROR to prevent warnings concerning environment variables not being set.
$"docker --log-level ERROR compose -f {Features.Db.FeatureName}.docker-compose.yml down"
);
var config = await _context.LoadConfig();
var env = $"DB_PORT={config.Db.DbhPort} PMA_PORT={config.Db.DbuPort}";
await _context.Bash($"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml down");
}
}
}

View File

@ -1,12 +1,10 @@
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,5 +1,7 @@
using System.CommandLine;
using Humanizer;
using MycroForge.Core.Contract;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
@ -58,10 +60,10 @@ public partial class MycroForge
var fqn = new FullyQualifiedName(name);
var folderPath = string.Empty;
if (fqn.HasNamespace)
folderPath = Path.Join(folderPath, fqn.Namespace);
if (fqn.HasPath)
folderPath = Path.Combine(folderPath, fqn.Path);
var filePath = Path.Join(folderPath, $"{fqn.SnakeCasedName}.py");
var filePath = Path.Combine(folderPath, $"{fqn.SnakeCasedName}.py");
var template = withSession ? WithSessionTemplate : DefaultTemplate;
var code = string.Join('\n', template)
.Replace("%class_name%", fqn.PascalizedName);

View File

@ -1,5 +1,4 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@ -7,12 +6,11 @@ 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

@ -15,7 +15,6 @@ public partial class MycroForge
ApiPort = ctx.ParseResult.GetValueForOption(ApiPortOption),
DbhPort = ctx.ParseResult.GetValueForOption(DbhPortOption),
DbuPort = ctx.ParseResult.GetValueForOption(DbuPortOption),
DbuPlatform = ctx.ParseResult.GetValueForOption(DbuPlatformOption),
};
}
}

View File

@ -1,6 +1,4 @@
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
@ -13,7 +11,6 @@ public partial class MycroForge
public int? ApiPort { get; set; }
public int? DbhPort { get; set; }
public int? DbuPort { get; set; }
public ProjectConfig.DbConfig.DbuPlatformOptions DbuPlatform { get; set; }
public Features.Api.Options ApiOptions => new()
{
@ -23,8 +20,7 @@ public partial class MycroForge
public Features.Db.Options DbOptions => new()
{
DbhPort = DbhPort <= 0 ? 5050 : DbhPort,
DbuPort = DbuPort <= 0 ? 5051 : DbhPort,
DbuPlatform = DbuPlatform
DbuPort = DbuPort <= 0 ? 5051 : DbhPort
};
}
}

View File

@ -39,11 +39,6 @@ public partial class MycroForge
description: "The database UI port"
);
private static readonly Option<ProjectConfig.DbConfig.DbuPlatformOptions> DbuPlatformOption = new(
aliases: ["--database-ui-platform", "--dbu-platform"],
description: "The docker platform for the PhpMyAdmin image"
);
private readonly ProjectContext _context;
private readonly List<IFeature> _features;
private readonly OptionsContainer _optionsContainer;
@ -60,7 +55,6 @@ public partial class MycroForge
AddOption(ApiPortOption);
AddOption(DbhPortOption);
AddOption(DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, new Binder());
}
@ -81,7 +75,7 @@ public partial class MycroForge
await _context.CreateFile("main.py");
// Create the venv
await _context.Bash($"python3 -m venv {Path.Join(projectRoot, ".venv")}");
await _context.Bash($"python3 -m venv {Path.Combine(projectRoot, ".venv")}");
// Pass feature arguments to the ArgsContainer
_optionsContainer.Set(options.ApiOptions);
@ -106,7 +100,7 @@ public partial class MycroForge
private async Task<string> CreateDirectory(string name)
{
var directory = Path.Join(Directory.GetCurrentDirectory(), name);
var directory = Path.Combine(Directory.GetCurrentDirectory(), name);
if (Directory.Exists(directory))
throw new Exception($"Directory {directory} already exists.");

View File

@ -1,5 +1,4 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@ -7,7 +6,6 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresVenv]
public class Install : Command, ISubCommandOf<MycroForge>
{
private static readonly Argument<IEnumerable<string>> PackagesArgument =
@ -30,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

@ -10,29 +10,21 @@ public partial class MycroForge
{
public class Init : Command, ISubCommandOf<Plugin>
{
private static readonly Argument<string> NamespaceArgument =
new(name: "namespace", description: "The namespace of your project");
private static readonly Argument<string> ClassArgument =
new(name: "class", description: "The class name of the generated command");
private static readonly Argument<string> CommandArgument =
new(name: "command", description: "The command name that will be added to 'm4g'");
private static readonly Argument<string> NameArgument =
new(name: "name", description: "The name of your project");
private readonly ProjectContext _context;
public Init(ProjectContext context) : base("init", "Initialize a basic plugin project")
{
_context = context;
AddArgument(NamespaceArgument);
AddArgument(ClassArgument);
AddArgument(CommandArgument);
this.SetHandler(ExecuteAsync, NamespaceArgument, ClassArgument, CommandArgument);
AddArgument(NameArgument);
this.SetHandler(ExecuteAsync, NameArgument);
}
private async Task ExecuteAsync(string @namespace, string @class, string command)
private async Task ExecuteAsync(string name)
{
await _context.Bash($"dotnet new m4gp -n {@namespace} --class {@class} --command {command}");
await _context.Bash($"dotnet new m4gp -n {name}");
}
}
}

View File

@ -2,7 +2,6 @@
using Humanizer;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@ -12,7 +11,6 @@ public partial class MycroForge
{
public partial class Plugin
{
[RequiresPlugin]
public class Install : Command, ISubCommandOf<Plugin>
{
public enum TargetPlatform
@ -44,13 +42,9 @@ public partial class MycroForge
var assemblyName = GetAssemblyName();
var pluginInstallPath = Path.Join(Plugins.RootDirectory, assemblyName);
var platform = target.ToString().Dasherize();
var exitCode = await _context.Bash(
$"dotnet publish -c Release -r {platform} --output {pluginInstallPath}"
);
await _context.Bash($"dotnet publish -c Release -r {platform} --output {pluginInstallPath}");
Console.WriteLine($"Successfully installed plugin {assemblyName}");
Console.WriteLine(exitCode == 0
? $"Successfully installed plugin {assemblyName}"
: $"Could not install {assemblyName}, process exited with code {exitCode}.");
}
private string GetAssemblyName()

View File

@ -1,5 +1,4 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@ -7,7 +6,6 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresVenv]
public class Uninstall : Command, ISubCommandOf<MycroForge>
{
private static readonly Argument<IEnumerable<string>> PackagesArgument =
@ -29,17 +27,9 @@ 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(' ', packs)}",
$"pip uninstall{(yes ? " --yes " : " ")}{string.Join(' ', packages)}",
"pip freeze > requirements.txt"
);
}

View File

@ -1,96 +0,0 @@
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);
else if (_command.GetRequiresVenvAttribute() is RequiresVenvAttribute requiresVenvAttribute)
requiresVenvAttribute.RequireVenv(commandText);
}
await next(context);
});
return builder;
}
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;
}
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 RequiresVenvAttribute? GetRequiresVenvAttribute(this Command command) =>
command.GetType().GetCustomAttribute<RequiresVenvAttribute>();
}

View File

@ -8,7 +8,7 @@ namespace MycroForge.CLI.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection RegisterDefaultCommands(this IServiceCollection services)
public static IServiceCollection RegisterCommandDefaults(this IServiceCollection services)
{
// Register ProjectContext, OptionsContainer & features
services.AddScoped<ProjectContext>();

View File

@ -2,8 +2,11 @@
public static class StringExtensions
{
public static string SlashesToDots(this string path) =>
path.Replace('/', '.')
.Replace('\\', '.')
.Trim();
public static string DeduplicateDots(this string path)
{
while (path.Contains(".."))
path = path.Replace("..", ".");
return path.Trim('.');
}
}

View File

@ -52,7 +52,7 @@ public sealed partial class Api : IFeature
await context.Bash(
"source .venv/bin/activate",
"python3 -m pip install fastapi uvicorn",
"python3 -m pip install fastapi",
"python3 -m pip freeze > requirements.txt"
);

View File

@ -1,15 +1,10 @@
using MycroForge.Core;
namespace MycroForge.CLI.Features;
namespace MycroForge.CLI.Features;
public sealed partial class Db
{
public class Options
{
public int? DbhPort { get; set; }
public int? DbuPort { get; set; }
public ProjectConfig.DbConfig.DbuPlatformOptions DbuPlatform { get; set; }
}
}

View File

@ -37,6 +37,7 @@ public sealed partial class Db : IFeature
private static readonly string[] DockerCompose =
[
"version: '3.8'",
"# Access the database UI at http://localhost:${DBU_PORT}.",
"# Login: username = root & password = password",
"",
@ -57,7 +58,7 @@ public sealed partial class Db : IFeature
" - '%app_name%_mariadb:/var/lib/mysql'",
"",
" %app_name%_phpmyadmin:",
" image: %dbu_platform%/phpmyadmin",
" image: phpmyadmin/phpmyadmin",
" container_name: %app_name%_phpmyadmin",
" ports:",
" - '${DBU_PORT}:80'",
@ -86,16 +87,15 @@ public sealed partial class Db : IFeature
{
_optionsContainer = optionsContainer;
}
public async Task ExecuteAsync(ProjectContext context)
{
var options = _optionsContainer.Get<Options>();
var config = await context.LoadConfig(create: true);
config.Db = new()
{
DbhPort = options.DbhPort ?? 5050,
DbuPort = options.DbuPort ?? 5051,
DbuPlatform = options.DbuPlatform
DbhPort = options.DbhPort ?? 5050,
DbuPort = options.DbuPort ?? 5051
};
await context.SaveConfig(config);
@ -123,10 +123,7 @@ public sealed partial class Db : IFeature
await context.CreateFile($"{FeatureName}/entities/entity_base.py", EntityBase);
var dockerCompose = string.Join('\n', DockerCompose)
.Replace("%app_name%", appName)
.Replace("%dbu_platform%", options.DbuPlatform.ToString())
;
var dockerCompose = string.Join('\n', DockerCompose).Replace("%app_name%", appName);
await context.CreateFile($"{FeatureName}.docker-compose.yml", dockerCompose);
}

View File

@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>0.0.1</Version>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -1,18 +1,25 @@
using MycroForge.CLI.Extensions;
using System.CommandLine;
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
.RegisterDefaultCommands()
.RegisterCommandDefaults()
.RegisterCommandPlugins()
;
})
.Build();
var command = host.Services.GetRequiredService<RootCommand>();
await command.ExecuteAsync(args.Length == 0 ? ["--help"] : args);
try
{
await host.Services.GetRequiredService<MycroForge.CLI.Commands.MycroForge>()
.InvokeAsync(args.Length == 0 ? ["--help"] : args);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}

View File

@ -0,0 +1,11 @@
#!/usr/bin/bash
TARGET=$1
if [ -z "$TARGET" ]; then
echo "The target platform was not provided."
exit 1
fi
dotnet publish --self-contained -r "$TARGET"
zip -vr "releases/m4g-$TARGET.zip" "bin/Release/net8.0/$TARGET/"

View File

@ -0,0 +1,7 @@
#!/usr/bin/bash
./scripts/build-executable.sh linux-x64
./scripts/build-executable.sh linux-arm
./scripts/build-executable.sh linux-arm64
./scripts/build-executable.sh osx-x64
./scripts/build-executable.sh osx-arm64

View File

@ -0,0 +1,21 @@
#!/usr/bin/bash
ZIP=$1
if [ -z "$ZIP" ]; then
echo "The zip file was not provided."
exit 1
fi
TARGET=${ZIP//"m4g-"/}
TARGET=${TARGET//".zip"/}
DIR="/tmp/m4g"
rm -rf "$DIR"
unzip "$ZIP" -d "$DIR"
sudo rm -rf /usr/share/m4g
sudo cp -r "$DIR/bin/Release/net8.0/$TARGET" /usr/share/m4g
sudo unlink /usr/local/bin/m4g 2> /dev/null
sudo ln -s /usr/share/m4g/MycroForge.CLI /usr/local/bin/m4g

View File

@ -0,0 +1,19 @@
#!/usr/bin/bash
ZIP=$1
if [ -z "$ZIP" ]; then
echo "The zip file was not provided."
exit 1
fi
TARGET=${ZIP//"m4g-"/}
TARGET=${TARGET//".zip"/}
DIR="/tmp/m4g"
rm -rf "$DIR"
unzip "$ZIP" -d "$DIR"
sudo rm -rf /usr/local/bin/m4g
sudo cp -r "$DIR/bin/Release/net8.0/$TARGET" /usr/local/bin/m4g
mv /usr/share/m4g/MycroForge.CLI /usr/share/m4g/m4g

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/bash
dotnet pack -v d

View File

@ -0,0 +1,11 @@
#!/usr/bin/bash
# Make sure to run this script from the MycroForge.CLI directory and prefixed with sudo!
# Example:
# sudo ./scripts/publish-linux.sh
dotnet publish --self-contained -r linux-x64
sudo rm -rf /usr/share/m4g
sudo cp -r bin/Release/net8.0/linux-x64 /usr/share/m4g
sudo unlink /usr/local/bin/m4g
sudo ln -s /usr/share/m4g/MycroForge.CLI /usr/local/bin/m4g

View File

@ -1,7 +1,5 @@
#!/usr/bin/bash
#!/usr/bin/bash
dotnet pack -v d
VERSION="1.0.0"
dotnet nuget push nupkg/MycroForge.CLI.$VERSION.nupkg --source devdisciples
dotnet nuget push nupkg/MycroForge.CLI.1.0.0.nupkg --source devdisciples

View File

@ -48,7 +48,7 @@ public class Source
public Source InsertMultiLineAtEnd(params string[] text)
{
_text += string.Join('\n', text);
_text += (string.Join('\n', text));
return this;
}

View File

@ -1,17 +0,0 @@
using System.Text.Json;
namespace MycroForge.Core;
public static class DefaultJsonSerializerOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static readonly JsonSerializerOptions CamelCasePrettyPrint = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
}

View File

@ -4,12 +4,15 @@ namespace MycroForge.Core.Extensions;
public static class ObjectStreamExtensions
{
public static async Task<string> SerializeAsync(this object @object, JsonSerializerOptions? options = null)
public static async Task<string> SerializeAsync(
this object @object,
JsonSerializerOptions? jsonSerializerOptions = null
)
{
using var stream = new MemoryStream();
using var reader = new StreamReader(stream);
options ??= DefaultJsonSerializerOptions.Default;
var options = jsonSerializerOptions ?? Serialization.DefaultJsonSerializerOptions.Default;
await JsonSerializer.SerializeAsync(stream, @object, options);
stream.Position = 0;
return await reader.ReadToEndAsync();

View File

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<PackageId>MycroForge.Core</PackageId>
<Description>The MycroForge core package</Description>
<Version>0.0.1</Version>
<Version>1.0.0</Version>
<Authors>Donné Napo</Authors>
<Company>Dev Disciples</Company>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>

View File

@ -1,16 +0,0 @@
namespace MycroForge.Core;
public partial class ProjectConfig
{
public partial class DbConfig
{
public enum DbuPlatformOptions
{
amd64,
arm32v5,
arm32v6,
arm32v7,
arm64v8
}
}
}

View File

@ -1,16 +1,10 @@
using System.Text.Json.Serialization;
namespace MycroForge.Core;
namespace MycroForge.Core;
public partial class ProjectConfig
{
public partial class DbConfig
public class DbConfig
{
public int DbhPort { get; set; }
public int DbuPort { get; set; }
[JsonIgnore]
public DbuPlatformOptions DbuPlatform { get; set; }
}
}

View File

@ -9,7 +9,7 @@ public class ProjectContext
{
public string RootDirectory { get; private set; } = Environment.CurrentDirectory;
public string AppName => Path.GetFileNameWithoutExtension(RootDirectory).Underscore().ToLower();
private string ConfigPath => Path.Join(RootDirectory, "m4g.json");
private string ConfigPath => Path.Combine(RootDirectory, "m4g.json");
public async Task<ProjectConfig> LoadConfig(bool create = false)
@ -21,8 +21,8 @@ public class ProjectContext
}
var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(
File.OpenRead(ConfigPath),
DefaultJsonSerializerOptions.CamelCasePrettyPrint
File.OpenRead(ConfigPath),
Serialization.DefaultJsonSerializerOptions.CamelCasePrettyPrint
);
if (config is null)
@ -37,9 +37,21 @@ public class ProjectContext
RootDirectory = path;
}
public void AssertDirectoryExists(string path)
{
var fullPath = Path.Combine(RootDirectory, path);
if (!Directory.Exists(fullPath))
{
throw new(string.Join('\n',
$"{fullPath} does not exist, make sure you're in the correct directory."
));
}
}
public async Task CreateFile(string path, params string[] content)
{
var fullPath = Path.Join(RootDirectory, path);
var fullPath = Path.Combine(RootDirectory, path);
var fileInfo = new FileInfo(fullPath);
if (fileInfo.Exists) return;
@ -47,12 +59,11 @@ 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)
{
var fullPath = Path.Join(RootDirectory, path);
var fullPath = Path.Combine(RootDirectory, path);
var fileInfo = new FileInfo(fullPath);
if (!fileInfo.Exists)
@ -63,14 +74,13 @@ public class ProjectContext
public async Task WriteFile(string path, params string[] content)
{
var fullPath = Path.Join(RootDirectory, path);
var fullPath = Path.Combine(RootDirectory, path);
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<int> Bash(params string[] script)
public async Task Bash(params string[] script)
{
var info = new ProcessStartInfo
{
@ -104,21 +114,21 @@ public class ProjectContext
process.BeginErrorReadLine();
await using var input = process.StandardInput;
// Concat with '&&' operator to make sure that script does not continue on failure.
await input.WriteAsync(string.Join(" && ", script));
foreach (var line in script)
await input.WriteLineAsync(line);
await input.FlushAsync();
input.Close();
await process.WaitForExitAsync();
Environment.ExitCode = process.ExitCode;
return process.ExitCode;
if (process.ExitCode != 0)
Console.WriteLine($"Process finished with exit code {process.ExitCode}.");
}
public async Task SaveConfig(ProjectConfig config)
{
var json = await config.SerializeAsync(DefaultJsonSerializerOptions.CamelCasePrettyPrint);
var json = await config.SerializeAsync(Serialization.DefaultJsonSerializerOptions.CamelCasePrettyPrint);
await File.WriteAllTextAsync(ConfigPath, json);
}
}

View File

@ -0,0 +1,20 @@
using System.Text.Json;
namespace MycroForge.Core;
public static class Serialization
{
public static class DefaultJsonSerializerOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static readonly JsonSerializerOptions CamelCasePrettyPrint = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
}
}

View File

@ -1,5 +0,0 @@
#!/bin/bash
VERSION=$(grep '<Version>' < MycroForge.Core.csproj | sed 's/.*<Version>\(.*\)<\/Version>/\1/' | tr -d '[:space:]')
dotnet build -r Releasedo
dotnet nuget push --source devdisciples "bin/Release/MycroForge.Core.$VERSION.nupkg"

View File

@ -4,7 +4,7 @@
<!-- The package metadata. Fill in the properties marked as TODO below -->
<!-- Follow the instructions on https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices -->
<PackageId>MycroForge.PluginTemplate.Package</PackageId>
<PackageVersion>0.0.1</PackageVersion>
<PackageVersion>1.0</PackageVersion>
<Title>A template for generating MycroForge plugins</Title>
<Authors>Donné Napo</Authors>
<Description>Template to use when creating a plugin for the MycroForge CLI.</Description>

View File

@ -8,7 +8,7 @@ https://learn.microsoft.com/en-us/dotnet/core/tutorials/cli-templates-create-tem
### Build the package
`dotnet pack`
### Push to devdisciples nuget
### Push to local nuget
`dotnet nuget push bin/Release/MycroForge.PluginTemplate.Package.1.0.0.nupkg --source devdisciples`
### Install template package from local nuget

View File

@ -1,4 +1,4 @@
#!/bin/env bash
#!/usr/bin/env bash
rm -rf templates/MycroForge.PluginTemplate
cp -R ../MycroForge.PluginTemplate templates/MycroForge.PluginTemplate

View File

@ -1,12 +0,0 @@
#!/bin/env bash
rm -rf templates/MycroForge.PluginTemplate
cp -R ../MycroForge.PluginTemplate templates/MycroForge.PluginTemplate
dotnet pack
# Set the path to the package
VERSION=$(grep '<PackageVersion>' < MycroForge.PluginTemplate.Package.csproj | sed 's/.*<PackageVersion>\(.*\)<\/PackageVersion>/\1/' | tr -d '[:space:]')
PACKAGE="bin/Release/MycroForge.PluginTemplate.Package.$VERSION.nupkg"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

View File

@ -17,16 +17,5 @@
"language": "C#",
"type": "project"
},
"preferNameDirectory": true,
"symbols": {
"class": {
"type": "parameter",
"replaces": "ExampleCommand",
"fileRename": "ExampleCommand"
},
"command": {
"type": "parameter",
"replaces": "example"
}
}
"preferNameDirectory": true
}

View File

@ -5,7 +5,7 @@ using RootCommand = MycroForge.Core.RootCommand;
namespace MycroForge.PluginTemplate;
public class ExampleCommand : Command, ISubCommandOf<RootCommand>
public class HelloWorldCommand : Command, ISubCommandOf<RootCommand>
{
private readonly Argument<string> NameArgument =
new(name: "name", description: "The name of the person to greet");
@ -15,8 +15,8 @@ public class ExampleCommand : Command, ISubCommandOf<RootCommand>
private readonly ProjectContext _context;
public ExampleCommand(ProjectContext context) :
base("example", "A basic command plugin generated by the 'm4g plugin init' command")
public HelloWorldCommand(ProjectContext context) :
base("hello", "An example command generated by dotnet new using the m4gp template")
{
_context = context;
AddArgument(NameArgument);
@ -28,9 +28,9 @@ public class ExampleCommand : Command, ISubCommandOf<RootCommand>
{
name = allCaps ? name.ToUpper() : name;
await _context.CreateFile("example.txt",
await _context.CreateFile("hello_world.txt",
$"Hello {name}!",
"This file was generated by the 'm4g example' command!"
"This file was generated by your custom command!"
);
}
}

View File

@ -4,12 +4,12 @@ using RootCommand = MycroForge.Core.RootCommand;
namespace MycroForge.PluginTemplate;
public class ExampleCommandPlugin : ICommandPlugin
public class HelloWorldCommandPlugin : ICommandPlugin
{
public string Name => "MycroForge.PluginTemplate";
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<ISubCommandOf<RootCommand>, ExampleCommand>();
services.AddScoped<ISubCommandOf<RootCommand>, HelloWorldCommand>();
}
}

View File

@ -11,7 +11,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MycroForge.Core" Version="0.0.1" />
<PackageReference Include="MycroForge.Core" Version="1.0.0" />
</ItemGroup>
</Project>

View File

@ -8,8 +8,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.PluginTemplate",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.PluginTemplate.Package", "MycroForge.PluginTemplate.Package\MycroForge.PluginTemplate.Package.csproj", "{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.CLI.Tests", "MycroForge.CLI.Tests\MycroForge.CLI.Tests.csproj", "{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -32,9 +30,5 @@ Global
{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}.Release|Any CPU.Build.0 = Release|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -1,3 +1,29 @@
### Documentation
### Dependencies
Go to https://m4g.devdisciples.com for the docs.
- git
- Docker
- bash (/bin/bash)
- Python 3.10.2 (/usr/bin/python3)
- python3-pip
- python3-venv
#### Note
The MycroForge CLI assumes a linux compatible environment, so on Windows you'll have to use WSL.
`Ubuntu-22.04` is the recommended WSL version to use.
### TODO
- Figure out why BashException cannot be caught, can it be due to the differences in scoping?
Because the `Bash` class is static and the services calling `Bash.ExecuteAsync` are in the container.
Maybe this in combination with the async nature of the whole thing?
### Install
Run the install script in the same directory as the downloaded zip. See the example below for linux-x64.
`sudo ./install.sh m4g-<platform>.zip <platform>`
### Add DevDisciples NuGet source
```bash
dotnet nuget add source --name devdisciples --username username --password password https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json --store-password-in-clear-text
```

20
docs/.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

41
docs/README.md Normal file
View File

@ -0,0 +1,41 @@
# Website
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
Using SSH:
```
$ USE_SSH=true yarn deploy
```
Not using SSH:
```
$ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

3
docs/babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
};

View File

@ -0,0 +1,24 @@
# Commands
```
Description:
The MycroForge CLI tool.
Usage:
m4g [command] [options]
Options:
--version Show version information
-?, -h, --help Show help and usage information
Commands:
init <name> Initialize a new project
i, install <packages> Install packages and update the requirements.txt
u, uninstall <packages> Uninstall packages and update the requirements.txt
hydrate Initialize venv and install dependencies from requirements.txt
add Add features to the project
g, generate Generate common items
api API related commands
db Database related commands
p, plugin Plugin related commands
```

View File

@ -0,0 +1,22 @@
---
sidebar_position: 1
---
# m4g add
```
Description:
Add features to the project
Usage:
m4g add [command] [options]
Options:
-?, -h, --help Show help and usage information
Commands:
api Add FastAPI to the project
db Add SQLAlchemy & Alembic to the project
git Add git to the project
gitignore Add a default .gitignore file to the project
```

View File

@ -0,0 +1,17 @@
---
sidebar_position: 1
---
# m4g add api
```
Description:
Add FastAPI to the project
Usage:
m4g add api [options]
Options:
--api-port <api-port> The API port
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,18 @@
---
sidebar_position: 1
---
# m4g add db
```
Description:
Add SQLAlchemy & Alembic to the project
Usage:
m4g add db [options]
Options:
--database-host-port, --dbh-port <database-host-port> The database host port
--database-ui-port, --dbu-port <database-ui-port> The database UI port
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,16 @@
---
sidebar_position: 1
---
# m4g add git
```
Description:
Add git to the project
Usage:
m4g add git [options]
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,16 @@
---
sidebar_position: 1
---
# m4g add gitignore
```
Description:
Add a default .gitignore file to the project
Usage:
m4g add gitignore [options]
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,20 @@
---
sidebar_position: 1
---
# m4g api
```
Description:
API related commands
Usage:
m4g api [command] [options]
Options:
-?, -h, --help Show help and usage information
Commands:
run Run your app
g, generate Generate an API item
```

View File

@ -0,0 +1,20 @@
---
sidebar_position: 1
---
# m4g api generate
```
Description:
Generate an API item
Usage:
m4g api generate [command] [options]
Options:
-?, -h, --help Show help and usage information
Commands:
r, router <name> Generate an api router
crud <entity> Generated CRUD functionality for an entity
```

View File

@ -0,0 +1,19 @@
---
sidebar_position: 1
---
# m4g api generate crud
```
Description:
Generated CRUD functionality for an entity
Usage:
m4g api generate crud <entity> [options]
Arguments:
<entity> The entity to target
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,19 @@
---
sidebar_position: 1
---
# m4g api generate router
```
Description:
Generate an api router
Usage:
m4g api generate router <name> [options]
Arguments:
<name> The name of the api router
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,16 @@
---
sidebar_position: 1
---
# m4g api run
```
Description:
Run your app
Usage:
m4g api run [options]
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,24 @@
---
sidebar_position: 1
---
# m4g db
```
Description:
Database related commands
Usage:
m4g db [command] [options]
Options:
-?, -h, --help Show help and usage information
Commands:
run Runs the services defined in db.docker-compose.yml
stop Stops db.docker-compose.yml
migrate Apply migrations to the database
rollback Rollback the last migration
g, generate Generate a database item
link Define relationships between entities
```

View File

@ -0,0 +1,20 @@
---
sidebar_position: 1
---
# m4g db generate
```
Description:
Generate a database item
Usage:
m4g db generate [command] [options]
Options:
-?, -h, --help Show help and usage information
Commands:
e, entity <name> Generate and database entity
m, migration <name> Generate a migration
```

View File

@ -0,0 +1,35 @@
---
sidebar_position: 1
---
# m4g db generate entity
```
Description:
Generate and database entity
Usage:
m4g db generate entity <name> [options]
Arguments:
<name> The name of the database entity
Supported formats:
Entity
path/relative/to/entities:Entity
Options:
-c, --column <column> Specify the fields to add.
Format:
<name>:<native_type>:<orm_type>
<name> = Name of the column
<native_type> = The native Python type
<orm_type> = The SQLAlchemy type
Example:
first_name:str:String(255)
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,19 @@
---
sidebar_position: 1
---
# m4g db generate migration
```
Description:
Generate a migration
Usage:
m4g db generate migration <name> [options]
Arguments:
<name> The name of the migration
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,21 @@
---
sidebar_position: 1
---
# m4g db link
```
Description:
Define relationships between entities
Usage:
m4g db link [command] [options]
Options:
-?, -h, --help Show help and usage information
Commands:
one <entity> Define a 1:n relation
many <entity> Define a n:m relation
```

View File

@ -0,0 +1,21 @@
---
sidebar_position: 1
---
# m4g db link many
```
Description:
Define a n:m relation
Usage:
m4g db link many <entity> [options]
Arguments:
<entity> The left side of the relation
Options:
--to-one <to-one> The right side of the relation
--to-many <to-many> The right side of the relation
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,21 @@
---
sidebar_position: 1
---
# m4g db link one
```
Description:
Define a 1:n relation
Usage:
m4g db link one <entity> [options]
Arguments:
<entity> The left side of the relation
Options:
--to-one <to-one> The right side of the relation
--to-many <to-many> The right side of the relation
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,16 @@
---
sidebar_position: 1
---
# m4g db migrate
```
Description:
Apply migrations to the database
Usage:
m4g db migrate [options]
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,16 @@
---
sidebar_position: 1
---
# m4g db rollback
```
Description:
Rollback the last migration
Usage:
m4g db rollback [options]
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,16 @@
---
sidebar_position: 1
---
# m4g db run
```
Description:
Runs the services defined in db.docker-compose.yml
Usage:
m4g db run [options]
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,16 @@
---
sidebar_position: 1
---
# m4g db stop
```
Description:
Stops db.docker-compose.yml
Usage:
m4g db stop [options]
Options:
-?, -h, --help Show help and usage information
```

View File

@ -0,0 +1,20 @@
---
sidebar_position: 1
---
# m4g generate
```
Description:
Generate common items
Usage:
m4g generate [command] [options]
Options:
-?, -h, --help Show help and usage information
Commands:
s, service <name> Generate a service
venv Generate a venv
```

View File

@ -0,0 +1,20 @@
---
sidebar_position: 1
---
# m4g generate service
```
Description:
Generate a service
Usage:
m4g generate service <name> [options]
Arguments:
<name> The name of the service
Options:
--with-session Create a service that uses database sessions
-?, -h, --help Show help and usage information
```

Some files were not shown because too many files have changed in this diff Show More