Skip to content

Commit

Permalink
Added base CLI project with configuration and login.
Browse files Browse the repository at this point in the history
  • Loading branch information
rmaffitsancsoft committed Apr 9, 2024
1 parent 9735e2a commit 4795aec
Show file tree
Hide file tree
Showing 11 changed files with 383 additions and 1 deletion.
4 changes: 3 additions & 1 deletion src/dotnet/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -485,4 +485,6 @@ $RECYCLE.BIN/

appsettings.*.json
sharedsettings.*.json
app.db
app.db

HQ.CLI/data/
34 changes: 34 additions & 0 deletions src/dotnet/HQ.CLI/Commands/ConfigureCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Cli;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace HQ.CLI.Commands;

internal class ConfigureCommand : AsyncCommand<HQCommandSettings>
{
private readonly HQConfig _config;

public ConfigureCommand(HQConfig config)
{
_config = config;
}

public override async Task<int> ExecuteAsync(CommandContext context, HQCommandSettings settings)
{
_config.ApiUrl = AnsiConsole.Prompt(
new TextPrompt<Uri>("Enter API URL:")
.ValidationErrorMessage("[red]That's not a valid API URL[/]")
.Validate(uri => uri.IsAbsoluteUri));

_config.AuthUrl = AnsiConsole.Prompt(new TextPrompt<Uri>("Enter Auth URL:")
.ValidationErrorMessage("[red]That's not a valid Auth URL[/]")
.Validate(uri => uri.IsAbsoluteUri));

return 0;
}
}
109 changes: 109 additions & 0 deletions src/dotnet/HQ.CLI/Commands/LoginCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using IdentityModel.Client;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Cli;
using System.Diagnostics;
using static IdentityModel.OidcConstants;

namespace HQ.CLI.Commands;

internal class LoginCommand : AsyncCommand<HQCommandSettings>
{
private readonly ILogger<LoginCommand> _logger;
private readonly HQConfig _config;
private readonly IDataProtectionProvider _dataProtectionProvider;

public LoginCommand(ILogger<LoginCommand> logger, HQConfig config, IDataProtectionProvider dataProtectionProvider)
{
_logger = logger;
_config = config;
_dataProtectionProvider = dataProtectionProvider;
}

public override async Task<int> ExecuteAsync(CommandContext context, HQCommandSettings settings)
{
if(String.IsNullOrEmpty(_config.AuthUrl?.AbsoluteUri))
{
AnsiConsole.MarkupLine("[red]Invalid Auth URL[/]");
return 1;
}

var client = new HttpClient();

var disco = await client.GetDiscoveryDocumentAsync(_config.AuthUrl.AbsoluteUri);
if (disco.IsError) throw new Exception(disco.Error);

var authorizeResponse = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest
{
Address = disco.DeviceAuthorizationEndpoint,
ClientId = "hq",
Scope = "openid profile email offline_access"
});
if (authorizeResponse.IsError) throw new Exception(authorizeResponse.Error);

AnsiConsole.MarkupLineInterpolated($@"Attempting to automatically open the login page in your default browser.
If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
[link]{authorizeResponse.VerificationUri}[/]
Then enter the code:
{authorizeResponse.UserCode}");

try
{
Process launchBrowserProcess = new Process();
launchBrowserProcess.StartInfo.UseShellExecute = true;
launchBrowserProcess.StartInfo.FileName = authorizeResponse.VerificationUriComplete;
launchBrowserProcess.Start();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to launch browser.");
}

var interval = TimeSpan.FromSeconds(authorizeResponse.Interval);
var expiresIn = authorizeResponse.ExpiresIn;
DateTime? expiresAt = null;
if (expiresIn.HasValue)
{
expiresAt = DateTime.UtcNow.AddSeconds(expiresIn.Value);
}

IdentityModel.Client.TokenResponse? response = null;
do
{
response = await client.RequestDeviceTokenAsync(new DeviceTokenRequest
{
Address = disco.TokenEndpoint,

ClientId = "hq",
DeviceCode = authorizeResponse.DeviceCode
});

// Console.WriteLine(response.Raw);

if (response.Error == "slow_down")
{
// Console.WriteLine("Got a slow down!");
interval += TimeSpan.FromSeconds(5);
}

// Console.WriteLine("Waiting for response...");
await Task.Delay(interval);
}
while (response.Error == "authorization_pending" || response.Error == "slow_down");
if (response.IsError) throw new Exception(response.Error);

var protector = _dataProtectionProvider.CreateProtector("HQ.CLI");

_config.RefreshToken = !String.IsNullOrEmpty(response.RefreshToken) ? protector.Protect(response.RefreshToken) : null;
_config.AccessToken = !String.IsNullOrEmpty(response.AccessToken) ? protector.Protect(response.AccessToken) : null; ;
_config.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(response.ExpiresIn);

AnsiConsole.MarkupLine("[green]Authentication successful![/] ");

return 0;
}
}
32 changes: 32 additions & 0 deletions src/dotnet/HQ.CLI/HQ.CLI.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

