Compare commits

...

54 Commits

Author SHA1 Message Date
c0b2ce44ad Changed build_cli trigger to push
All checks were successful
Build and publish MycroForge.CLI / build (push) Successful in 45s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-24 11:56:48 +02:00
21f4e7765b Added uvicorn to API feature
All checks were successful
Test MycroForge.CLI / test (push) Has been skipped
2024-10-24 11:52:37 +02:00
33ba944f8a Updated scripts
All checks were successful
Test MycroForge.CLI / test (push) Successful in 2m23s
2024-10-13 18:12:04 +02:00
05051878f2 Cleaning up versions and workflows
All checks were successful
Test MycroForge.CLI / test (push) Has been skipped
2024-10-13 16:03:02 +02:00
3fff9c7dd0 Renamed build job to test 2024-10-13 15:34:33 +02:00
eda7992c23 Apply version 0.0.1
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 1m41s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-13 15:13:50 +02:00
457429f7ec Improved plugin feature
All checks were successful
Build and publish MycroForge.CLI / test (push) Has been skipped
Test MycroForge.CLI / test (push) Successful in 5m39s
2024-10-13 13:39:56 +02:00
8f82360cc7 Forget dollas symbol
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 40s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:28:39 +02:00
8396da2e9a Cleaned up build
Some checks failed
Test MycroForge.CLI / test (push) Has been cancelled
Build and publish MycroForge.CLI / test (push) Failing after 47s
2024-10-06 14:27:10 +02:00
5c7d07afb0 Fixed syntax error
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 41s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:20:47 +02:00
52d2507891 Listing nupkg directory
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 56s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:16:41 +02:00
7d677d27c6 Trying with full path
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 49s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:13:22 +02:00
0f85244681 Changed version variable reference
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 1m5s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:09:00 +02:00
d59bf264b0 Changed dotnet pack verbosity level
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 49s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:06:36 +02:00
9b339c738e Wrapped nuget push argument in quotes
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 42s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:00:31 +02:00
165d97245d Properly configured build actions
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 50s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 13:50:21 +02:00
a1ffc51a57 Moved docs to separate repo
Some checks failed
Build and publish MycroForge.CLI / test (push) Has been cancelled
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 13:27:22 +02:00
6464b9f8f3 Merged build & publish steps
Some checks failed
Test MycroForge.CLI / test (push) Has been skipped
Build and publish MycroForge.CLI / test (push) Failing after 41s
2024-10-06 12:52:12 +02:00
30d2ccba76 Fixed secret reference
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 45s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:48:56 +02:00
7d61f76b94 Removed comment
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 1m25s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:45:27 +02:00
38b83ac8ac Added version to .csproj
All checks were successful
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:43:45 +02:00
f8efd45076 Onelined add source command
All checks were successful
Build MycroForge.CLI / test (push) Successful in 39s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:31:49 +02:00
c1f618ad19 Merged adding source with the publishing
All checks were successful
Build MycroForge.CLI / test (push) Successful in 54s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:27:55 +02:00
5d49b9ab2d Added directory change to MycroForge.CLI
Some checks failed
Build MycroForge.CLI / test (push) Failing after 44s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:24:46 +02:00
bb3d75521e Testing build action
Some checks failed
Build MycroForge.CLI / test (push) Failing after 37s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:19:20 +02:00
bc1f0fb943 Manually reference MycroForge.Core in MycroForge.PluginTemplate
All checks were successful
Test and build / test (push) Successful in 2m46s
2024-10-06 11:33:36 +02:00
e95bd759c3 Commented all steps except test
Some checks failed
Test and build / test (push) Failing after 41s
2024-10-06 11:24:53 +02:00
2918f87911 Changed line endings
Some checks failed
Test and build / test (push) Failing after 1m37s
2024-10-06 10:20:45 +02:00
33a86882ac Removed driver spec
Some checks are pending
Test and build / test (push) Waiting to run
2024-10-06 10:19:58 +02:00
d95ee39c0b Tried specifying driver too
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 10:00:58 +02:00
9109e9a4c7 Changed back to ubuntu-latest again
Some checks are pending
Test and build / test (push) Waiting to run
2024-10-06 09:55:52 +02:00
67a693b916 Changed .yaml to .yml and changed name back to default
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 09:53:57 +02:00
b8373f6908 Changed act runner label
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 09:46:15 +02:00
b14a2e7117 Fixed error in yaml
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 09:41:57 +02:00
207345b54b Moved build file to workflows folder 2024-10-06 09:40:27 +02:00
dc8327289f Testing ci pipeline 2024-10-06 09:28:13 +02:00
a88f8a1f11 Added .gitea directory 2024-10-05 16:54:45 +02:00
f676f236b1 Added CLI test project and tests for 'm4g init' command 2024-10-05 13:37:14 +02:00
4f322e56c7 Updated README.md 2024-09-22 19:40:28 +02:00
4b0d1df34f Added missing example in tutorial 2024-09-22 17:33:47 +02:00
128ae21088 Clean up 2024-07-26 16:57:28 +02:00
6301bd438a Additional clean up 2024-07-25 07:35:06 +02:00
3f33035611 Cleaned up 2024-07-25 07:34:27 +02:00
32b7a3c01c Cleaned up project and added constraints for SQLAlchemy types in generate entity 2024-07-24 07:18:42 +02:00
91431fd996 Clean up 2024-07-23 22:04:51 +02:00
5ccb40bb44 Fixed exceptions and added constraints to commands 2024-07-23 21:46:11 +02:00
d210c6ac7c Cleaned up, tested and documented --dbu-platform 2024-07-22 21:27:17 +02:00
aa1c2422ef Ading comments and cleaning up 2024-07-22 06:24:45 +02:00
5698b504e9 Removed bash highlighting support 2024-07-20 00:33:02 +02:00
e2b2c82ff7 Optimised docs build and polished docs 2024-07-19 16:18:24 +02:00
7badcc333b Added command plugin documentation and renamed subCommands to commands in api command code 2024-07-19 15:05:20 +02:00
bf27a344e1 Improved docs 2024-07-19 13:08:59 +02:00
577d61ed42 Added deployment capabilities 2024-07-17 23:20:07 +02:00
777b0fccc8 Updated docs and minor cleanup 2024-07-15 22:49:31 +02:00
134 changed files with 1060 additions and 17259 deletions

