41
loading...
This website collects cookies to deliver better user experience
Spectre.Console is a .NET Standard 2.0 library that makes it easier to create beautiful console applications.
SampleMigrator
class, abstracting the actual migration work (which will actually just be a bunch of Task.Delay
s to simulate stuff). It provides a way to connect to an environment, gather some initial information, do the actual migration and then disconnect.public class SampleMigrator : IAsyncDisposable
{
Task ConnectAsync(string username, string password, Environment environment);
Task<MigrationInformation> GatherMigrationInformationAsync();
IAsyncEnumerable<MigrationResult> MigrateAsync();
ValueTask DisposeAsync();
}
public enum Environment { Development, Staging, Production }
public record MigrationInformation(int ThingsToMigrate);
public record MigrationResult(int ThingsId, bool IsMigrationSuccessful);
ConnectAsync
and DisposeAsync
handle the connection lifetime, GatherMigrationInformationAsync
gets some initial information, while MigrateAsync
handles the migration process, returning an IAsyncEnumerable
to report the status.dotnet add package Spectre.Console
var app = new CommandApp();
app.Configure(config =>
{
config.AddCommand<MigrateCommand>("migrate");
config.AddCommand<RollbackCommand>("rollback");
});
AddCommand
method requires us to specify a generic type that implements ICommand
, getting as a parameter the name of the command, i.e. what we'll write in the command line to execute it.ICommand
directly, or we can inherit from some abstract classes that already have some boilerplate code in place. These are Command
and AsyncCommand
, but also Command<TSettings>
and AsyncCommand<TSettings>
, in which TSettings
allow us to specify some settings, like the parameters and options the command accepts.public class MigrateCommand : AsyncCommand<MigrateCommand.Settings>
{
public class Settings : CommandSettings
{
[CommandOption("-u|--username")]
[Description("Username to access the environment for migration")]
public string? Username { get; init; }
[CommandOption("-p|--password")]
[Description("Password to access the environment for migration")]
public string? Password { get; init; }
[CommandOption("-e|--environment")]
[Description("Target environment for the migration")]
public Environment? Environment { get; init; }
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
// ...
}
}
./SpectreConsoleSample.App migrate -u SomeUser
migrate
selects the command, then I'm using options, namely the -u/--username
option to immediately provide the username to the application. Could do the same with the password and environment, but could't show off all of the things happening in the demo.Validate
in the Settings
class), cause we're going to ask the user for any missing/incorrect value.ExecuteAsync
, started with the following, asking for any missing options:var username = AskUsernameIfMissing(settings.Username);
var password = AskPasswordIfMissing(settings.Password);
var environment = AskEnvironmentIfMissing(settings.Environment);
AskXYZ
using the following local functions:static string AskUsernameIfMissing(string? current)
=> !string.IsNullOrWhiteSpace(current)
? current
: AnsiConsole.Prompt(
new TextPrompt<string>("What's the username?")
.Validate(username
=> !string.IsNullOrWhiteSpace(username)
? ValidationResult.Success()
: ValidationResult.Error("[yellow]Invalid username[/]")));
static string AskPasswordIfMissing(string? current)
=> TryGetValidPassword(current, out var validPassword)
? validPassword
: AnsiConsole.Prompt(
new TextPrompt<string>("What's the password?")
.Secret()
.Validate(password
=> TryGetValidPassword(password, out _)
? ValidationResult.Success()
: ValidationResult.Error("[yellow]Invalid password[/]")));
static bool TryGetValidPassword(string? password, [NotNullWhen(true)] out string? validPassword)
{
var isValidPassword = !string.IsNullOrWhiteSpace(password) && password.Length > 2;
validPassword = password;
return isValidPassword;
}
static Environment AskEnvironmentIfMissing(Environment? current)
=> current ?? AnsiConsole.Prompt(
new SelectionPrompt<Environment>()
.Title("What's the target environment?")
.AddChoices(
Environment.Development,
Environment.Staging,
Environment.Production)
);
AnsiConsole
is the entry point for interactions with the console.AnsiConsole.Prompt
and passing in a TextPromp
, where we provide the text to show the user and expect a string
in return. We can define a validation function, so if the user enters incorrect information, it'll be refused (as you can see in the video at the top of the post). For the password, we also call the Secret
extension method, so this prompt is treated as such, not showing the value on the screen.SelectionPrompt
, so the user needs only to move through the options and select the desired one.AnsiConsole.Render(
new Table()
.AddColumn(new TableColumn("Setting").Centered())
.AddColumn(new TableColumn("Value").Centered())
.AddRow("Username", username)
.AddRow("Password", "REDACTED")
.AddRow(new Text("Environment"), new Markup($"[{GetEnvironmentColor(environment)}]{environment}[/]")));
// ...
static string GetEnvironmentColor(Environment environment)
=> environment switch
{
Environment.Development => "green",
Environment.Staging => "yellow",
Environment.Production => "red",
_ => throw new ArgumentOutOfRangeException()
};
SampleMigrator
class.SampleMigrator
's methods are async (in multiple variations), which immediately gives us an hint that they're good candidates to make use of Spectre.Console's progress reporting features.ConnectAsync
, GatherMigrationInformationAsync
and DisposeAsync
, which return Task
or ValueTask
, we can use a simple spinner, just to signal things are happening. We can use the Status
component for that.await AnsiConsole
.Status()
.StartAsync("Connecting...", _ => migrator.ConnectAsync(username, password, environment));
var migrationInformation = await AnsiConsole
.Status()
.StartAsync(
"Gathering migration information...",
_ => migrator.GatherMigrationInformationAsync());
// ...
await AnsiConsole
.Status()
.StartAsync("Disconnecting...", _ => migrator.DisposeAsync().AsTask());
GatherMigrationInformationAsync
) and MigrateAsync
returns an IAsyncEnumerable
with information about each migrated item, we can make use of the Progress
component as follows.var migrationResults = await AnsiConsole.Progress().StartAsync(async ctx =>
{
var migrationTask = ctx.AddTask("Migrating...", maxValue: migrationInformation.ThingsToMigrate);
var successes = 0;
var failures = 0;
await foreach (var migration in migrator.MigrateAsync())
{
if (migration.IsMigrationSuccessful)
{
++successes;
}
else
{
++failures;
}
migrationTask.Increment(1);
}
return (successes, failures);
});
AddTask
call on the context. Then, for each processed item, we call Increment
on the progress task.AnsiConsole.Render(
new BarChart()
.Label("Migration results")
.AddItem("Succeeded", migrationResults.successes, Color.Green)
.AddItem("Failed", migrationResults.failures, Color.Red));