mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
Esiur CLI
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# CLI repository analysis
|
||||
|
||||
This report records the analysis performed before the cross-platform CLI foundation was implemented.
|
||||
|
||||
## Reusable Esiur.Net APIs
|
||||
|
||||
- `Warehouse.Get<EpConnection>(endpoint, EpConnectionContext)` parses `ep://` URLs, creates the protocol store, opens the connection, and performs authentication.
|
||||
- `EpConnectionContext` selects authentication mode, provider protocol, identity, domain, reconnect behavior, and authentication timeout.
|
||||
- `PasswordAuthenticationProvider` and its handler implement the current `password-sha3-v1` exchange. The provider obtains client credentials through `GetSelfCredential` or `GetSelfIdentityAndCredential`. The exchange does not expose a reusable login token.
|
||||
- `EpConnection.Get(path)` resolves and attaches one stable resource path. `EpConnection.Query(path)` queries children, and `EpConnection.GetLinkDefinitions(path)` obtains reachable remote definitions.
|
||||
- `EpResource` contains the peer's session-local resource ID and stable link, its attached property snapshot, and the remote definition installed on `Instance.Definition`.
|
||||
- `TypeDef`, `PropertyDef`, `FunctionDef`, `EventDef`, `ConstantDef`, and `ArgumentDef` expose names, member indexes, inheritance, annotations, flags, and TRU type descriptors.
|
||||
- `EpResource.TryGetPropertyValue(index, out value)` reads the attached property cache without generated stubs. Dynamic invocation and subscriptions are available through `_Invoke`, `_InvokeStream`, `Subscribe`, and `Unsubscribe` for later phases.
|
||||
- `Warehouse.Close` and `EpConnection.Destroy` provide deterministic one-shot cleanup.
|
||||
|
||||
Esiur.Net currently represents type identity as an unsigned 64-bit ID, not `System.Guid`. The CLI renders this losslessly as a 16-digit hexadecimal value.
|
||||
|
||||
## Existing C# generators
|
||||
|
||||
Two generators have distinct roles:
|
||||
|
||||
- `Libraries/Esiur/Proxy/ResourceGenerator.cs` is the incremental compile-time source generator for locally attributed resources.
|
||||
- `Libraries/Esiur/Proxy/TypeDefGenerator.cs` consumes remote definitions and emits C# proxies, records, enums, and registration code.
|
||||
|
||||
`TypeDefGenerator` is the later CLI generation reference. It emits `EpResource` subclasses, preserves member indexes, attaches remote instances using connection/instance ID/age/link constructors, maps TRUs including nullable and composite types, carries annotations and remote names, handles inheritance, and emits record, enum, static-call, event, and streaming shapes. It should be refactored behind the future `ICodeGenerator` abstraction rather than replaced.
|
||||
|
||||
## Relevant esiur-ts APIs
|
||||
|
||||
The TypeScript v3 runtime already provides:
|
||||
|
||||
- `EpConnection.get`, resource attachment, indexed `invoke` and `set`, remote TypeDef fetching, and property/event notification handling.
|
||||
- `EpResource` with a property cache, type definition, dynamic proxy access, and property/event notification sources.
|
||||
- `RemoteTypeDef` decoding with remote property, function, event, constant, flag, annotation, and TRU metadata.
|
||||
- resource decorators and `TypeDef` templates, warehouse type registration, record classes, enum wrappers, and list/map/nullable TRU descriptors.
|
||||
- `bigint`-capable integer codecs and typed wire descriptors needed to avoid mapping 64-bit integers to JavaScript `number`.
|
||||
|
||||
Generated TypeScript stubs still need a public generated-resource base or factory, stable static type metadata and UUID/ID registration, typed indexed property/function/event wrappers, an inheritance metadata convention, and a documented generated record/enum registration shape. Those changes belong to the TypeScript generation phase and were not made here.
|
||||
|
||||
## Browsing gaps and changes
|
||||
|
||||
The protocol already supports the first-phase operations, so the CLI does not parse EP packets. `AsyncReply` has no `CancellationToken` overload; the CLI adapts it to a task and disposes the connection when cancellation or timeout ends the operation. Native cancellation-aware library overloads remain a useful future addition.
|
||||
|
||||
Testing exposed one reusable browsing defect: `MemoryStore.Children` returned no children for a root store and returned all descendants for a non-root resource. It now returns true direct children, including at the store root, and honors the optional child name filter. This makes remote `query` traversal consistent and lets recursion remain an application-level policy.
|
||||
|
||||
The protocol query reply does not carry child counts, so the CLI obtains each displayed direct child count with an additional query. A richer batched browsing API may be worthwhile for high-latency servers.
|
||||
|
||||
## CLI structure
|
||||
|
||||
- `Configuration`: JSON configuration, named profiles, endpoint validation, duration parsing, and precedence resolution.
|
||||
- `Authentication`: a replaceable credential service. The default implementation prompts or reads explicit standard input and persists nothing.
|
||||
- `Client`: session creation/disposal and reusable resource inspection services.
|
||||
- `Rendering`: table, JSON, JSON Lines, raw output, TRU formatting, and safe value normalization.
|
||||
- `CliApplication`: parsing and dispatch only; protocol operations remain in services shared by future shell commands.
|
||||
- `Tests/Esiur.CLI.Tests`: unit tests plus an in-process Esiur server integration test.
|
||||
|
||||
## Compatibility risks
|
||||
|
||||
- Password profiles require another prompt because the current authentication provider does not return a reusable token and the foundation intentionally adds no platform-specific secret-store dependency.
|
||||
- Recursive queries require multiple network round trips; child counts add another query per result.
|
||||
- Session IDs are peer-local and cannot be reused by another CLI process.
|
||||
- Type identity is currently `ulong`; output consumers must not assume a GUID string.
|
||||
- Trimming and NativeAOT remain disabled because dynamic resource attachment, reflection, and serialization have not been validated under those deployment modes.
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text;
|
||||
using Esiur.CLI.Configuration;
|
||||
|
||||
namespace Esiur.CLI.Authentication;
|
||||
|
||||
public interface ICredentialService
|
||||
{
|
||||
ValueTask<byte[]?> GetPasswordAsync(
|
||||
ConnectionProfile profile,
|
||||
bool readStandardInput,
|
||||
TextReader input,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask RemoveAsync(string profileName, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Password credentials are deliberately ephemeral. A platform secret-store implementation
|
||||
/// can replace this service later without changing profile or connection code.
|
||||
/// </summary>
|
||||
public sealed class PromptCredentialService : ICredentialService
|
||||
{
|
||||
public async ValueTask<byte[]?> GetPasswordAsync(
|
||||
ConnectionProfile profile,
|
||||
bool readStandardInput,
|
||||
TextReader input,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPasswordProvider(profile.Provider)) return null;
|
||||
|
||||
string password;
|
||||
if (readStandardInput || Console.IsInputRedirected)
|
||||
{
|
||||
password = (await input.ReadToEndAsync(cancellationToken)).TrimEnd('\r', '\n');
|
||||
}
|
||||
else
|
||||
{
|
||||
await error.WriteAsync("Password: ");
|
||||
var value = new StringBuilder();
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Enter) break;
|
||||
if (key.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (value.Length > 0) value.Length--;
|
||||
continue;
|
||||
}
|
||||
if (!char.IsControl(key.KeyChar)) value.Append(key.KeyChar);
|
||||
}
|
||||
await error.WriteLineAsync();
|
||||
password = value.ToString();
|
||||
value.Clear();
|
||||
}
|
||||
|
||||
if (password.Length == 0)
|
||||
throw new CliException("A password is required.", ExitCodes.AuthenticationFailed);
|
||||
return Encoding.UTF8.GetBytes(password);
|
||||
}
|
||||
|
||||
public ValueTask RemoveAsync(string profileName, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public static bool IsPasswordProvider(string? provider) =>
|
||||
string.Equals(provider, "password", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(provider, "password-sha3-v1", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using System.Diagnostics;
|
||||
using Esiur.CLI.Authentication;
|
||||
using Esiur.CLI.Client;
|
||||
using Esiur.CLI.Configuration;
|
||||
using Esiur.CLI.Rendering;
|
||||
|
||||
namespace Esiur.CLI;
|
||||
|
||||
public sealed class CliApplication
|
||||
{
|
||||
readonly IConfigurationStore configurationStore;
|
||||
readonly ICredentialService credentials;
|
||||
readonly IEsiurSessionFactory sessions;
|
||||
readonly ResourceInspectionService resources;
|
||||
readonly TextReader input;
|
||||
readonly TextWriter output;
|
||||
readonly TextWriter error;
|
||||
|
||||
public CliApplication(
|
||||
IConfigurationStore configurationStore,
|
||||
ICredentialService credentials,
|
||||
IEsiurSessionFactory sessions,
|
||||
ResourceInspectionService resources,
|
||||
TextReader input,
|
||||
TextWriter output,
|
||||
TextWriter error)
|
||||
{
|
||||
this.configurationStore = configurationStore;
|
||||
this.credentials = credentials;
|
||||
this.sessions = sessions;
|
||||
this.resources = resources;
|
||||
this.input = input;
|
||||
this.output = output;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public static async Task<int> RunAsync(
|
||||
string[] arguments, TextReader input, TextWriter output, TextWriter error)
|
||||
{
|
||||
var credentials = new PromptCredentialService();
|
||||
var app = new CliApplication(
|
||||
new ConfigurationStore(), credentials, new EsiurSessionFactory(credentials),
|
||||
new ResourceInspectionService(), input, output, error);
|
||||
return await app.RunAsync(arguments, CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(string[] arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
ConsoleCancelEventHandler? handler = null;
|
||||
if (!Console.IsInputRedirected || !Console.IsOutputRedirected)
|
||||
{
|
||||
handler = (_, eventArgs) => { eventArgs.Cancel = true; cancellation.Cancel(); };
|
||||
Console.CancelKeyPress += handler;
|
||||
}
|
||||
|
||||
GlobalOptions? global = null;
|
||||
try
|
||||
{
|
||||
var tokens = arguments.ToList();
|
||||
global = ParseGlobalOptions(tokens);
|
||||
if (tokens.Count == 0 || tokens[0] is "help" or "--help" or "-h")
|
||||
{
|
||||
await PrintHelpAsync();
|
||||
return tokens.Count == 0 ? ExitCodes.InvalidArguments : ExitCodes.Success;
|
||||
}
|
||||
|
||||
var command = Take(tokens).ToLowerInvariant();
|
||||
var configuration = await configurationStore.LoadAsync(cancellation.Token);
|
||||
return command switch
|
||||
{
|
||||
"version" => await VersionAsync(tokens),
|
||||
"login" => await LoginAsync(tokens, configuration, global, cancellation.Token),
|
||||
"logout" => await LogoutAsync(tokens, configuration, global, cancellation.Token),
|
||||
"profile" => await ProfileAsync(tokens, configuration, global, cancellation.Token),
|
||||
"query" => await QueryAsync(tokens, configuration, global, cancellation.Token),
|
||||
"describe" => await DescribeAsync(tokens, configuration, global, cancellation.Token),
|
||||
"get" => await GetAsync(tokens, configuration, global, cancellation.Token),
|
||||
_ => throw new CliException($"Unknown command \"{command}\".", ExitCodes.InvalidArguments),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await error.WriteLineAsync("Error: Operation cancelled.");
|
||||
return ExitCodes.Cancelled;
|
||||
}
|
||||
catch (CliException exception)
|
||||
{
|
||||
await error.WriteLineAsync($"Error: {RedactSecrets(exception.Message)}");
|
||||
if (global?.Debug == true && exception.InnerException is not null)
|
||||
await error.WriteLineAsync(RedactSecrets(exception.InnerException.ToString()));
|
||||
return exception.ExitCode;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await error.WriteLineAsync($"Error: {RedactSecrets(exception.Message)}");
|
||||
if (global?.Debug == true) await error.WriteLineAsync(RedactSecrets(exception.ToString()));
|
||||
return ExitCodes.GeneralFailure;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handler is not null) Console.CancelKeyPress -= handler;
|
||||
}
|
||||
}
|
||||
|
||||
async Task<int> VersionAsync(List<string> tokens)
|
||||
{
|
||||
EnsureEmpty(tokens);
|
||||
var assembly = typeof(CliApplication).Assembly.GetName();
|
||||
await output.WriteLineAsync($"Esiur CLI {assembly.Version?.ToString(3) ?? "3.0.0"}");
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
async Task<int> LoginAsync(
|
||||
List<string> tokens, CliConfiguration configuration, GlobalOptions global,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokens.Count < 2)
|
||||
throw new CliException("Usage: esiur login <name> <ep://endpoint> [--provider password] [--identity <name>] [--password-stdin]", ExitCodes.InvalidArguments);
|
||||
var name = Take(tokens);
|
||||
var endpoint = Take(tokens);
|
||||
var passwordStdin = TakeFlag(tokens, "--password-stdin");
|
||||
var domain = TakeOption(tokens, "--domain");
|
||||
EnsureEmpty(tokens);
|
||||
ConfigurationResolver.ValidateEndpoint(endpoint);
|
||||
var provider = global.Provider ?? (string.IsNullOrWhiteSpace(global.Identity) ? null : "password");
|
||||
if (!string.IsNullOrWhiteSpace(provider) && !PromptCredentialService.IsPasswordProvider(provider))
|
||||
throw new CliException($"Authentication provider \"{provider}\" is not supported by this CLI build.", ExitCodes.InvalidArguments);
|
||||
var profile = new ConnectionProfile
|
||||
{
|
||||
Name = name,
|
||||
Endpoint = endpoint,
|
||||
Provider = provider,
|
||||
Identity = global.Identity,
|
||||
Domain = domain,
|
||||
OutputFormat = global.Output ?? configuration.OutputFormat,
|
||||
};
|
||||
var temporary = new CliConfiguration
|
||||
{
|
||||
DefaultProfile = name,
|
||||
OutputFormat = configuration.OutputFormat,
|
||||
Timeout = configuration.Timeout,
|
||||
Profiles = new Dictionary<string, ConnectionProfile>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[name] = profile,
|
||||
},
|
||||
};
|
||||
var resolved = ConfigurationResolver.Resolve(temporary, global with
|
||||
{
|
||||
Profile = name, Endpoint = null, Provider = null, Identity = null,
|
||||
});
|
||||
await using (await sessions.ConnectAsync(
|
||||
resolved, passwordStdin, input, error, cancellationToken)) { }
|
||||
|
||||
configuration.Profiles[name] = profile;
|
||||
configuration.DefaultProfile ??= name;
|
||||
await configurationStore.SaveAsync(configuration, cancellationToken);
|
||||
await output.WriteLineAsync($"Profile \"{name}\" saved. Credentials were not written to the profile file.");
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
async Task<int> LogoutAsync(
|
||||
List<string> tokens, CliConfiguration configuration, GlobalOptions global,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var name = tokens.Count > 0 ? Take(tokens) : global.Profile ?? configuration.DefaultProfile;
|
||||
EnsureEmpty(tokens);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new CliException("No profile is selected.", ExitCodes.InvalidArguments);
|
||||
if (!configuration.Profiles.ContainsKey(name))
|
||||
throw new CliException($"Profile \"{name}\" was not found.", ExitCodes.InvalidArguments);
|
||||
await credentials.RemoveAsync(name, cancellationToken);
|
||||
await output.WriteLineAsync($"Logged out of profile \"{name}\". The profile was retained.");
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
async Task<int> ProfileAsync(
|
||||
List<string> tokens, CliConfiguration configuration, GlobalOptions global,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokens.Count == 0)
|
||||
throw new CliException("Usage: esiur profile <list|show|use|remove>", ExitCodes.InvalidArguments);
|
||||
var operation = Take(tokens).ToLowerInvariant();
|
||||
var renderer = new OutputRenderer(output);
|
||||
var format = OutputFormatExtensions.Parse(global.Output ?? configuration.OutputFormat);
|
||||
switch (operation)
|
||||
{
|
||||
case "list":
|
||||
EnsureEmpty(tokens);
|
||||
var profiles = configuration.Profiles.Values.OrderBy(x => x.Name).Select(x => new
|
||||
{
|
||||
x.Name,
|
||||
x.Endpoint,
|
||||
x.Provider,
|
||||
x.Identity,
|
||||
Default = string.Equals(x.Name, configuration.DefaultProfile, StringComparison.OrdinalIgnoreCase),
|
||||
}).ToArray();
|
||||
await renderer.RenderAsync(profiles, format, cancellationToken);
|
||||
break;
|
||||
case "show":
|
||||
var shown = RequiredProfile(tokens, configuration);
|
||||
EnsureEmpty(tokens);
|
||||
await renderer.RenderAsync(shown, format, cancellationToken);
|
||||
break;
|
||||
case "use":
|
||||
var selected = RequiredProfile(tokens, configuration);
|
||||
EnsureEmpty(tokens);
|
||||
configuration.DefaultProfile = selected.Name;
|
||||
await configurationStore.SaveAsync(configuration, cancellationToken);
|
||||
await output.WriteLineAsync($"Default profile set to \"{selected.Name}\".");
|
||||
break;
|
||||
case "remove":
|
||||
var removed = RequiredProfile(tokens, configuration);
|
||||
EnsureEmpty(tokens);
|
||||
configuration.Profiles.Remove(removed.Name);
|
||||
if (string.Equals(configuration.DefaultProfile, removed.Name, StringComparison.OrdinalIgnoreCase))
|
||||
configuration.DefaultProfile = null;
|
||||
await credentials.RemoveAsync(removed.Name, cancellationToken);
|
||||
await configurationStore.SaveAsync(configuration, cancellationToken);
|
||||
await output.WriteLineAsync($"Profile \"{removed.Name}\" removed.");
|
||||
break;
|
||||
default:
|
||||
throw new CliException($"Unknown profile command \"{operation}\".", ExitCodes.InvalidArguments);
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
async Task<int> QueryAsync(
|
||||
List<string> tokens, CliConfiguration configuration, GlobalOptions global,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokens.Count == 0) throw new CliException("Usage: esiur query <path> [--recursive|--depth <number>] [--type <name>]", ExitCodes.InvalidArguments);
|
||||
var path = Take(tokens);
|
||||
var recursive = TakeFlag(tokens, "--recursive");
|
||||
var depthText = TakeOption(tokens, "--depth");
|
||||
var type = TakeOption(tokens, "--type");
|
||||
EnsureEmpty(tokens);
|
||||
var depth = recursive ? int.MaxValue : depthText is null ? 1
|
||||
: int.TryParse(depthText, out var parsed) ? parsed
|
||||
: throw new CliException($"Depth \"{depthText}\" is invalid.", ExitCodes.InvalidArguments);
|
||||
var settings = ConfigurationResolver.Resolve(configuration, global);
|
||||
using var timeout = CreateTimeout(settings.Timeout, cancellationToken);
|
||||
try
|
||||
{
|
||||
await using var session = await sessions.ConnectAsync(settings, false, input, error, timeout.Token);
|
||||
var result = await resources.QueryAsync(session, path, depth, type, timeout.Token);
|
||||
await new OutputRenderer(output).RenderAsync(result, OutputFormatExtensions.Parse(settings.OutputFormat), timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new CliException("The operation timed out.", ExitCodes.Timeout);
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
async Task<int> DescribeAsync(
|
||||
List<string> tokens, CliConfiguration configuration, GlobalOptions global,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokens.Count == 0) throw new CliException("Usage: esiur describe <path> [--values|--schema-only]", ExitCodes.InvalidArguments);
|
||||
var path = Take(tokens);
|
||||
var values = TakeFlag(tokens, "--values");
|
||||
var schemaOnly = TakeFlag(tokens, "--schema-only");
|
||||
if (values && schemaOnly) throw new CliException("--values and --schema-only cannot be combined.", ExitCodes.InvalidArguments);
|
||||
EnsureEmpty(tokens);
|
||||
var settings = ConfigurationResolver.Resolve(configuration, global);
|
||||
using var timeout = CreateTimeout(settings.Timeout, cancellationToken);
|
||||
try
|
||||
{
|
||||
await using var session = await sessions.ConnectAsync(settings, false, input, error, timeout.Token);
|
||||
var result = await resources.DescribeAsync(session, path, values && !schemaOnly, timeout.Token);
|
||||
await new OutputRenderer(output).RenderAsync(result, OutputFormatExtensions.Parse(settings.OutputFormat), timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new CliException("The operation timed out.", ExitCodes.Timeout);
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
async Task<int> GetAsync(
|
||||
List<string> tokens, CliConfiguration configuration, GlobalOptions global,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokens.Count < 2) throw new CliException("Usage: esiur get <path> <property> [property...]", ExitCodes.InvalidArguments);
|
||||
var path = Take(tokens);
|
||||
var members = tokens.ToArray();
|
||||
tokens.Clear();
|
||||
var settings = ConfigurationResolver.Resolve(configuration, global);
|
||||
using var timeout = CreateTimeout(settings.Timeout, cancellationToken);
|
||||
try
|
||||
{
|
||||
await using var session = await sessions.ConnectAsync(settings, false, input, error, timeout.Token);
|
||||
var result = await resources.GetAsync(session, path, members, timeout.Token);
|
||||
var format = OutputFormatExtensions.Parse(settings.OutputFormat);
|
||||
await new OutputRenderer(output).RenderAsync(result, format, timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new CliException("The operation timed out.", ExitCodes.Timeout);
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
static CancellationTokenSource CreateTimeout(TimeSpan duration, CancellationToken parent)
|
||||
{
|
||||
var source = CancellationTokenSource.CreateLinkedTokenSource(parent);
|
||||
if (duration > TimeSpan.Zero) source.CancelAfter(duration);
|
||||
return source;
|
||||
}
|
||||
|
||||
static ConnectionProfile RequiredProfile(List<string> tokens, CliConfiguration configuration)
|
||||
{
|
||||
if (tokens.Count == 0) throw new CliException("A profile name is required.", ExitCodes.InvalidArguments);
|
||||
var name = Take(tokens);
|
||||
return configuration.Profiles.TryGetValue(name, out var profile) ? profile
|
||||
: throw new CliException($"Profile \"{name}\" was not found.", ExitCodes.InvalidArguments);
|
||||
}
|
||||
|
||||
static GlobalOptions ParseGlobalOptions(List<string> tokens)
|
||||
{
|
||||
var profile = TakeOption(tokens, "--profile");
|
||||
var endpoint = TakeOption(tokens, "--endpoint");
|
||||
var provider = TakeOption(tokens, "--provider");
|
||||
var identity = TakeOption(tokens, "--identity");
|
||||
var output = TakeOption(tokens, "--output");
|
||||
var timeoutText = TakeOption(tokens, "--timeout");
|
||||
return new GlobalOptions(profile, endpoint, provider, identity, output,
|
||||
timeoutText is null ? null : DurationParser.Parse(timeoutText),
|
||||
TakeFlag(tokens, "--verbose"), TakeFlag(tokens, "--debug"));
|
||||
}
|
||||
|
||||
public static string? TakeOption(List<string> tokens, string name)
|
||||
{
|
||||
for (var index = 0; index < tokens.Count; index++)
|
||||
{
|
||||
if (tokens[index].StartsWith(name + "=", StringComparison.Ordinal))
|
||||
{
|
||||
var value = tokens[index][(name.Length + 1)..];
|
||||
tokens.RemoveAt(index);
|
||||
return value;
|
||||
}
|
||||
if (tokens[index] != name) continue;
|
||||
if (index + 1 >= tokens.Count)
|
||||
throw new CliException($"Option {name} requires a value.", ExitCodes.InvalidArguments);
|
||||
var result = tokens[index + 1];
|
||||
tokens.RemoveRange(index, 2);
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool TakeFlag(List<string> tokens, string name)
|
||||
{
|
||||
var index = tokens.IndexOf(name);
|
||||
if (index < 0) return false;
|
||||
tokens.RemoveAt(index);
|
||||
return true;
|
||||
}
|
||||
|
||||
static string Take(List<string> tokens)
|
||||
{
|
||||
var value = tokens[0];
|
||||
tokens.RemoveAt(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
static void EnsureEmpty(List<string> tokens)
|
||||
{
|
||||
if (tokens.Count > 0)
|
||||
throw new CliException($"Unexpected argument \"{tokens[0]}\".", ExitCodes.InvalidArguments);
|
||||
}
|
||||
|
||||
public static string RedactSecrets(string value) => System.Text.RegularExpressions.Regex.Replace(
|
||||
value, "(?i)(password|token)(\\s*[:=]\\s*)[^\\s,;]+", "$1$2***");
|
||||
|
||||
async Task PrintHelpAsync() => await output.WriteLineAsync(
|
||||
"""
|
||||
Usage: esiur [global options] <command> [arguments]
|
||||
|
||||
Commands:
|
||||
version
|
||||
login <name> <ep://endpoint> [--provider password] [--identity <name>] [--password-stdin]
|
||||
logout [name]
|
||||
profile list|show|use|remove [name]
|
||||
query <path> [--recursive|--depth <number>] [--type <name>]
|
||||
describe <path> [--values|--schema-only]
|
||||
get <path> <property> [property...]
|
||||
|
||||
Global options:
|
||||
--profile <name> Use a saved profile
|
||||
--endpoint <ep://...> Temporarily override the endpoint
|
||||
--output <format> table, json, jsonl, or raw
|
||||
--timeout <duration> For example 30s or 2m
|
||||
--verbose Show diagnostics
|
||||
--debug Show exception details
|
||||
""");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Esiur.CLI;
|
||||
|
||||
public sealed class CliException : Exception
|
||||
{
|
||||
public CliException(string message, int exitCode = ExitCodes.GeneralFailure, Exception? inner = null)
|
||||
: base(message, inner) => ExitCode = exitCode;
|
||||
|
||||
public int ExitCode { get; }
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Esiur.CLI.Authentication;
|
||||
using Esiur.CLI.Configuration;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
|
||||
namespace Esiur.CLI.Client;
|
||||
|
||||
public interface IEsiurSessionFactory
|
||||
{
|
||||
Task<EsiurSession> ConnectAsync(
|
||||
ResolvedConnection settings,
|
||||
bool passwordFromStandardInput,
|
||||
TextReader input,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class EsiurSessionFactory(ICredentialService credentials) : IEsiurSessionFactory
|
||||
{
|
||||
public async Task<EsiurSession> ConnectAsync(
|
||||
ResolvedConnection settings,
|
||||
bool passwordFromStandardInput,
|
||||
TextReader input,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
byte[]? password = null;
|
||||
try
|
||||
{
|
||||
var profile = settings.Profile ?? new ConnectionProfile
|
||||
{
|
||||
Name = settings.DisplayName,
|
||||
Endpoint = settings.Endpoint,
|
||||
Provider = settings.Provider,
|
||||
Identity = settings.Identity,
|
||||
Domain = settings.Domain ?? string.Empty,
|
||||
};
|
||||
var context = new EpConnectionContext
|
||||
{
|
||||
AutoReconnect = false,
|
||||
AuthenticationTimeout = settings.Timeout,
|
||||
Domain = settings.Domain ?? string.Empty,
|
||||
};
|
||||
|
||||
if (PromptCredentialService.IsPasswordProvider(settings.Provider))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.Identity))
|
||||
throw new CliException("Password authentication requires an identity.", ExitCodes.InvalidArguments);
|
||||
password = await credentials.GetPasswordAsync(
|
||||
profile, passwordFromStandardInput, input, error, cancellationToken);
|
||||
warehouse.RegisterAuthenticationProvider(
|
||||
new PasswordClientProvider(settings.Identity, password!));
|
||||
context.AuthenticationMode = AuthenticationMode.InitializerIdentity;
|
||||
context.AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName;
|
||||
context.Identity = settings.Identity;
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if (settings.Timeout > TimeSpan.Zero) timeout.CancelAfter(settings.Timeout);
|
||||
var endpoint = EndpointParser.ConnectionEndpoint(settings.Endpoint);
|
||||
var connectTask = AwaitReply(warehouse.Get<EpConnection>(endpoint, context), timeout.Token);
|
||||
var connection = await connectTask;
|
||||
if (connection is null)
|
||||
throw new CliException($"Could not connect to {endpoint}.", ExitCodes.ConnectionFailed);
|
||||
return new EsiurSession(warehouse, connection, settings);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try { await warehouse.Close(); } catch { }
|
||||
throw new CliException("The connection timed out.", ExitCodes.Timeout);
|
||||
}
|
||||
catch (CliException)
|
||||
{
|
||||
try { await warehouse.Close(); } catch { }
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
try { await warehouse.Close(); } catch { }
|
||||
var authentication = PromptCredentialService.IsPasswordProvider(settings.Provider)
|
||||
&& exception.Message.Contains("auth", StringComparison.OrdinalIgnoreCase);
|
||||
throw new CliException(
|
||||
authentication ? "Authentication failed." : $"Connection failed: {exception.Message}",
|
||||
authentication ? ExitCodes.AuthenticationFailed : ExitCodes.ConnectionFailed,
|
||||
exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (password is not null) Array.Clear(password, 0, password.Length);
|
||||
}
|
||||
}
|
||||
|
||||
internal static Task<T> AwaitReply<T>(Esiur.Core.AsyncReply<T> reply, CancellationToken cancellationToken)
|
||||
{
|
||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
reply.Then(value => completion.TrySetResult((T)value))
|
||||
.Error(exception => completion.TrySetException(exception));
|
||||
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
|
||||
_ = completion.Task.ContinueWith(_ => registration.Dispose(), TaskScheduler.Default);
|
||||
return completion.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EsiurSession : IAsyncDisposable
|
||||
{
|
||||
internal EsiurSession(Warehouse warehouse, EpConnection connection, ResolvedConnection settings)
|
||||
{
|
||||
Warehouse = warehouse;
|
||||
Connection = connection;
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
public Warehouse Warehouse { get; }
|
||||
public EpConnection Connection { get; }
|
||||
public ResolvedConnection Settings { get; }
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { Connection.Destroy(); } catch { }
|
||||
try { await Warehouse.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public static class EndpointParser
|
||||
{
|
||||
public static string ConnectionEndpoint(string endpoint)
|
||||
{
|
||||
ConfigurationResolver.ValidateEndpoint(endpoint);
|
||||
var uri = new Uri(endpoint);
|
||||
return $"ep://{uri.Authority}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Esiur.Core;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
|
||||
namespace Esiur.CLI.Client;
|
||||
|
||||
internal sealed class PasswordClientProvider(string identity, byte[] password) : PasswordAuthenticationProvider
|
||||
{
|
||||
public override byte[] GetSelfCredential(string requestedIdentity, string domain, string hostname) =>
|
||||
string.Equals(requestedIdentity, identity, StringComparison.Ordinal) ? password : null!;
|
||||
|
||||
public override IdentityPassword GetSelfIdentityAndCredential(string domain, string hostname) =>
|
||||
new() { Identity = identity, Password = password };
|
||||
|
||||
public override AsyncReply<bool> Login(Session session) => new(true);
|
||||
public override AsyncReply<bool> Logout(Session session) => new(true);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using Esiur.CLI.Rendering;
|
||||
using Esiur.Data.Types;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.CLI.Client;
|
||||
|
||||
public sealed record ResourceSummary(
|
||||
string Name,
|
||||
string Path,
|
||||
uint SessionId,
|
||||
string Type,
|
||||
string TypeId,
|
||||
int ChildCount);
|
||||
|
||||
public sealed record PropertyDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string Type,
|
||||
bool ReadOnly,
|
||||
bool Constant,
|
||||
bool Historical,
|
||||
bool Inherited,
|
||||
object? Value,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record FunctionDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string Arguments,
|
||||
string ReturnType,
|
||||
string Flags,
|
||||
bool Inherited,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record EventDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string ArgumentType,
|
||||
bool Subscribable,
|
||||
bool Inherited,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record ConstantDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string Type,
|
||||
object? Value,
|
||||
bool Inherited,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record ResourceDescription(
|
||||
string Path,
|
||||
string Type,
|
||||
string TypeId,
|
||||
uint SessionId,
|
||||
ulong Age,
|
||||
string? ParentTypeId,
|
||||
IReadOnlyDictionary<string, string> Annotations,
|
||||
IReadOnlyList<PropertyDescription> Properties,
|
||||
IReadOnlyList<FunctionDescription> Functions,
|
||||
IReadOnlyList<EventDescription> Events,
|
||||
IReadOnlyList<ConstantDescription> Constants);
|
||||
|
||||
public sealed record PropertyResult(string Resource, string Property, byte Index, object? Value);
|
||||
|
||||
public sealed class ResourceInspectionService
|
||||
{
|
||||
public async Task<IReadOnlyList<ResourceSummary>> QueryAsync(
|
||||
EsiurSession session,
|
||||
string path,
|
||||
int depth,
|
||||
string? typeFilter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (depth < 1) throw new CliException("Query depth must be at least 1.", ExitCodes.InvalidArguments);
|
||||
var output = new List<ResourceSummary>();
|
||||
var queue = new Queue<(string Path, int Level)>();
|
||||
var seen = new HashSet<uint>();
|
||||
queue.Enqueue((NormalizePath(path), 1));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var current = queue.Dequeue();
|
||||
IResource[] children;
|
||||
try
|
||||
{
|
||||
children = await EsiurSessionFactory.AwaitReply(
|
||||
session.Connection.Query(current.Path), cancellationToken) ?? [];
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw MapResourceException(current.Path, exception);
|
||||
}
|
||||
|
||||
foreach (var child in children.OfType<EpResource>())
|
||||
{
|
||||
if (!seen.Add(child.ResourceInstanceId)) continue;
|
||||
var childPath = NormalizePath(child.ResourceLink ?? child.Instance.Link);
|
||||
var definition = child.Instance.Definition;
|
||||
var childCount = 0;
|
||||
try
|
||||
{
|
||||
var descendants = await EsiurSessionFactory.AwaitReply(
|
||||
session.Connection.Query(childPath), cancellationToken);
|
||||
childCount = descendants?.Length ?? 0;
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(typeFilter)
|
||||
|| definition.Name.Contains(typeFilter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
output.Add(new ResourceSummary(
|
||||
child.Instance.Name,
|
||||
childPath,
|
||||
child.ResourceInstanceId,
|
||||
definition.Name,
|
||||
TypeIdFormatter.Format(definition.Id),
|
||||
childCount));
|
||||
}
|
||||
|
||||
if (current.Level < depth)
|
||||
queue.Enqueue((childPath, current.Level + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async Task<ResourceDescription> DescribeAsync(
|
||||
EsiurSession session,
|
||||
string path,
|
||||
bool includeValues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resource = await ResolveAsync(session, path, cancellationToken);
|
||||
var definition = resource.Instance.Definition;
|
||||
return new ResourceDescription(
|
||||
NormalizePath(resource.ResourceLink ?? path),
|
||||
definition.Name,
|
||||
TypeIdFormatter.Format(definition.Id),
|
||||
resource.ResourceInstanceId,
|
||||
resource.Instance.Age,
|
||||
definition.ParentTypeId is ulong parent ? TypeIdFormatter.Format(parent) : null,
|
||||
ToDictionary(definition.Annotations),
|
||||
definition.Properties.OrderBy(x => x.Index).Select(property =>
|
||||
new PropertyDescription(
|
||||
property.Name,
|
||||
property.Index,
|
||||
TypeFormatter.Format(property.ValueType),
|
||||
property.ReadOnly,
|
||||
property.Constant,
|
||||
property.Historical,
|
||||
property.Inherited,
|
||||
includeValues && resource.TryGetPropertyValue(property.Index, out var value) ? value : null,
|
||||
ToDictionary(property.Annotations))).ToArray(),
|
||||
definition.Functions.OrderBy(x => x.Index).Select(function =>
|
||||
new FunctionDescription(
|
||||
function.Name,
|
||||
function.Index,
|
||||
string.Join(", ", (function.Arguments ?? []).Select(argument =>
|
||||
$"{argument.Name}: {TypeFormatter.Format(argument.Type)}{(argument.Optional ? " = optional" : "")}")),
|
||||
TypeFormatter.Format(function.ReturnType),
|
||||
FunctionFlags(function),
|
||||
function.Inherited,
|
||||
ToDictionary(function.Annotations))).ToArray(),
|
||||
definition.Events.OrderBy(x => x.Index).Select(@event =>
|
||||
new EventDescription(
|
||||
@event.Name,
|
||||
@event.Index,
|
||||
TypeFormatter.Format(@event.ArgumentType),
|
||||
@event.Subscribable,
|
||||
@event.Inherited,
|
||||
ToDictionary(@event.Annotations))).ToArray(),
|
||||
definition.Constants.OrderBy(x => x.Index).Select(constant =>
|
||||
new ConstantDescription(
|
||||
constant.Name,
|
||||
constant.Index,
|
||||
TypeFormatter.Format(constant.ValueType),
|
||||
constant.Value,
|
||||
constant.Inherited,
|
||||
ToDictionary(constant.Annotations))).ToArray());
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PropertyResult>> GetAsync(
|
||||
EsiurSession session,
|
||||
string path,
|
||||
IReadOnlyList<string> members,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resource = await ResolveAsync(session, path, cancellationToken);
|
||||
var results = new List<PropertyResult>();
|
||||
foreach (var member in members)
|
||||
{
|
||||
var property = ResolveProperty(resource.Instance.Definition, member)
|
||||
?? throw new CliException(
|
||||
$"Property \"{member}\" was not found on \"{path}\".", ExitCodes.MemberNotFound);
|
||||
if (!resource.TryGetPropertyValue(property.Index, out var value))
|
||||
throw new CliException(
|
||||
$"Property \"{property.Name}\" has no attached value.", ExitCodes.GeneralFailure);
|
||||
results.Add(new PropertyResult(NormalizePath(resource.ResourceLink ?? path), property.Name, property.Index, value));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<EpResource> ResolveAsync(
|
||||
EsiurSession session, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resource = await EsiurSessionFactory.AwaitReply(
|
||||
session.Connection.Get(NormalizePath(path)), cancellationToken);
|
||||
return resource as EpResource
|
||||
?? throw new CliException($"Resource \"{path}\" was not found.", ExitCodes.ResourceNotFound);
|
||||
}
|
||||
catch (CliException) { throw; }
|
||||
catch (Exception exception) { throw MapResourceException(path, exception); }
|
||||
}
|
||||
|
||||
static PropertyDef? ResolveProperty(TypeDef definition, string member)
|
||||
{
|
||||
if (byte.TryParse(member, out var index)) return definition.GetPropertyDefByIndex(index);
|
||||
return definition.GetPropertyDefByName(member);
|
||||
}
|
||||
|
||||
static CliException MapResourceException(string path, Exception exception)
|
||||
{
|
||||
if (exception.Message.Contains("not found", StringComparison.OrdinalIgnoreCase))
|
||||
return new CliException($"Resource \"{path}\" was not found.", ExitCodes.ResourceNotFound, exception);
|
||||
if (exception.Message.Contains("allow", StringComparison.OrdinalIgnoreCase)
|
||||
|| exception.Message.Contains("permission", StringComparison.OrdinalIgnoreCase))
|
||||
return new CliException($"Access to resource \"{path}\" was denied.", ExitCodes.AccessDenied, exception);
|
||||
return new CliException($"Could not inspect resource \"{path}\": {exception.Message}", ExitCodes.GeneralFailure, exception);
|
||||
}
|
||||
|
||||
public static string NormalizePath(string path) => path.Trim().Trim('/');
|
||||
|
||||
static IReadOnlyDictionary<string, string> ToDictionary(Esiur.Data.Map<string, string>? map) =>
|
||||
map is null ? new Dictionary<string, string>() : map.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
static string FunctionFlags(FunctionDef function) => string.Join(", ", new[]
|
||||
{
|
||||
function.IsStatic ? "static" : null,
|
||||
function.ReadOnly ? "read-only" : null,
|
||||
function.Idempotent ? "idempotent" : null,
|
||||
function.Cancellable ? "cancellable" : null,
|
||||
function.StreamMode != StreamMode.None ? $"stream:{function.StreamMode}" : null,
|
||||
function.Pausable ? "pausable" : null,
|
||||
}.Where(x => x is not null));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<Copyright>Ahmed Kh. Zamil</Copyright>
|
||||
<PackageProjectUrl>http://www.esiur.com</PackageProjectUrl>
|
||||
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>3.0.0</Version>
|
||||
<Authors>Ahmed Kh. Zamil</Authors>
|
||||
<Company>Esiur Foundation</Company>
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Esiur.Cli</AssemblyName>
|
||||
<RootNamespace>Esiur.CLI</RootNamespace>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishAot>false</PublishAot>
|
||||
|
||||
<PackAsTool>true</PackAsTool>
|
||||
<ToolCommandName>esiur</ToolCommandName>
|
||||
@@ -26,10 +32,6 @@
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.9.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" />
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Esiur.CLI;
|
||||
|
||||
public static class ExitCodes
|
||||
{
|
||||
public const int Success = 0;
|
||||
public const int GeneralFailure = 1;
|
||||
public const int InvalidArguments = 2;
|
||||
public const int AuthenticationFailed = 3;
|
||||
public const int ConnectionFailed = 4;
|
||||
public const int ResourceNotFound = 5;
|
||||
public const int MemberNotFound = 6;
|
||||
public const int AccessDenied = 7;
|
||||
public const int InvalidValue = 8;
|
||||
public const int InvocationFailed = 9;
|
||||
public const int Timeout = 10;
|
||||
public const int Cancelled = 11;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Esiur Foundation, Ahmed Kh. Zamil.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
*/
|
||||
|
||||
using CommandLine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Esiur.CLI
|
||||
{
|
||||
internal class GetTemplateOptions
|
||||
{
|
||||
[Option('d', "dir", Required = false, HelpText = "Directory name where the generated models will be saved.")]
|
||||
public string? Dir { get; set; }
|
||||
|
||||
[Option('u', "username", Required = false, HelpText = "Authentication username.")]
|
||||
public string? Username { get; set; }
|
||||
|
||||
[Option('p', "password", Required = false, HelpText = "Authentication password.")]
|
||||
public string? Password { get; set; }
|
||||
|
||||
|
||||
[Option('a', "async-setters", Required = false, HelpText = "Use asynchronous property setters.")]
|
||||
public bool AsyncSetters { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,3 @@
|
||||
|
||||
/*
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Esiur Foundation, Ahmed Kh. Zamil.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
*/
|
||||
|
||||
using CommandLine;
|
||||
using Esiur.CLI;
|
||||
using Esiur.Data;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
||||
|
||||
if (args.Length > 0)
|
||||
{
|
||||
if (args[0].ToLower() == "get-template" && args.Length >= 2)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = args[1];
|
||||
|
||||
Parser.Default.ParseArguments<GetTemplateOptions>(args.Skip(2))
|
||||
.WithParsed<GetTemplateOptions>(o =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Esiur.Proxy.TypeDefGenerator.GetTypes(url, o.Dir, false, o.Username, o.Password, o.AsyncSetters);
|
||||
Console.WriteLine($"Generated successfully: {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (args[0].ToLower() == "version")
|
||||
{
|
||||
var version = FileVersionInfo.GetVersionInfo(typeof(Esiur.Core.AsyncReply).Assembly.Location).FileVersion;
|
||||
|
||||
Console.WriteLine("Esiur " + version);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
PrintHelp();
|
||||
|
||||
|
||||
static void PrintHelp()
|
||||
{
|
||||
var version = FileVersionInfo.GetVersionInfo(typeof(Esiur.Core.AsyncReply).Assembly.Location).FileVersion;
|
||||
|
||||
|
||||
Console.WriteLine("Usage: <command> [arguments]");
|
||||
Console.WriteLine("");
|
||||
Console.WriteLine("Available commands:");
|
||||
Console.WriteLine("\tget-template\tGet a template from an EP link.");
|
||||
Console.WriteLine("\tversion\t\tPrint Esiur version.");
|
||||
Console.WriteLine("");
|
||||
Console.WriteLine("Global options:");
|
||||
Console.WriteLine("\t-u, --username\t\tAuthentication username.");
|
||||
Console.WriteLine("\t-p, --password\t\tAuthentication password.");
|
||||
Console.WriteLine("\t-d, --dir\t\tDirectory name where the generated models will be saved.");
|
||||
Console.WriteLine("\t-a, --async-setters\tUse asynchronous property setters.");
|
||||
|
||||
}
|
||||
return await CliApplication.RunAsync(args, Console.In, Console.Out, Console.Error);
|
||||
|
||||
+112
-19
@@ -1,29 +1,122 @@
|
||||
# Esiur CLI
|
||||
|
||||
A command-line utility to generate
|
||||
`esiur` is the cross-platform command-line client for Esiur.Net. This first implementation supports profiles, authentication, resource browsing, schema inspection, and property reads. It runs directly on .NET and does not require Node.js, PowerShell, Bash, or Dart.
|
||||
|
||||
# Installation
|
||||
- Command-line
|
||||
``` dotnet tool install -g Esiur.CLI ```
|
||||
## Build and install
|
||||
|
||||
# Usage
|
||||
```
|
||||
Available commands:
|
||||
get-template Get a template from an EP link.
|
||||
version Print Esiur version.
|
||||
|
||||
Global options:
|
||||
-u, --username Authentication username.
|
||||
-p, --password Authentication password.
|
||||
-d, --dir Directory name where the generated models will be saved.
|
||||
-a, --async-setters Use asynchronous property setters.
|
||||
The project targets .NET 10:
|
||||
|
||||
```console
|
||||
dotnet build Tools/Esiur.CLI/Esiur.CLI.csproj
|
||||
dotnet pack Tools/Esiur.CLI/Esiur.CLI.csproj -c Release
|
||||
dotnet tool install --global --add-source Tools/Esiur.CLI/nupkg Esiur.Cli
|
||||
```
|
||||
|
||||
## Example
|
||||
The project is configured for self-contained, single-file, non-trimmed publishing. Platform release archives and installers are planned for the packaging phase.
|
||||
|
||||
```
|
||||
dotnet run esiur get-template ep://localhost/sys/service
|
||||
## Login and profiles
|
||||
|
||||
Create and verify a password-authenticated profile:
|
||||
|
||||
```console
|
||||
esiur login production ep://host --provider password --identity ahmed
|
||||
```
|
||||
|
||||
|
||||
The password prompt does not echo input. For automation, supply it on standard input:
|
||||
|
||||
```console
|
||||
printf '%s' "$ESIUR_PASSWORD" | esiur login production ep://host \
|
||||
--provider password --identity ahmed --password-stdin
|
||||
```
|
||||
|
||||
Esiur.Net's password provider does not currently issue reusable tokens. The CLI therefore stores endpoint and identity metadata, but never the plaintext password. A later command using that profile prompts again when authentication is required.
|
||||
|
||||
Manage profiles with:
|
||||
|
||||
```console
|
||||
esiur profile list
|
||||
esiur profile show production
|
||||
esiur profile use production
|
||||
esiur profile remove production
|
||||
esiur logout production
|
||||
```
|
||||
|
||||
`logout` removes credential material through the credential abstraction and retains the profile. With the default prompt-only implementation there is no persisted credential to remove.
|
||||
|
||||
Configuration is stored at `%APPDATA%\Esiur\config.json` on Windows and `${XDG_CONFIG_HOME:-~/.config}/esiur/config.json` on Linux.
|
||||
|
||||
## Browse resources
|
||||
|
||||
List direct children or recurse through a resource tree:
|
||||
|
||||
```console
|
||||
esiur query sys
|
||||
esiur query sys --depth 2
|
||||
esiur query sys --recursive
|
||||
esiur query sys --type Service --output json
|
||||
```
|
||||
|
||||
Results include the stable path, session-local ID, type name, type ID, and direct child count. Numeric IDs are informational in one-shot commands and are not stable between processes.
|
||||
|
||||
## Describe resources
|
||||
|
||||
Read the remote `TypeDef` schema:
|
||||
|
||||
```console
|
||||
esiur describe sys/service
|
||||
esiur describe sys/service --values
|
||||
esiur describe sys/service --output json
|
||||
```
|
||||
|
||||
Descriptions include resource identity and age, inheritance, annotations, properties, functions, events, and constants. `--values` includes the values attached with the resource; `--schema-only` explicitly suppresses them.
|
||||
|
||||
## Read properties
|
||||
|
||||
Properties can be selected by name or numeric member index:
|
||||
|
||||
```console
|
||||
esiur get sys/service Name
|
||||
esiur get sys/service Name Running Status
|
||||
esiur get sys/service 0 --output raw
|
||||
```
|
||||
|
||||
## Connection overrides and automation
|
||||
|
||||
Every operational command accepts a saved profile or a temporary endpoint:
|
||||
|
||||
```console
|
||||
esiur --profile production describe sys/service
|
||||
esiur --endpoint ep://host query sys --output json
|
||||
esiur get sys/service Name --timeout 30s
|
||||
```
|
||||
|
||||
Configuration precedence is explicit option, environment variable, selected profile, global configuration, then built-in default. Supported variables are `ESIUR_PROFILE`, `ESIUR_ENDPOINT`, `ESIUR_PROVIDER`, `ESIUR_IDENTITY`, `ESIUR_OUTPUT`, and `ESIUR_TIMEOUT`. There is intentionally no long-lived password environment variable.
|
||||
|
||||
Output formats are `table`, `json`, `jsonl`, and `raw`. Command results go to standard output; errors and password prompts go to standard error. JSON output contains no status text.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---:|---|
|
||||
| 0 | Success |
|
||||
| 1 | General failure |
|
||||
| 2 | Invalid command or arguments |
|
||||
| 3 | Authentication failed |
|
||||
| 4 | Connection failed |
|
||||
| 5 | Resource not found |
|
||||
| 6 | Member not found |
|
||||
| 7 | Access denied |
|
||||
| 8 | Invalid value |
|
||||
| 9 | Invocation failed |
|
||||
| 10 | Timeout |
|
||||
| 11 | Cancelled |
|
||||
|
||||
## Security and troubleshooting
|
||||
|
||||
- Passwords are read using a non-echoing prompt or explicit standard input and are not serialized into configuration.
|
||||
- Endpoint user-info such as `ep://user:password@host` is rejected.
|
||||
- Diagnostics redact values labelled as passwords or tokens. Use `--debug` for exception details; never paste debug output without reviewing it.
|
||||
- If a connection fails, verify the `ep://` host and port, the server's allowed authentication provider, identity, and domain.
|
||||
- `query` requires attach permission for the parent and returned children. `describe --values` requires attaching the target resource.
|
||||
|
||||
Interactive shell, mutation, invocation, subscriptions, code generation, and release packaging are intentionally deferred to their focused implementation phases.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Esiur.CLI;
|
||||
|
||||
public enum OutputFormat { Table, Json, JsonLines, Raw }
|
||||
|
||||
public static class OutputFormatExtensions
|
||||
{
|
||||
public static OutputFormat Parse(string? value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"table" or null => OutputFormat.Table,
|
||||
"json" => OutputFormat.Json,
|
||||
"jsonl" => OutputFormat.JsonLines,
|
||||
"raw" => OutputFormat.Raw,
|
||||
_ => throw new CliException(
|
||||
$"Output format \"{value}\" is invalid. Expected table, json, jsonl, or raw.",
|
||||
ExitCodes.InvalidArguments),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.CLI.Rendering;
|
||||
|
||||
public sealed class OutputRenderer(TextWriter output)
|
||||
{
|
||||
static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public async Task RenderAsync(object? value, OutputFormat format, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
switch (format)
|
||||
{
|
||||
case OutputFormat.Json:
|
||||
await output.WriteLineAsync(JsonSerializer.Serialize(Normalize(value), JsonOptions));
|
||||
break;
|
||||
case OutputFormat.JsonLines:
|
||||
if (value is IEnumerable values and not string)
|
||||
foreach (var item in values)
|
||||
await output.WriteLineAsync(JsonSerializer.Serialize(Normalize(item), JsonOptions));
|
||||
else
|
||||
await output.WriteLineAsync(JsonSerializer.Serialize(Normalize(value), JsonOptions));
|
||||
break;
|
||||
case OutputFormat.Raw:
|
||||
await RenderRawAsync(value);
|
||||
break;
|
||||
default:
|
||||
await RenderTableAsync(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async Task RenderRawAsync(object? value)
|
||||
{
|
||||
if (value is IEnumerable values and not string and not byte[])
|
||||
{
|
||||
foreach (var item in values)
|
||||
{
|
||||
var scalar = item?.GetType().GetProperty("Value")?.GetValue(item) ?? item;
|
||||
await output.WriteLineAsync(Scalar(scalar));
|
||||
}
|
||||
}
|
||||
else await output.WriteLineAsync(Scalar(value));
|
||||
}
|
||||
|
||||
async Task RenderTableAsync(object? value)
|
||||
{
|
||||
if (value is Esiur.CLI.Client.ResourceDescription description)
|
||||
{
|
||||
await WriteKeyValuesAsync(new[]
|
||||
{
|
||||
("Path", (object?)description.Path), ("Type", description.Type),
|
||||
("Type ID", description.TypeId), ("Session ID", description.SessionId),
|
||||
("Age", description.Age), ("Parent Type ID", description.ParentTypeId),
|
||||
});
|
||||
await WriteSectionAsync("Properties", description.Properties);
|
||||
await WriteSectionAsync("Functions", description.Functions);
|
||||
await WriteSectionAsync("Events", description.Events);
|
||||
await WriteSectionAsync("Constants", description.Constants);
|
||||
if (description.Annotations.Count > 0)
|
||||
await WriteKeyValuesAsync(description.Annotations.Select(x => (x.Key, (object?)x.Value)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value is IEnumerable enumerable and not string)
|
||||
{
|
||||
var rows = enumerable.Cast<object?>().ToArray();
|
||||
await WriteObjectsAsync(rows);
|
||||
return;
|
||||
}
|
||||
if (value is not null)
|
||||
{
|
||||
var properties = value.GetType().GetProperties().Where(x => x.CanRead).ToArray();
|
||||
if (properties.Length > 0)
|
||||
{
|
||||
await WriteKeyValuesAsync(properties.Select(x => (Humanize(x.Name), x.GetValue(value))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await output.WriteLineAsync(Scalar(value));
|
||||
}
|
||||
|
||||
async Task WriteSectionAsync(string title, IEnumerable values)
|
||||
{
|
||||
var rows = values.Cast<object?>().ToArray();
|
||||
if (rows.Length == 0) return;
|
||||
await output.WriteLineAsync();
|
||||
await output.WriteLineAsync(title);
|
||||
await WriteObjectsAsync(rows);
|
||||
}
|
||||
|
||||
async Task WriteObjectsAsync(object?[] rows)
|
||||
{
|
||||
if (rows.Length == 0) return;
|
||||
var first = rows.FirstOrDefault(x => x is not null);
|
||||
if (first is null) return;
|
||||
var properties = first.GetType().GetProperties()
|
||||
.Where(property => property.CanRead
|
||||
&& !typeof(IEnumerable<KeyValuePair<string, string>>).IsAssignableFrom(property.PropertyType))
|
||||
.ToArray();
|
||||
if (properties.Length == 0)
|
||||
{
|
||||
foreach (var row in rows) await output.WriteLineAsync(Scalar(row));
|
||||
return;
|
||||
}
|
||||
|
||||
var cells = rows.Select(row => properties.Select(property =>
|
||||
Scalar(row is null ? null : property.GetValue(row))).ToArray()).ToArray();
|
||||
var widths = properties.Select((property, column) => Math.Min(80,
|
||||
cells.Select(row => row[column].Length).Append(Humanize(property.Name).Length).Max())).ToArray();
|
||||
await output.WriteLineAsync(string.Join(" ", properties.Select((property, i) =>
|
||||
Humanize(property.Name).PadRight(widths[i]))));
|
||||
await output.WriteLineAsync(string.Join(" ", widths.Select(width => new string('-', width))));
|
||||
foreach (var row in cells)
|
||||
await output.WriteLineAsync(string.Join(" ", row.Select((cell, i) =>
|
||||
Truncate(cell, widths[i]).PadRight(widths[i]))));
|
||||
}
|
||||
|
||||
async Task WriteKeyValuesAsync(IEnumerable<(string Key, object? Value)> values)
|
||||
{
|
||||
var rows = values.Where(x => x.Value is not null).ToArray();
|
||||
var width = rows.Length == 0 ? 0 : rows.Max(x => x.Key.Length);
|
||||
foreach (var row in rows)
|
||||
await output.WriteLineAsync($"{row.Key.PadRight(width)} {Scalar(row.Value)}");
|
||||
}
|
||||
|
||||
public static object? Normalize(object? value)
|
||||
{
|
||||
if (value is null || value is string || value.GetType().IsPrimitive
|
||||
|| value is decimal || value is DateTime || value is DateTimeOffset || value is Guid)
|
||||
return value;
|
||||
if (value is Enum) return value.ToString();
|
||||
if (value is byte[] bytes) return Convert.ToBase64String(bytes);
|
||||
if (value is IResource resource) return resource.Instance?.Link;
|
||||
if (value is IDictionary dictionary)
|
||||
{
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
result[Convert.ToString(entry.Key, CultureInfo.InvariantCulture) ?? string.Empty] = Normalize(entry.Value);
|
||||
return result;
|
||||
}
|
||||
if (value is IEnumerable enumerable)
|
||||
return enumerable.Cast<object?>().Select(Normalize).ToArray();
|
||||
var properties = value.GetType().GetProperties().Where(property => property.CanRead);
|
||||
return properties.ToDictionary(property => property.Name, property => Normalize(property.GetValue(value)));
|
||||
}
|
||||
|
||||
static string Scalar(object? value) => Normalize(value) switch
|
||||
{
|
||||
null => "null",
|
||||
bool boolean => boolean ? "true" : "false",
|
||||
DateTime date => date.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
DateTimeOffset date => date.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
string text => text,
|
||||
object normalized when normalized.GetType().IsPrimitive || normalized is decimal
|
||||
=> Convert.ToString(normalized, CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
object normalized => JsonSerializer.Serialize(normalized, JsonOptions),
|
||||
};
|
||||
|
||||
static string Humanize(string name) => string.Concat(name.Select((character, index) =>
|
||||
index > 0 && char.IsUpper(character) ? " " + character : character.ToString()));
|
||||
static string Truncate(string value, int width) => value.Length <= width ? value : value[..Math.Max(0, width - 1)] + "…";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Esiur.Data;
|
||||
|
||||
namespace Esiur.CLI.Rendering;
|
||||
|
||||
public static class TypeFormatter
|
||||
{
|
||||
public static string Format(Tru? type)
|
||||
{
|
||||
if (type is null) return "Dynamic";
|
||||
if (type is TruTypeDef reference && reference.TypeDef is not null)
|
||||
return reference.TypeDef.Name + (type.Nullable ? "?" : string.Empty);
|
||||
return type.ToString() ?? type.Identifier.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public static class TypeIdFormatter
|
||||
{
|
||||
public static string Format(ulong id) => $"0x{id:x16}";
|
||||
}
|
||||
Reference in New Issue
Block a user