19
.dockerignore Normal file
View File

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

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

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

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

20
.gitea/workflows/test.yml Normal file
View File

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

30
Dockerfile Normal file
View File

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

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

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

View File

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

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

View File

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

View File

@ -96,14 +96,18 @@ public partial class EntityLinker
var left = await LoadEntity(_left);
var right = await LoadEntity(_right);
var associationTable = string.Join('\n', AssociationTable);
associationTable = associationTable
var associationTable = string.Join('\n', 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 =
$"{Features.Db.FeatureName}/entities/associations/{left.TableName.Singularize()}_{right.TableName.Singularize()}_mapping.py";
var associationTablePath = Path.Join(
Features.Db.FeatureName,
"entities",
"associations",
$"{left.TableName.Singularize()}_{right.TableName.Singularize()}_mapping.py"
);
await _context.CreateFile(associationTablePath, associationTable);
@ -138,19 +142,23 @@ public partial class EntityLinker
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();
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 = $"{Features.Db.FeatureName}/entities";
var path = Path.Join(Features.Db.FeatureName, "entities");
if (fqn.HasPath)
path = Path.Combine(path, fqn.Path);
if (fqn.HasNamespace)
path = Path.Join(path, fqn.Namespace);
path = Path.Combine(path, $"{fqn.SnakeCasedName}.py");
path = Path.Join(path, $"{fqn.SnakeCasedName}.py");
var entity = new EntityModel(fqn.PascalizedName, path, await _context.ReadFile(path));
entity.Initialize();
return entity;

View File

@ -38,12 +38,14 @@ public class MainModifier
public MainModifier IncludeRouter(string prefix, string router)
{
_routerIncludeBuffer.Add($"\napp.include_router(prefix=\"/{prefix}\", router={router}.router)\n");
_routerIncludeBuffer.Add($"app.include_router(prefix=\"/{prefix}\", router={router}.router)");
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();
@ -69,15 +71,17 @@ 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, _routerIncludeBuffer.ToArray()
);
_source.InsertMultiLine(_lastRouterInclude.EndIndex, content);
}
else
{
_source.InsertMultiLineAtEnd(_routerIncludeBuffer.ToArray());
_source.InsertMultiLineAtEnd(content);
}
}
}

View File

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

View File

