Esiur CLI

This commit is contained in:
2026-07-18 00:44:14 +03:00
parent 3e8fb50110
commit ac36a3ddc7
36 changed files with 2396 additions and 304 deletions
@@ -0,0 +1,10 @@
namespace Esiur.CLI.Configuration;
public sealed class CliConfiguration
{
public string? DefaultProfile { get; set; }
public string OutputFormat { get; set; } = "table";
public string Timeout { get; set; } = "30s";
public Dictionary<string, ConnectionProfile> Profiles { get; set; }
= new(StringComparer.OrdinalIgnoreCase);
}
@@ -0,0 +1,96 @@
namespace Esiur.CLI.Configuration;
public sealed record GlobalOptions(
string? Profile,
string? Endpoint,
string? Provider,
string? Identity,
string? Output,
TimeSpan? Timeout,
bool Verbose,
bool Debug);
public sealed record ResolvedConnection(
string DisplayName,
string Endpoint,
string? Provider,
string? Identity,
string? Domain,
string OutputFormat,
TimeSpan Timeout,
ConnectionProfile? Profile);
public static class ConfigurationResolver
{
public static ResolvedConnection Resolve(CliConfiguration configuration, GlobalOptions options)
{
var profileName = options.Profile
?? Environment.GetEnvironmentVariable("ESIUR_PROFILE")
?? configuration.DefaultProfile;
ConnectionProfile? profile = null;
if (!string.IsNullOrWhiteSpace(profileName)
&& !configuration.Profiles.TryGetValue(profileName, out profile))
throw new CliException($"Profile \"{profileName}\" was not found.", ExitCodes.InvalidArguments);
var endpoint = options.Endpoint
?? Environment.GetEnvironmentVariable("ESIUR_ENDPOINT")
?? profile?.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint))
throw new CliException("No endpoint was supplied and no profile is selected.", ExitCodes.InvalidArguments);
ValidateEndpoint(endpoint);
var output = options.Output
?? Environment.GetEnvironmentVariable("ESIUR_OUTPUT")
?? profile?.OutputFormat
?? configuration.OutputFormat;
OutputFormatExtensions.Parse(output);
var timeout = options.Timeout
?? DurationParser.TryParse(Environment.GetEnvironmentVariable("ESIUR_TIMEOUT"))
?? DurationParser.Parse(configuration.Timeout);
return new ResolvedConnection(
profile?.Name ?? "endpoint",
endpoint,
options.Provider ?? Environment.GetEnvironmentVariable("ESIUR_PROVIDER") ?? profile?.Provider,
options.Identity ?? Environment.GetEnvironmentVariable("ESIUR_IDENTITY") ?? profile?.Identity,
profile?.Domain,
output,
timeout,
profile);
}
public static void ValidateEndpoint(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)
|| !string.Equals(uri.Scheme, "ep", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(uri.Host)
|| !string.IsNullOrEmpty(uri.UserInfo))
throw new CliException($"Endpoint \"{endpoint}\" is not a valid ep:// endpoint.", ExitCodes.InvalidArguments);
}
}
public static class DurationParser
{
public static TimeSpan Parse(string value) => TryParse(value)
?? throw new CliException($"Duration \"{value}\" is invalid. Use values such as 500ms, 30s, 5m, or 1h.", ExitCodes.InvalidArguments);
public static TimeSpan? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
value = value.Trim();
var suffixes = new (string Suffix, double Factor)[]
{
("ms", 1), ("s", 1_000), ("m", 60_000), ("h", 3_600_000),
};
foreach (var item in suffixes)
if (value.EndsWith(item.Suffix, StringComparison.OrdinalIgnoreCase)
&& double.TryParse(value[..^item.Suffix.Length],
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var number)
&& number >= 0)
return TimeSpan.FromMilliseconds(number * item.Factor);
return TimeSpan.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, out var parsed)
&& parsed >= TimeSpan.Zero ? parsed : null;
}
}
@@ -0,0 +1,60 @@
using System.Text.Json;
namespace Esiur.CLI.Configuration;
public interface IConfigurationStore
{
Task<CliConfiguration> LoadAsync(CancellationToken cancellationToken);
Task SaveAsync(CliConfiguration configuration, CancellationToken cancellationToken);
}
public sealed class ConfigurationStore : IConfigurationStore
{
static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
public ConfigurationStore(string? path = null) => Path = path ?? GetDefaultPath();
public string Path { get; }
public static string GetDefaultPath()
{
if (OperatingSystem.IsWindows())
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return System.IO.Path.Combine(appData, "Esiur", "config.json");
}
var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
if (string.IsNullOrWhiteSpace(configHome))
configHome = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
return System.IO.Path.Combine(configHome, "esiur", "config.json");
}
public async Task<CliConfiguration> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(Path))
return new CliConfiguration();
await using var stream = File.OpenRead(Path);
var value = await JsonSerializer.DeserializeAsync<CliConfiguration>(stream, JsonOptions, cancellationToken)
?? new CliConfiguration();
value.Profiles = new Dictionary<string, ConnectionProfile>(
value.Profiles ?? [], StringComparer.OrdinalIgnoreCase);
return value;
}
public async Task SaveAsync(CliConfiguration configuration, CancellationToken cancellationToken)
{
var directory = System.IO.Path.GetDirectoryName(Path)!;
Directory.CreateDirectory(directory);
var temporary = Path + ".tmp";
await using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
await JsonSerializer.SerializeAsync(stream, configuration, JsonOptions, cancellationToken);
File.Move(temporary, Path, true);
}
}
@@ -0,0 +1,12 @@
namespace Esiur.CLI.Configuration;
public sealed class ConnectionProfile
{
public required string Name { get; init; }
public required string Endpoint { get; init; }
public string? Provider { get; init; }
public string? Identity { get; init; }
public string? Domain { get; init; }
public string? DefaultResourcePath { get; init; }
public string OutputFormat { get; init; } = "table";
}