</PropertyGroup>

<ItemGroup>
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="IdentityModel.AspNetCore" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="Spectre.Console.Cli" Version="0.48.1-preview.0.38" />
</ItemGroup>

<ItemGroup>
<None Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>

<ItemGroup>
<Folder Include="data\keys\" />
<Folder Include="Settings\" />
</ItemGroup>

</Project>
18 changes: 18 additions & 0 deletions src/dotnet/HQ.CLI/HQCommandInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Spectre.Console.Cli;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace HQ.CLI;

internal class HQCommandInterceptor : ICommandInterceptor
{
public void Intercept(CommandContext context, CommandSettings settings)
{
if (settings is HQCommandSettings hqSettings)
{
}
}
}
13 changes: 13 additions & 0 deletions src/dotnet/HQ.CLI/HQCommandSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Spectre.Console.Cli;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace HQ.CLI
{
public class HQCommandSettings : CommandSettings
{
}
}
18 changes: 18 additions & 0 deletions src/dotnet/HQ.CLI/HQConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace HQ.CLI
{
internal class HQConfig
{
public Uri? ApiUrl { get; set; }
public Uri? AuthUrl { get; set; }
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public DateTime? AccessTokenExpiresAt { get; set; }
}
}
67 changes: 67 additions & 0 deletions src/dotnet/HQ.CLI/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using HQ.CLI;
using HQ.CLI.Commands;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Cli;
using System.Text.Json;

// Setup data directory
var userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify);
var dataPath = Environment.GetEnvironmentVariable("HQ_DATA_PATH") ?? Path.Join(userProfilePath, ".sancsoft", "hq");
Directory.CreateDirectory(dataPath);

// Setup services
var services = new ServiceCollection();

// Setup logging
var logLevel = LogLevel.Information;
if(Enum.TryParse<LogLevel>(Environment.GetEnvironmentVariable("HQ_LOG_LEVEL"), true, out LogLevel envLogLevel))
{
logLevel = envLogLevel;
}

services.AddLogging(c => c.AddConsole().SetMinimumLevel(logLevel));

// Setup configuration
var configJsonPath = Path.Join(dataPath, "config.json");
var config = File.Exists(configJsonPath) ? JsonSerializer.Deserialize<HQConfig>(File.ReadAllText(configJsonPath)) ?? new() : new();
services.AddSingleton(config);

services.AddSingleton<ICommandInterceptor, HQCommandInterceptor>();

var dataProtectionBuilder = services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Join(dataPath, "keys")))
.SetApplicationName("HQ.CLI");

if (OperatingSystem.IsWindows())
{
dataProtectionBuilder.ProtectKeysWithDpapi();
}

var registrar = new TypeRegistrar(services);
var app = new CommandApp(registrar);
app.Configure(config =>
{
config.AddCommand<ConfigureCommand>("configure")
.WithDescription("Configure HQ CLI");

config.AddCommand<LoginCommand>("login")
.WithDescription("Login to HQ CLI");
});

var rc = await app.RunAsync(args);

if(rc == 0)
{
var options = new JsonSerializerOptions()
{
WriteIndented = true
};

await File.WriteAllTextAsync(configJsonPath, JsonSerializer.Serialize(config, options));
}

return rc;
45 changes: 45 additions & 0 deletions src/dotnet/HQ.CLI/TypeRegistrar.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console.Cli;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace HQ.CLI
{
public sealed class TypeRegistrar : ITypeRegistrar
{
private readonly IServiceCollection _builder;

public TypeRegistrar(IServiceCollection builder)
{
_builder = builder;
}

public ITypeResolver Build()
{
return new TypeResolver(_builder.BuildServiceProvider());
}

public void Register(Type service, Type implementation)
{
_builder.AddSingleton(service, implementation);
}

public void RegisterInstance(Type service, object implementation)
{
_builder.AddSingleton(service, implementation);
}

public void RegisterLazy(Type service, Func<object> func)
{
if (func is null)
{
throw new ArgumentNullException(nameof(func));
}

_builder.AddSingleton(service, (provider) => func());
}
}
}
38 changes: 38 additions & 0 deletions src/dotnet/HQ.CLI/TypeResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console.Cli;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace HQ.CLI
{
public sealed class TypeResolver : ITypeResolver, IDisposable
{
private readonly IServiceProvider _provider;

public TypeResolver(IServiceProvider provider)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
}

public object? Resolve(Type? type)
{
if (type == null)
{
return null;
}

return _provider.GetService(type);
}

public void Dispose()
{
if (_provider is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}
Loading

0 comments on commit 4795aec

Please sign in to comment.