@ -0,0 +1,17 @@
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresFeatureAttribute : Attribute
{
public string FeatureName { get; }
public RequiresFeatureAttribute(string featureName)
{
FeatureName = featureName;
}
public void RequireFeature(string command)
{
if (!Directory.Exists(Path.Join(Environment.CurrentDirectory, FeatureName)))
throw new($"Command '{command}' requires feature {FeatureName}");
}
}

View File

@ -0,0 +1,17 @@
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresFileAttribute : Attribute
{
public string FilePath { get; }
public RequiresFileAttribute(string filePath)
{
FilePath = filePath;
}
public void RequireFile(string command)
{
if (!File.Exists(Path.Join(Environment.CurrentDirectory, FilePath)))
throw new($"Command '{command}' requires file {FilePath}");
}
}

View File

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

@ -0,0 +1,12 @@
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,14 +1,20 @@
using Humanizer;
using MycroForge.CLI.Extensions;
namespace MycroForge.CLI.Commands;
public class FullyQualifiedName
{
public string Path { get; }
public string Namespace { get; }
public string PascalizedName { get; }
public string SnakeCasedName { get; }
public bool HasPath => Path.Length > 0;
public string FilePath =>
string.IsNullOrEmpty(Namespace.Trim())
? SnakeCasedName
: Path.Join(Namespace, SnakeCasedName);
public bool HasNamespace => Namespace.Length > 0;
public FullyQualifiedName(string name)
@ -21,8 +27,22 @@ public class FullyQualifiedName
name = fullName[1];
}
Path = path;
Namespace = path;
PascalizedName = name.Pascalize();
SnakeCasedName = name.Underscore().ToLower();
SnakeCasedName = SnakeCase(name);
}
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,6 +21,11 @@ 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;
@ -34,12 +39,22 @@ public partial class MycroForge
AddOption(DbhPortOption);
AddOption(DbuPortOption);
this.SetHandler(ExecuteAsync, DbhPortOption, DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, DbhPortOption, DbuPortOption, DbuPlatformOption);
}
private async Task ExecuteAsync(int dbhPort, int dbuPort)
private async Task ExecuteAsync(
int dbhPort,
int dbuPort,
ProjectConfig.DbConfig.DbuPlatformOptions dbuPlatform
)
{
_optionsContainer.Set(new Features.Db.Options { DbhPort = dbhPort, DbuPort = dbuPort });
_optionsContainer.Set(new Features.Db.Options
{
DbhPort = dbhPort,
DbuPort = dbuPort,
DbuPlatform = dbuPlatform
});
var feature = _features.First(f => f.Name == Features.Db.FeatureName);
await feature.ExecuteAsync(_context);
}

View File

@ -29,11 +29,10 @@ public partial class MycroForge
private async Task ExecuteAsync(string entity)
{
var fqn = new FullyQualifiedName(entity);
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);
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);
}
}
}

View File

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

View File

