Why another console framework? I created this small framework to overcome the traditional limitation of console parsing tools and testing GitHub actions for deploying on NuGet. The ingredients of this article are good C# code, GitHub actions, and a framework that simplifies parsing of command-line arguments (allowing a YML definition of the commands you want to launch).
Introduction
Console applications are becoming viral tools for developers. After a trend that was going to move each non-visual tool to GUIs for better control, in the last years, we see the success of CLIs. Just remind to Docker and Kubernetes. In the past months, I developed an open-source headless CMS called RawCMS, and I put a CLI application on it. I started this tool by using the library in the .NET market, and the final experience wasn’t so good. I completed the task, but the next time I had to develop a client application, I decided to invest some time trying to do something better. I finished developing a straightforward console framework that automates command execution and the interaction with the user.
In this article, I will explain how I did it — focus on the exciting part I learned. The code is available on GitHub, as usual, and I released a Nuget package for testing (links at the end of the article).
Why Another Console Framework
The honest answer is because I needed to find an excuse for testing Github’s actions. I could have done a dummy project for this, but I do not like to work with fake thing too much. That’s why I take my last poor experience on .NET RawCMS CLI and try to do something useful. Now, I’ll try to find a rational reason.
The framework I used until now, Commandlineparser
, works properly, and it is well structured. It has this simple approach:
class Program
{
public class Options
{
[Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")]
public bool Verbose { get; set; }
}
static void Main(string[] args)
{
Parser.Default.ParseArguments<Options>(args)
.WithParsed<Options>(o =>
{
if (o.Verbose)
{
Console.WriteLine($"Verbose output enabled. Current Arguments: -v {o.Verbose}");
Console.WriteLine("Quick Start Example! App is in Verbose mode!");
}
else
{
Console.WriteLine($"Current Arguments: -v {o.Verbose}");
Console.WriteLine("Quick Start Example!");
}
});
}
}
This lead in organizing the code as follows:
- You create a class per command that map inputs
- In this class, you will define annotation on each field that will be parsed from the command line input
- You will define a piece of code that will receive the class input
I found this approach very well structured, with a good definition of model and business logic. Anyway, for a simple project, ask you to produce many classes, keep them synced, and this is a mess of time for very simple programs. Moreover, it doesn’t provide any support for working in interactive mode or automating scripts.
To overcome these limitations, I decided to use a different approach:
- The parameter and command definition will be derived automatically from the method annotations.
- I will implement an architecture that will allow the user to call a command directly or simply define a script and run it, utilizing the command inside it.
If what I tried to explain is not clear, maybe we need an example.
class Program
{
static void Main(string[] args)
{
ConsoleAuto.Config(args)
.LoadCommands()
.Run();
}
[ConsoleCommand]
public void MyMethod(string inputOne, int inputTwo)
{
}
}
and you can use it like:
> MyProgram.exec CommandName --arg value --arg2 value
> MyProgram.exec Exec MySettings.yaml # (settings from file input!)
> MyProgram.execWelcome to MyProgram!
This is the commands available, digit the number to get more info
0 - info: Display this text
1 - MyMethod: Execute a test method
I hope now it is a little bit clear.
How It Works
This tool is very simple. It can be decomposed in a few subparts, form top to bottom:
- the fluent registration class
- the program definition (sequence of command to be executed)
- the command implementation
- the base execution
The Fluent Registration Class
This class is designed to offer a simple way to be integrated into console applications. You will need something like this:
ConsoleAuto.Config(args, servicecollection)
.LoadCommands()
.LoadCommands(assembly)
.LoadFromType(typeof(MyCommand))
.Register<MyService>()
.Register<IMyService2>(new Service2())
.Default("MyDefaultCommand")
.Configure(config => {
})
.Run();
The Program Definition (Sequence of Command to Be Executed)
The console parsing ends in the hydration of a class that represents the program definition. This class contains the list of commands to be executed and the argument that they need to work. All the things that will be executed at runtime pass from these commands. For example, the welcome message is a command that is executed at each time, or there is an “info” command that displays all available options.
This is the program definition:
public class ProgramDefinition
{
public Dictionary<string, object> State { get; set; }
public Dictionary<string, CommandDefinition> Commands { get; set; }
}
That is loaded from a YAML script file or by command-line invocation.
Commands:
welcome_step1:
Action: welcome
Desctiption:
Args:
header: my text (first line)
welcome_step2:
Action: welcome
Desctiption:
Args:
header: my text (second line)
main_step:
Action: CommandOne
Description:
Args:
text: I'm the central command output!
State:
text: myglobal
The Command Implementation
The command implementation represents a command that can be invoked. I created two annotations, one for elevating a regular method as a command, and one for describing the parameter.
The implementation is something like:
public class CommandImplementation
{
public string Name { get; set; }
public MethodInfo Method { get; set; }
public Dictionary<string, object> DefaultArgs { get; set; }
public ExecutionMode Mode { get; set; } = ExecutionMode.OnDemand;
public int Order { get; set; }
public bool IsPublic { get; set; } = true;
public string Info { get; set; }
public List<ParamImplementation> Params { get; set; }
}
The ConsoleAuto
scans all assembly to find all commands and adds it to its internal set of available commands.
The Base Execution
When the user asks for command execution, the class is activated by .NET Core DI and the method executed using reflection.
public void Run()
{
LoadCommands(this.GetType().Assembly);
this.serviceBuilder.AddSingleton<ConsoleAutoConfig>(this.config);
foreach (var command in this.config.programDefinition.Commands)
{
var commandDef = command.Value;
var commandImpl = this.config
.AvailableCommands
.FirstOrDefault(x => x.Name == commandDef.Action);
InvokeCommand(commandImpl, commandDef);
}
}
private void InvokeCommand
(CommandImplementation commandImpl, CommandDefinition commandDefinition)
{
commandImpl.Method.Invoke(instance, args.ToArray());
}
The DevOps Setup
After spending a couple of hours in this funny library, I finally arrived at the point where I can start testing the GitHub actions.
What I wanted to set up was a simple flow that:
- builds the code
- tests the code
- packages the code into a Nuget package
- pushes the package to NuGet
The GitHub setup was easy, and you just need to create a YAML file into the .github/workflows folder.
The file I used is this one.
name: .NET Core
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Print version
run: sed -i "s/1.0.0/1.0.$GITHUB_RUN_NUMBER.0/g" ConsoleAuto/ConsoleAuto.csproj
&& cat ConsoleAuto/ConsoleAuto.csproj
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 3.1.101
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
- name: publish on version change
id: publish_nuget
uses: rohith/publish-nuget@v2
with:
# Filepath of the project to be packaged, relative to root of repository
PROJECT_FILE_PATH: ConsoleAuto/ConsoleAuto.csproj
# NuGet package id, used for version detection & defaults to project name
PACKAGE_NAME: ConsoleAuto
# Filepath with version info, relative to root of repository
# & defaults to PROJECT_FILE_PATH
VERSION_FILE_PATH: ConsoleAuto/ConsoleAuto.csproj
# Regex pattern to extract version info in a capturing group
VERSION_REGEX: <Version>(.*)<\/Version>
# Useful with external providers like Nerdbank.GitVersioning,
# ignores VERSION_FILE_PATH & VERSION_REGEX
# VERSION_STATIC: ${{steps.version.outputs.Version }}
# Flag to toggle git tagging, enabled by default
TAG_COMMIT: true
# Format of the git tag, [*] gets replaced with actual version
# TAG_FORMAT: v*
# API key to authenticate with NuGet server
NUGET_KEY: ${{secrets.NUGET_API_KEY}}
# NuGet server uri hosting the packages, defaults to https://api.nuget.org
# NUGET_SOURCE: https://api.nuget.org
# Flag to toggle pushing symbols along with nuget package to the server,
# disabled by default
# INCLUDE_SYMBOLS: false
Please note the automatic versioning based on the progressive build number.
The steps are:
- patching the versions
- build
- test
- publish to NuGet
The paths are easily done replacing the version in .csproj file. It is done as the first step so that all following compilation is made with the right build number.
sed -i "s/1.0.0/1.0.$GITHUB_RUN_NUMBER.0/g" ConsoleAuto/ConsoleAuto.csproj
The script assumes that in csproj, there is always version tag 1.0.0. You can use regexp to improve that solution.
The build\test commands are quite standard and are equivalent to:
dotnet restore
dotnet build --configuration Release --no-restore
dotnet test --no-restore --verbosity normal
The final publishing step is a third party addon that runs internally the pack command and then the push one. If you look to the GitHub history, you will find something similar to this:
dotnet pack --no-build -c Release ConsoleAuto/ConsoleAuto.csproj
dotnet nuget push *.nupkg
What I Learned From this Experience
In our field, all we need is already discovered or done. The fast way to complete a task is by doing it using what you find. Never reinvent the wheel. Anyway, when you need to learn something new, you may be encouraged by trying to create something useful. This can make your learning more practical and let you clash with real problems.
After a Saturday spent on this library, I learned:
- There was already a library that maps automatically form methods to command line arguments. This library is very well done and also has a web interface for running commands visually using swagger UI.
- The GitHub actions are very cool, but the problem is on third party plugins that are not well documented or fully working. Just give them some time to mature. But for this topic, I will need another article. 😃
References
History
- 3rd July, 2020: Initial version