@ -1,17 +1,19 @@
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>> subCommands) :
public Api(IEnumerable<ISubCommandOf<Api>> commands) :
base("api", "API related commands")
{
foreach (var subCommandOf in subCommands)
AddCommand((subCommandOf as Command)!);
foreach (var command in commands)
AddCommand((command 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,11 +15,65 @@ public partial class MycroForge
{
public class Entity : Command, ISubCommandOf<Generate>
{
private record ColumnDefinition(string Name, string NativeType, string OrmType);
#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 static readonly string[] Template =
[
"from sqlalchemy import %type_imports%",
"from sqlalchemy import %sqlalchemy_imports%",
"from sqlalchemy.orm import Mapped, mapped_column",
$"from {Features.Db.FeatureName}.entities.entity_base import EntityBase",
"",
@ -56,6 +110,10 @@ 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")
@ -70,25 +128,27 @@ public partial class MycroForge
private async Task ExecuteAsync(string name, IEnumerable<string> columns)
{
var fqn = new FullyQualifiedName(name);
var folderPath = $"{Features.Db.FeatureName}/entities";
var folderPath = Path.Join(Features.Db.FeatureName, "entities");
_context.AssertDirectoryExists(Features.Db.FeatureName);
if (fqn.HasNamespace)
folderPath = Path.Join(folderPath, fqn.Namespace);
if (fqn.HasPath)
folderPath = Path.Combine(folderPath, fqn.Path);
var sqlAlchemyColumn = GetColumnDefinitions(columns.ToArray());
var distinctSqlAlchemyColumnTypes = sqlAlchemyColumn
.Select(c => c.SqlAlchemyType.Split('(').First())
.Distinct();
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 sqlAlchemyImport = string.Join(", ", distinctSqlAlchemyColumnTypes);
var columnDefinitions = string.Join("\n ", sqlAlchemyColumn.Select(ColumnToString));
var code = string.Join('\n', Template);
code = code.Replace("%type_imports%", typeImports);
code = code.Replace("%sqlalchemy_imports%", sqlAlchemyImport);
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.Combine(folderPath, fileName);
var filePath = Path.Join(folderPath, fileName);
await _context.CreateFile(filePath, code);
var importPathParts = new[] { folderPath, fileName.Replace(".py", "") }
@ -109,25 +169,58 @@ public partial class MycroForge
await _context.WriteFile("main.py", main);
}
private List<ColumnDefinition> GetColumnDefinitions(string[] fields)
private List<ColumnDefinition> GetColumnDefinitions(string[] columns)
{
var definitions = new List<ColumnDefinition>();
foreach (var field in fields)
foreach (var column in columns)
{
if (field.Split(':') is not { Length: 3 } definition)
throw new Exception($"Field definition {field} is invalid.");
if (column.Split(':') is not { Length: 3 } definition)
throw new Exception($"Column definition {column} is invalid.");
definitions.Add(new ColumnDefinition(definition[0], definition[1], definition[2]));
}
ValidateSqlAlchemyColumnTypes(definitions);
return definitions;
}
private static string ColumnToString(ColumnDefinition definition)
private static void ValidateSqlAlchemyColumnTypes(List<ColumnDefinition> definitions)
{
return $"{definition.Name}: Mapped[{definition.NativeType}] = mapped_column({definition.OrmType})";
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);
}
}
}
private static string ColumnToString(ColumnDefinition definition) =>
$"{definition.Name}: Mapped[{definition.NativeType}] = mapped_column({definition.SqlAlchemyType})";
}
}
}

View File

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

View File

@ -21,9 +21,10 @@ public partial class MycroForge
private async Task ExecuteAsync()
{
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");
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"
);
}
}
}

View File

@ -1,10 +1,12 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresFeature(Features.Db.FeatureName)]
public partial class Db : Command, ISubCommandOf<MycroForge>
{
public Db(IEnumerable<ISubCommandOf<Db>> commands)

View File

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

View File

@ -1,4 +1,5 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@ -6,6 +7,7 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresFile("requirements.txt")]
public class Hydrate : Command, ISubCommandOf<MycroForge>
{
private readonly ProjectContext _context;

View File

@ -15,6 +15,7 @@ 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,4 +1,6 @@
namespace MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
@ -11,6 +13,7 @@ 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()
{
@ -20,7 +23,8 @@ public partial class MycroForge
public Features.Db.Options DbOptions => new()
{
DbhPort = DbhPort <= 0 ? 5050 : DbhPort,
DbuPort = DbuPort <= 0 ? 5051 : DbhPort
DbuPort = DbuPort <= 0 ? 5051 : DbhPort,
DbuPlatform = DbuPlatform
};
}
}

View File

@ -39,6 +39,11 @@ 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;
@ -55,6 +60,7 @@ public partial class MycroForge
AddOption(ApiPortOption);
AddOption(DbhPortOption);
AddOption(DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, new Binder());
}
@ -75,7 +81,7 @@ public partial class MycroForge
await _context.CreateFile("main.py");
// Create the venv
await _context.Bash($"python3 -m venv {Path.Combine(projectRoot, ".venv")}");
await _context.Bash($"python3 -m venv {Path.Join(projectRoot, ".venv")}");
// Pass feature arguments to the ArgsContainer
_optionsContainer.Set(options.ApiOptions);
@ -100,7 +106,7 @@ public partial class MycroForge
private async Task<string> CreateDirectory(string name)
{
var directory = Path.Combine(Directory.GetCurrentDirectory(), name);
var directory = Path.Join(Directory.GetCurrentDirectory(), name);
if (Directory.Exists(directory))
throw new Exception($"Directory {directory} already exists.");

View File

@ -1,4 +1,5 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@ -6,6 +7,7 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresVenv]
public class Install : Command, ISubCommandOf<MycroForge>
{
private static readonly Argument<IEnumerable<string>> PackagesArgument =
@ -28,7 +30,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,21 +10,29 @@ public partial class MycroForge
{
public class Init : Command, ISubCommandOf<Plugin>
{
private static readonly Argument<string> NameArgument =
new(name: "name", description: "The name of your project");
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 readonly ProjectContext _context;
public Init(ProjectContext context) : base("init", "Initialize a basic plugin project")
{
_context = context;
AddArgument(NameArgument);
this.SetHandler(ExecuteAsync, NameArgument);
AddArgument(NamespaceArgument);
AddArgument(ClassArgument);
AddArgument(CommandArgument);
this.SetHandler(ExecuteAsync, NamespaceArgument, ClassArgument, CommandArgument);
}
private async Task ExecuteAsync(string name)
private async Task ExecuteAsync(string @namespace, string @class, string command)
{
await _context.Bash($"dotnet new m4gp -n {name}");
await _context.Bash($"dotnet new m4gp -n {@namespace} --class {@class} --command {command}");
}
}
}

View File

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

View File

@ -1,4 +1,5 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@ -6,6 +7,7 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresVenv]
public class Uninstall : Command, ISubCommandOf<MycroForge>
{
private static readonly Argument<IEnumerable<string>> PackagesArgument =
@ -27,9 +29,17 @@ public partial class MycroForge
private async Task ExecuteAsync(IEnumerable<string> packages, bool yes)
{
var packs = packages.ToArray();
if (packs.Length == 0)
{
Console.WriteLine("'m4g uninstall' requires at least one package.");
return;
}
await _context.Bash(
"source .venv/bin/activate",
$"pip uninstall{(yes ? " --yes " : " ")}{string.Join(' ', packages)}",
$"pip uninstall{(yes ? " --yes " : " ")}{string.Join(' ', packs)}",
"pip freeze > requirements.txt"
);
}

View File

@ -0,0 +1,96 @@
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 RegisterCommandDefaults(this IServiceCollection services)
public static IServiceCollection RegisterDefaultCommands(this IServiceCollection services)
{
// Register ProjectContext, OptionsContainer & features
services.AddScoped<ProjectContext>();

View File

@ -2,11 +2,8 @@
public static class StringExtensions
{
public static string DeduplicateDots(this string path)
{
while (path.Contains(".."))
path = path.Replace("..", ".");
return path.Trim('.');
}
public static string SlashesToDots(this string path) =>
path.Replace('/', '.')
.Replace('\\', '.')
.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",
"python3 -m pip install fastapi uvicorn",
"python3 -m pip freeze > requirements.txt"
);

View File

@ -1,10 +1,15 @@
namespace MycroForge.CLI.Features;
using MycroForge.Core;
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,7 +37,6 @@ 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",
"",
@ -58,7 +57,7 @@ public sealed partial class Db : IFeature
" - '%app_name%_mariadb:/var/lib/mysql'",
"",
" %app_name%_phpmyadmin:",
" image: phpmyadmin/phpmyadmin",
" image: %dbu_platform%/phpmyadmin",
" container_name: %app_name%_phpmyadmin",
" ports:",
" - '${DBU_PORT}:80'",
@ -95,7 +94,8 @@ public sealed partial class Db : IFeature
config.Db = new()
{
DbhPort = options.DbhPort ?? 5050,
DbuPort = options.DbuPort ?? 5051
DbuPort = options.DbuPort ?? 5051,
DbuPlatform = options.DbuPlatform
};
await context.SaveConfig(config);
@ -123,7 +123,10 @@ 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);
var dockerCompose = string.Join('\n', DockerCompose)
.Replace("%app_name%", appName)
.Replace("%dbu_platform%", options.DbuPlatform.ToString())
;
await context.CreateFile($"{FeatureName}.docker-compose.yml", dockerCompose);
}

View File

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

View File

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

View File

@ -1,11 +0,0 @@
#!/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

@ -1,7 +0,0 @@
#!/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

@ -1,21 +0,0 @@
#!/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

@ -1,19 +0,0 @@
#!/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,11 +0,0 @@
#!/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,5 +1,7 @@
#!/usr/bin/bash
#!/usr/bin/bash
dotnet pack -v d
dotnet nuget push nupkg/MycroForge.CLI.1.0.0.nupkg --source devdisciples
VERSION="1.0.0"
dotnet nuget push nupkg/MycroForge.CLI.$VERSION.nupkg --source devdisciples

View File

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

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

@ -0,0 +1,17 @@
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,14 +4,11 @@ namespace MycroForge.Core.Extensions;
public static class ObjectStreamExtensions
{
public static async Task<string> SerializeAsync(
this object @object,
JsonSerializerOptions? jsonSerializerOptions = null
)
public static async Task<string> SerializeAsync(this object @object, JsonSerializerOptions? options = null)
{
using var stream = new MemoryStream();
using var reader = new StreamReader(stream);
var options = jsonSerializerOptions ?? Serialization.DefaultJsonSerializerOptions.Default;
options ??= DefaultJsonSerializerOptions.Default;
await JsonSerializer.SerializeAsync(stream, @object, options);
stream.Position = 0;

View File

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

View File

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

View File

@ -1,10 +1,16 @@
namespace MycroForge.Core;
using System.Text.Json.Serialization;
namespace MycroForge.Core;
public partial class ProjectConfig
{
public class DbConfig
public partial 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.Combine(RootDirectory, "m4g.json");
private string ConfigPath => Path.Join(RootDirectory, "m4g.json");
public async Task<ProjectConfig> LoadConfig(bool create = false)
@ -22,7 +22,7 @@ public class ProjectContext
var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(
File.OpenRead(ConfigPath),
Serialization.DefaultJsonSerializerOptions.CamelCasePrettyPrint
DefaultJsonSerializerOptions.CamelCasePrettyPrint
);
if (config is null)
@ -37,21 +37,9 @@ 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.Combine(RootDirectory, path);
var fullPath = Path.Join(RootDirectory, path);
var fileInfo = new FileInfo(fullPath);
if (fileInfo.Exists) return;
@ -59,11 +47,12 @@ 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.Combine(RootDirectory, path);
var fullPath = Path.Join(RootDirectory, path);
var fileInfo = new FileInfo(fullPath);
if (!fileInfo.Exists)
@ -74,13 +63,14 @@ public class ProjectContext
public async Task WriteFile(string path, params string[] content)
{
var fullPath = Path.Combine(RootDirectory, path);
var fullPath = Path.Join(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 Bash(params string[] script)
public async Task<int> Bash(params string[] script)
{
var info = new ProcessStartInfo
{
@ -114,21 +104,21 @@ public class ProjectContext
process.BeginErrorReadLine();
await using var input = process.StandardInput;
foreach (var line in script)
await input.WriteLineAsync(line);
// Concat with '&&' operator to make sure that script does not continue on failure.
await input.WriteAsync(string.Join(" && ", script));
await input.FlushAsync();
input.Close();
await process.WaitForExitAsync();
Environment.ExitCode = process.ExitCode;
if (process.ExitCode != 0)
Console.WriteLine($"Process finished with exit code {process.ExitCode}.");
return process.ExitCode;
}
public async Task SaveConfig(ProjectConfig config)
{
var json = await config.SerializeAsync(Serialization.DefaultJsonSerializerOptions.CamelCasePrettyPrint);
var json = await config.SerializeAsync(DefaultJsonSerializerOptions.CamelCasePrettyPrint);
await File.WriteAllTextAsync(ConfigPath, json);
}
}

View File

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

@ -0,0 +1,5 @@
#!/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>1.0</PackageVersion>
<PackageVersion>0.0.1</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 local nuget
### Push to devdisciples 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 @@
#!/usr/bin/env bash
#!/bin/env bash
rm -rf templates/MycroForge.PluginTemplate
cp -R ../MycroForge.PluginTemplate templates/MycroForge.PluginTemplate

View File

@ -0,0 +1,12 @@
#!/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,5 +17,16 @@
"language": "C#",
"type": "project"
},
"preferNameDirectory": true
"preferNameDirectory": true,
"symbols": {
"class": {
"type": "parameter",
"replaces": "ExampleCommand",
"fileRename": "ExampleCommand"
},
"command": {
"type": "parameter",
"replaces": "example"
}
}
}

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ 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
@ -30,5 +32,9 @@ 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,29 +1,3 @@
### Dependencies
### Documentation
- 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
```
Go to https://m4g.devdisciples.com for the docs.

20
docs/.gitignore vendored
View File

@ -1,20 +0,0 @@
# 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*

View File

@ -1,41 +0,0 @@
# 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.

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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