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
+6
View File
@@ -0,0 +1,6 @@
<Project>
<!-- Test and example executables are repository assets, not NuGet products. -->
<PropertyGroup Condition="$([System.String]::Copy('$(MSBuildProjectDirectory)').StartsWith('$(MSBuildThisFileDirectory)Tests')) Or $([System.String]::Copy('$(MSBuildProjectDirectory)').StartsWith('$(MSBuildThisFileDirectory)Examples'))">
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
+36
View File
@@ -119,6 +119,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Esiur.Tests.RPC.SignalRServ
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Esiur.Tests.RPC.ThriftServer", "Tests\RPC\Thrift\Esiur.Tests.RPC.ThriftServer.csproj", "{7ADA729C-6E99-B3D1-B16F-1D3DEEF11242}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Stores", "Stores", "{1A602930-A50D-602B-FE1D-41ECE2A490FD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EntityCore", "EntityCore", "{A756472F-B582-9566-FAF8-0AECEC8A1179}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Esiur.Tests.EntityCore", "Tests\Stores\EntityCore\Esiur.Tests.EntityCore.csproj", "{86A31D88-9534-44DC-AE35-DCE674547BA4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Esiur.CLI.Tests", "Tests\Esiur.CLI.Tests\Esiur.CLI.Tests.csproj", "{570DBADD-4430-4546-9765-79C86784798C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -489,6 +497,30 @@ Global
{7ADA729C-6E99-B3D1-B16F-1D3DEEF11242}.Release|x64.Build.0 = Release|Any CPU
{7ADA729C-6E99-B3D1-B16F-1D3DEEF11242}.Release|x86.ActiveCfg = Release|Any CPU
{7ADA729C-6E99-B3D1-B16F-1D3DEEF11242}.Release|x86.Build.0 = Release|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Debug|x64.ActiveCfg = Debug|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Debug|x64.Build.0 = Debug|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Debug|x86.ActiveCfg = Debug|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Debug|x86.Build.0 = Debug|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Release|Any CPU.Build.0 = Release|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Release|x64.ActiveCfg = Release|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Release|x64.Build.0 = Release|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Release|x86.ActiveCfg = Release|Any CPU
{86A31D88-9534-44DC-AE35-DCE674547BA4}.Release|x86.Build.0 = Release|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Debug|x64.ActiveCfg = Debug|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Debug|x64.Build.0 = Debug|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Debug|x86.ActiveCfg = Debug|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Debug|x86.Build.0 = Debug|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Release|Any CPU.Build.0 = Release|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Release|x64.ActiveCfg = Release|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Release|x64.Build.0 = Release|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Release|x86.ActiveCfg = Release|Any CPU
{570DBADD-4430-4546-9765-79C86784798C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -546,6 +578,10 @@ Global
{46ED42EA-E808-AEF4-4306-22368535580D} = {2023D47B-18D1-45F1-B7D2-CF53E868DA46}
{3CE2924C-1ED1-1949-D654-B8DCABBDF6FD} = {2023D47B-18D1-45F1-B7D2-CF53E868DA46}
{7ADA729C-6E99-B3D1-B16F-1D3DEEF11242} = {2023D47B-18D1-45F1-B7D2-CF53E868DA46}
{1A602930-A50D-602B-FE1D-41ECE2A490FD} = {2769C4C3-2595-413B-B7FE-5903826770C1}
{A756472F-B582-9566-FAF8-0AECEC8A1179} = {1A602930-A50D-602B-FE1D-41ECE2A490FD}
{86A31D88-9534-44DC-AE35-DCE674547BA4} = {A756472F-B582-9566-FAF8-0AECEC8A1179}
{570DBADD-4430-4546-9765-79C86784798C} = {2769C4C3-2595-413B-B7FE-5903826770C1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C584421D-5EC0-4821-B7D8-2633D8D405F2}
+12 -1
View File
@@ -1012,9 +1012,20 @@ public class Warehouse
var parent = await Get<IResource>(string.Join("/", location.Take(location.Length - 1)));
if (parent == null)
throw new Exception("Can't find parent");
{
// Stores may expose hierarchical links without materializing every
// intermediate path segment (for example, database/Type/42).
// In that case the root store still owns the complete relative path.
var root = await Get<IResource>(location[0]);
if (root is not IStore rootStore)
throw new Exception("Can't find parent or root store.");
store = rootStore;
}
else
{
store = parent.Instance.Store;// GetStore(location[0]);
}
//if (store == null)
// throw new Exception("Store not found.");
+17 -4
View File
@@ -119,12 +119,25 @@ public class MemoryStore : IStore
public AsyncBag<T> Children<T>(IResource resource, string name) where T : IResource
{
var parentLink = ReferenceEquals(resource, this)
? string.Empty
: (Link(resource) ?? string.Empty).Trim('/');
var prefix = parentLink.Length == 0 ? string.Empty : parentLink + "/";
var rt = new AsyncBag<T>();
var children = resources
.Where(entry => entry.Value is T)
.Select(entry => new { Resource = (T)entry.Value, Link = Link(entry.Value)?.Trim('/') })
.Where(entry => entry.Link is not null && entry.Link.StartsWith(prefix, StringComparison.Ordinal))
.Where(entry =>
{
var relative = entry.Link!.Substring(prefix.Length);
return relative.Length > 0 && !relative.Contains('/');
})
.Where(entry => name is null || entry.Resource.Instance.Name == name)
.Select(entry => entry.Resource)
.ToArray();
var location = Link(resource) + "/";
return new AsyncBag<T>(resources.Where(r => r.Value is T && Link(r.Value).StartsWith(location)).Select(r => (T)r.Value).ToArray());
return new AsyncBag<T>(children);
//var children = (resource.Instance.Variables["children"] as AutoList<IResource, Instance>);
+365 -118
View File
@@ -1,174 +1,421 @@
# Esiur
# Esiur for .NET
Esiur is a distributed object and resource library that facilitates real-time property modification, asynchronous function invocation, and event handling across different languages, primarily in C#, JavaScript, and Dart. Its designed for flexible, scalable, and efficient communication between client and server, suitable for applications that need rapid, bidirectional data updates and complex data handling.
[![CI](https://github.com/esiur/esiur-dotnet/actions/workflows/ci.yml/badge.svg)](https://github.com/esiur/esiur-dotnet/actions/workflows/ci.yml)
[![NuGet](https://img.shields.io/nuget/v/Esiur.svg)](https://www.nuget.org/packages/Esiur)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
## Key Features
### Real-Time Property Modification:
Esiur is a distributed resource framework for building real-time services without
maintaining a separate RPC contract. A resource can expose properties, methods,
events, records, and related resources over the self-describing EP protocol.
Allows resources to update properties in real-time across distributed environments, ensuring that changes on one side are immediately reflected on the other.
Version 3 focuses on a smaller public API, first-class ASP.NET Core hosting,
extensible authentication, authenticated encryption, bounded resource usage, and
predictable lifecycle behavior. The protocol remains designed for multiple
language implementations: wire concepts and protocol names stay consistent,
while each implementation can present idiomatic awaitables such as .NET
`AsyncReply<T>`, Dart `Future`, or JavaScript `Promise`.
### Asynchronous Function Invocation:
## Highlights
Supports async instance and static functions that are triggered across distributed nodes, allowing non-blocking operations and efficient resource management.
- Live distributed properties, method calls, and events.
- Self-describing resource, record, enum, and type metadata.
- C# source generation with `[Resource]` and `[Export]`.
- Native TCP and ASP.NET Core WebSocket transports.
- Isolated `Warehouse` runtimes with memory, Entity Framework Core, and MongoDB stores.
- Multi-step authentication providers with initializer, responder, and dual identities.
- Password challenge-response and PPAP ML-KEM-768 authentication.
- AES-256-GCM encrypted records and protected post-authentication key rotation.
- Permissions, rate-control, auditing, parser limits, connection limits, and attachment limits.
- Graceful startup and shutdown that settle resource lifecycle operations.
## Packages
### Event Handling:
All first-party v3 packages share major version `3` to make compatibility clear.
Built-in event system enables objects to raise events and propagate them across the network, making it easy to subscribe to or respond to specific events as they happen.
| Package | Purpose | Target framework |
| --- | --- | --- |
| [`Esiur`](https://www.nuget.org/packages/Esiur) | Core runtime, EP protocol, stores, security, and source generator | .NET Standard 2.0 |
| [`Esiur.AspNetCore`](https://www.nuget.org/packages/Esiur.AspNetCore) | ASP.NET Core hosting, dependency injection, endpoint routing, and WebSockets | .NET 8 / .NET 10 |
| [`Esiur.Stores.EntityCore`](https://www.nuget.org/packages/Esiur.Stores.EntityCore) | Entity Framework Core 10 resource store | .NET 10 |
| [`Esiur.Stores.MongoDB`](https://www.nuget.org/packages/Esiur.Stores.MongoDB) | MongoDB resource store | .NET 10 |
| [`Esiur.CLI`](https://www.nuget.org/packages/Esiur.CLI) | Generate typed C# models from a remote EP resource | .NET 10 tool |
The core runtime can be consumed by any compatible .NET Standard 2.0
application. Projects using the bundled source generator need a Roslyn 4.8 or
newer toolchain, such as .NET SDK 8 or newer. Building this repository requires
.NET SDK 10.
### Wide Range of Data Types:
## Install
Esiur supports extensive data types for transmission and representation, including primitive types, lists, maps, enums, tuples and nullable types.
For an ASP.NET Core service:
The library allows complex data structures to be transferred seamlessly, ensuring consistency and compatibility across different systems.
```shell
dotnet add package Esiur.AspNetCore --version 3.0.0
```
### Inheritance and Generic Types:
For the standalone runtime or a client:
Enables inheritance, so you can define base types and extend them, enhancing code reuse and flexibility.
```shell
dotnet add package Esiur --version 3.0.0
```
Supports generics, allowing for strong type definitions and collections, ensuring data integrity and reducing errors.
## Define a resource
### Self-Describing API:
Mark the class `partial` so Esiur's source generator can implement the resource
lifecycle and generate exported properties from fields.
Esiur provides a self-describing API that allows the client to introspect services and resources, discovering properties, methods, and events at runtime. This feature enables dynamic and versatile usage patterns, as the client can adjust based on available functionalities without precompiled knowledge of them.
```csharp
using Esiur.Resource;
### Multi-Language Support
Esiur has implementations in C#, JavaScript, and Dart, making it highly versatile for different platforms, including desktop, web, and mobile environments. This cross-platform compatibility enables developers to use it in various client-server and P2P distributed systems where real-time data handling is essential.
[Resource]
public partial class CounterResource
{
[Export]
int count;
## Example Use Cases
* IoT Networks: Seamlessly update and control device properties across a network, with real-time monitoring and updates.
* Game Development: Enable multiplayer synchronization of game states and events, ensuring real-time feedback for interactive experiences.
* Financial Services: Support real-time, distributed data sharing for trading platforms or monitoring systems where latency is critical.
[Export]
public event ResourceEventHandler<int>? Changed;
Esiurs robust, feature-rich approach allows developers to focus on building applications without worrying about the complexities of distributed systems, while its self-describing API and broad data type support enable flexible and scalable application design.
[Export]
public string Hello(string name) => $"Hello, {name}!";
## Installation
- Nuget
```Install-Package Esiur```
- Command-line
``` dotnet add package Esiur ```
[Export]
public int Increment()
{
Count++;
Changed?.Invoke(Count);
return Count;
}
}
```
## Getting Started
Esiur for C# uses source generator feature of .Net framework to implement the necessary calls for property modification, which means a class must be marked as "partial" so the library automatically creates setters and getters for every property exported to the public.
The private `count` field becomes the exported `Count` property. Assigning it
after the resource is attached publishes the modification to subscribed peers.
Methods and events explicitly marked with `[Export]` become part of the remote
definition.
>***MyResource.cs***
>```C#
>[Resource]
>public partial class HelloResource {
> // Esiur will generate a property with name `Counts`
> [Export] int counts;
> [Export] public string SayHi(string msg) {
> Counts++;
> GreetingReceived?.Invoke(msg);
> return $"Welcome, current time {DateTime.Now}";
> }
> [Export] public ResourceEventHandler<string> GreetingReceived;
>}
>```
## ASP.NET Core quick start
### Setting up the server
Esiur resources are arranged into stores (`IStore`) and accessed using paths similar to a Unix file system. Each store is responsible for keeping its resources in memory, files, or a database.
`Esiur.AspNetCore` is the recommended way to host Esiur in a web application.
The host owns startup and shutdown, while Esiur owns session authentication,
encryption, resource authorization, and the resource graph.
This example creates a Warehouse and uses the built-in `MemoryStore`, which keeps its resources in RAM.
```csharp
using Esiur.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://localhost:8080");
builder.Services.AddEsiur(esiur =>
{
esiur
.AddMemoryStore("sys")
.AddResource<CounterResource>("sys/counter")
.AllowAnonymous() // Development only.
.IncludeExceptionMessages(); // Development only.
});
var app = builder.Build();
var webSockets = new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
};
webSockets.AllowedOrigins.Add("http://localhost:8080");
app.UseWebSockets(webSockets);
app.MapEsiur("/esiur");
await app.RunAsync();
```
`AddEsiur` registers one host-managed `Warehouse` and `EpServer`. Resources are
constructed through dependency injection, configuration is validated at host
startup, and active WebSockets are drained or aborted before resources are
terminated during shutdown.
The endpoint returned by `MapEsiur` supports normal ASP.NET Core conventions:
```csharp
app.MapEsiur("/esiur")
.RequireAuthorization("EsiurUpgrade")
.RequireRateLimiting("EsiurHandshakes");
```
ASP.NET Core authorization protects the HTTP upgrade only. It does not replace
Esiur authentication or per-resource authorization.
## Connect a .NET client
Use the logical `ep://` resource URL and select the WebSocket transport with an
`EpConnectionContext`:
```csharp
using Esiur.Protocol;
using Esiur.Resource;
var client = new Warehouse();
dynamic counter = await client.Get<IResource>(
"ep://localhost/sys/counter",
new EpConnectionContext
{
WebSocketUri = new Uri("ws://localhost:8080/esiur"),
});
Console.WriteLine(await counter.Hello("Ada"));
Console.WriteLine(await counter.Increment());
await client.Close();
```
The `ep://` URL identifies the logical connection and resource path;
`WebSocketUri` identifies the transport endpoint. Esiur automatically requests
the case-sensitive `EP` WebSocket subprotocol.
For native TCP, omit `WebSocketUri` and include the EP port in the logical URL:
```csharp
dynamic counter = await client.Get<IResource>(
"ep://localhost:10518/sys/counter");
```
## Standalone hosting
Applications that do not use ASP.NET Core can compose the runtime directly.
`Warehouse` is the root container for stores, resources, protocol handlers,
security providers, policies, and lifecycle state.
```csharp
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Stores;
```C#
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
```
Now we can add our resource to the memory store using `warehouse.Put`.
```C#
await warehouse.Put("sys/hello", new HelloResource());
```
Add an `EpServer` to expose the resource through the Esiur EP protocol. Anonymous access is enabled here only to make the development example easy to run; production applications should configure an authentication provider.
```C#
await warehouse.Put("sys/counter", new CounterResource());
await warehouse.Put("sys/server", new EpServer
{
Port = 10518,
AllowUnauthorizedAccess = true, // Development only.
});
await warehouse.Open();
try
{
await Task.Delay(Timeout.InfiniteTimeSpan);
}
finally
{
await warehouse.Close();
}
```
Use a separate `Warehouse` for each isolated server or client runtime. Avoid
relying on the static default Warehouse in applications that host more than one
Esiur environment.
## Authentication and encryption
Anonymous access is opt-in. Production services should omit `AllowAnonymous`,
register an authentication provider, register an encryption provider, and
require encryption.
The ASP.NET Core password shortcut accepts precomputed Esiur credentials from
an application-owned cache or account store:
```csharp
using Esiur.AspNetCore;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
var credentials =
new Dictionary<(string Domain, string Identity), PasswordHash>();
builder.Services.AddEsiur(esiur =>
{
esiur
.AddMemoryStore("sys")
.AddResource<CounterResource>("sys/counter")
.UsePasswordAuthentication((identity, domain) =>
credentials.TryGetValue((domain, identity), out var credential)
? credential
: null)
.UseEncryption(new AesEncryptionProvider())
.RequireEncryption()
.AllowWebSocketOrigins("https://app.example.com");
});
```
Finally, open the Warehouse to initialize the system.
Create a `PasswordHash` once during enrollment with
`PasswordAuthenticationProvider.CreateCredential(passwordBytes)`. Clear the
plaintext bytes after use and protect the stored verifier like a password: it
is sufficient to authenticate through the `password-sha3-v1` protocol if
stolen.
Esiur v3 currently includes these canonical protocol names:
| Protocol | Role |
| --- | --- |
| `password-sha3-v1` | Simple password challenge-response for initializer, responder, or dual identity authentication |
| `ppap-mlkem768-v1` | ML-KEM-768 authentication with password-derived or static identities |
| `aes-gcm` | AES-256-GCM record protection derived from the authenticated session key |
The authentication provider abstraction supports multi-message handshakes.
PPAP uses that pipeline for initializer, responder, or dual identity
authentication and supports password-derived Argon2id identities as well as
static ML-KEM identities. Password registrations use a versioned nonce and
encapsulation key. Successful rotation is performed only through a dedicated
exchange after encryption is active, preventing the registration nonce from
becoming a permanent network identifier. Persistent PPAP registration stores
must implement atomic compare-and-rotate behavior.
Applications with custom protocols can implement `IAuthenticationProvider` and
register the provider through `UseAuthentication`. Protocol names are exact and
case-sensitive; v3 does not negotiate legacy aliases.
## Entity Framework Core 10
`Esiur.Stores.EntityCore` integrates Esiur resource materialization and paths
with EF Core 10.
```shell
dotnet add package Esiur.Stores.EntityCore --version 3.0.0
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.10
```
Configure the database provider first, then attach the `EntityStore` with the
typed `UseEsiur` extension:
```csharp
using Esiur.Resource;
using Esiur.Stores.EntityCore;
using Microsoft.EntityFrameworkCore;
var warehouse = new Warehouse();
var store = await warehouse.Put("database", new EntityStore());
DbContextOptions<AppDbContext>? options = null;
options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=app.db")
.UseEsiur(store, () => new AppDbContext(options!))
.Options;
```C#
await warehouse.Open();
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var device = await db.Devices.AddResourceAsync(new Device { Name = "sensor-1" });
```
To sum up
```csharp
using System.ComponentModel.DataAnnotations;
using Esiur.Resource;
using Microsoft.EntityFrameworkCore;
>***Program.cs***
>```C#
>using Esiur.Protocol;
>using Esiur.Resource;
>using Esiur.Stores;
>
>var warehouse = new Warehouse();
>await warehouse.Put("sys", new MemoryStore());
>await warehouse.Put("sys/hello", new HelloResource());
>await warehouse.Put("sys/server", new EpServer
>{
> AllowUnauthorizedAccess = true, // Development only.
>});
>await warehouse.Open();
>```
[Resource]
public partial class Device
{
[Key, Export]
int id;
[Export]
string name = string.Empty;
}
### Setting up the client
To access our resource remotely, we need to use it's full path including the protocol, host and instance link.
```C#
var warehouse = new Warehouse();
dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public DbSet<Device> Devices => Set<Device>();
}
```
Now we can invoke the exported functions and read/write properties;
The repository test suite runs a complete SQLite schema, insert,
materialization, and Warehouse lookup cycle. PostgreSQL through Npgsql and
MySQL through Oracle's EF provider are also checked for model creation and SQL
translation without requiring external database servers.
```C#
var reply = await res.SayHi("Hi, I'm calling you from dotnet");
Console.WriteLine(reply);
Console.WriteLine($"Number of people said hi {res.Counts}");
## Generate typed client models
EP is self-describing, so clients can work dynamically or generate strongly
typed models. Install the v3 CLI as a .NET tool:
```shell
dotnet tool install --global Esiur.CLI --version 3.0.0
esiur get-template ep://localhost:10518/sys/counter --dir Generated
```
Summing up
Use `--async-setters` to generate asynchronous property setters. The CLI also
accepts `--username` and `--password`, but command-line secrets may be visible
to other processes or shell history; prefer a safer credential workflow when
the environment is shared.
>***Program.cs***
>```C#
>using Esiur.Resource;
>
>var warehouse = new Warehouse();
>dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
>
>var reply = await res.SayHi("Hi, I'm calling you from dotnet");
>
>Console.WriteLine(reply);
>Console.WriteLine($"Number of people said hi {res.Counts}");
>
>```
## Runtime limits and security defaults
## Getting Types
`WarehouseConfiguration` bounds work performed on untrusted input. The default
configuration includes limits for:
In the above client example, we relied on Esiur support for dynamic objects, but this way the developer would need to know the functions, properties and events available given to them as API docs or inspect it in debugging mode.
- Packet size, decoded allocation size, collection items, and type-metadata depth.
- Attached and pending resources per connection.
- Concurrent connections and connection attempts globally and per IP address.
- Encrypted record size.
- Repeated rate-control denials.
Esiur has a self describing feature which comes with every language it supports, allowing the developer to fetch and generate classes that match the ones on the other side (i.e. server).
ASP.NET Core hosting also provides per-connection and host-wide pending
WebSocket send limits. Configure these for the expected workload rather than
disabling them:
After installing the Esiur NuGet package, a new command named ***Get-Types*** is added to the Visual Studio Package Manager Console. It generates client-side classes for robust static typing.
```ps
Get-Types ep://localhost/sys/hello
```csharp
builder.Services.AddEsiur(esiur => esiur
.AddMemoryStore("sys")
.AddResource<CounterResource>("sys/counter")
.LimitPendingWebSocketSendBytes(2 * 1024 * 1024)
.LimitTotalPendingWebSocketSendBytes(256L * 1024 * 1024)
.ConfigureWarehouse(configuration =>
{
configuration.Connections.MaximumConnections = 500;
configuration.Connections.MaximumConnectionsPerIpAddress = 20;
configuration.Connections.MaximumConnectionAttempts = 2_000;
}));
```
This will generate and add wrappers for all types needed by our resource.
When deploying behind a reverse proxy, trust only known proxy addresses and
apply forwarded headers before `UseWebSockets` and `MapEsiur`. Browser origin
checks, ASP.NET Core endpoint authorization, Esiur session authentication, and
resource permissions are separate layers and should be configured together.
Allowing us to use
```C#
var warehouse = new Warehouse();
var res = await warehouse.Get<MyResource>("ep://localhost/sys/hello");
var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply);
## Build and test
The repository is pinned to .NET SDK 10:
```shell
dotnet restore Esiur.sln
dotnet build Esiur.sln --configuration Release
dotnet test Esiur.sln --configuration Release
dotnet list Esiur.sln package --vulnerable --include-transitive
```
The main automated suite covers packet parsing, serialization, authentication,
PPAP registration rotation, encrypted records, resource attachment limits,
connection admission, WebSockets, ASP.NET Core hosting, Warehouse lifecycle,
and EF Core provider integration.
## Version 3 compatibility
Version 3 is an intentional compatibility boundary:
- All distributed first-party packages use major version `3`.
- ASP.NET Core applications use `AddEsiur` and `MapEsiur` instead of the legacy integration.
- Authentication and encryption providers negotiate canonical protocol names without aliases.
- EntityCore targets .NET 10 and EF Core 10.
- Legacy packet and hosting APIs removed during the v3 cleanup are not retained as compatibility shims.
Within the v3 family, minor and patch package versions may advance
independently. Consumers should keep the same major version across Esiur
packages.
## License
Esiur is available under the [MIT License](LICENSE).
@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
+16 -7
View File
@@ -60,9 +60,20 @@ public class EntityStore : IStore
public AsyncReply<IResource> Get(string path)
{
var p = path.Split('/');
var ti = TypesByName[p[0]];
var id = Convert.ChangeType(p[1], ti.PrimaryKey.PropertyType);
var p = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (p.Length != 2 || !TypesByName.TryGetValue(p[0], out var ti))
return new AsyncReply<IResource>(null);
object id;
try
{
id = Convert.ChangeType(p[1], ti.PrimaryKey.PropertyType);
}
catch (Exception) when (
ti.PrimaryKey.PropertyType != typeof(string))
{
return new AsyncReply<IResource>(null);
}
// Get db
using (var db = Getter())
@@ -97,8 +108,6 @@ public class EntityStore : IStore
//var refs = ent.References.ToList();
db.Dispose();
return new AsyncReply<IResource>(res);
}
}
@@ -223,7 +232,7 @@ public class EntityStore : IStore
internal DbContextOptions Options { get; set; }
public AsyncReply<bool> Handle(ResourceOperation operation, IResourceContext? context = null)
public AsyncReply<bool> Handle(ResourceOperation operation, IResourceContext context = null)
{
if (operation == ResourceOperation.Initialize)// SystemInitialized && DbContext != null)
{
@@ -247,7 +256,7 @@ public class EntityStore : IStore
TypesByName.Clear();
TypesByType.Clear();
var context = Getter();
using var context = Getter();
var types = context.Model.GetEntityTypes();
foreach (var t in types)
@@ -9,7 +9,7 @@
<Product>Esiur Entity Framework Extension</Product>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>Esiur.Stores.EntityCore</PackageId>
<Version>1.3.4</Version>
<Version>3.0.0</Version>
<LangVersion>latest</LangVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
@@ -23,7 +23,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
@@ -129,7 +129,7 @@ public static class EsiurExtensions
var entity = dbSet.Add((T)res);
await entity.Context.SaveChangesAsync();
var id = store.TypesByType[typeof(T)].PrimaryKey.GetValue(resource);
var id = store.TypesByType[typeof(T)].PrimaryKey.GetValue(res);
var resourceContext = manager == null
? null
@@ -178,12 +178,25 @@ public static class EsiurExtensions
extension = new EsiurExtensionOptions(store);
}
if (getter != null)
extension.Store.Getter = getter;
((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension);
return optionsBuilder;
}
public static DbContextOptionsBuilder<TContext> UseEsiur<TContext>(
this DbContextOptionsBuilder<TContext> optionsBuilder,
EntityStore store,
Func<DbContext> getter = null)
where TContext : DbContext
{
UseEsiur((DbContextOptionsBuilder)optionsBuilder, store, getter);
return optionsBuilder;
}
//public static DbContextOptionsBuilder<TContext> UseEsiur<TContext>(
// this DbContextOptionsBuilder<TContext> optionsBuilder,
// //DbContext context,
@@ -10,7 +10,7 @@
<PackageProjectUrl>http://www.esiur.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/esiur/esiur-dotnet/</RepositoryUrl>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<Version>1.5.6</Version>
<Version>3.0.0</Version>
<PackageId>Esiur.Stores.MongoDB</PackageId>
<LangVersion>latest</LangVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
+68
View File
@@ -0,0 +1,68 @@
using Esiur.CLI;
using Esiur.CLI.Authentication;
using Esiur.CLI.Client;
using Esiur.CLI.Configuration;
namespace Esiur.CLI.Tests;
public sealed class CommandTests
{
[Fact]
public async Task ProfileUseListAndRemoveShareConfigurationService()
{
var directory = Directory.CreateTempSubdirectory("esiur-cli-command-");
try
{
var store = new ConfigurationStore(Path.Combine(directory.FullName, "config.json"));
await store.SaveAsync(new CliConfiguration
{
Profiles = new(StringComparer.OrdinalIgnoreCase)
{
["production"] = new ConnectionProfile
{
Name = "production", Endpoint = "ep://host",
},
},
}, default);
var credentials = new PromptCredentialService();
var stdout = new StringWriter();
var stderr = new StringWriter();
var app = new CliApplication(
store, credentials, new EsiurSessionFactory(credentials),
new ResourceInspectionService(), TextReader.Null, stdout, stderr);
Assert.Equal(ExitCodes.Success,
await app.RunAsync(new[] { "profile", "use", "production" }, default));
Assert.Equal("production", (await store.LoadAsync(default)).DefaultProfile);
Assert.Equal(ExitCodes.Success,
await app.RunAsync(new[] { "profile", "list", "--output", "json" }, default));
Assert.Contains("production", stdout.ToString());
Assert.Equal(ExitCodes.Success,
await app.RunAsync(new[] { "profile", "remove", "production" }, default));
var final = await store.LoadAsync(default);
Assert.Empty(final.Profiles);
Assert.Null(final.DefaultProfile);
Assert.Empty(stderr.ToString());
}
finally { directory.Delete(true); }
}
[Fact]
public async Task UnknownCommandReturnsStableInvalidArgumentsCode()
{
var directory = Directory.CreateTempSubdirectory("esiur-cli-command-");
try
{
var credentials = new PromptCredentialService();
var app = new CliApplication(
new ConfigurationStore(Path.Combine(directory.FullName, "config.json")),
credentials, new EsiurSessionFactory(credentials), new ResourceInspectionService(),
TextReader.Null, new StringWriter(), new StringWriter());
Assert.Equal(ExitCodes.InvalidArguments,
await app.RunAsync(new[] { "does-not-exist" }, default));
}
finally { directory.Delete(true); }
}
}
+101
View File
@@ -0,0 +1,101 @@
using System.Text.Json;
using Esiur.CLI;
using Esiur.CLI.Client;
using Esiur.CLI.Configuration;
namespace Esiur.CLI.Tests;
public sealed class ConfigurationTests
{
[Theory]
[InlineData("ep://localhost", "ep://localhost")]
[InlineData("ep://example.test:9000/sys/service", "ep://example.test:9000")]
public void EndpointParserExtractsConnectionEndpoint(string value, string expected) =>
Assert.Equal(expected, EndpointParser.ConnectionEndpoint(value));
[Theory]
[InlineData("http://localhost")]
[InlineData("ep:///missing-host")]
[InlineData("not-an-endpoint")]
public void EndpointParserRejectsInvalidEndpoints(string value) =>
Assert.Throws<CliException>(() => ConfigurationResolver.ValidateEndpoint(value));
[Theory]
[InlineData("500ms", 500)]
[InlineData("30s", 30_000)]
[InlineData("2m", 120_000)]
public void DurationParserSupportsDocumentedSuffixes(string value, double milliseconds) =>
Assert.Equal(milliseconds, DurationParser.Parse(value).TotalMilliseconds);
[Fact]
public async Task ConfigurationRoundTripsProfilesWithoutSecrets()
{
var directory = Directory.CreateTempSubdirectory("esiur-cli-test-");
try
{
var path = Path.Combine(directory.FullName, "config.json");
var store = new ConfigurationStore(path);
await store.SaveAsync(new CliConfiguration
{
DefaultProfile = "production",
Profiles = new(StringComparer.OrdinalIgnoreCase)
{
["production"] = new ConnectionProfile
{
Name = "production",
Endpoint = "ep://host",
Provider = "password",
Identity = "ahmed",
},
},
}, default);
var text = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("\"password\":", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
var loaded = await store.LoadAsync(default);
Assert.Equal("production", loaded.DefaultProfile);
Assert.Equal("ep://host", loaded.Profiles["PRODUCTION"].Endpoint);
}
finally { directory.Delete(true); }
}
[Fact]
public void ExplicitOptionsOverrideSelectedProfileAndGlobals()
{
var configuration = new CliConfiguration
{
DefaultProfile = "saved",
OutputFormat = "table",
Profiles = new(StringComparer.OrdinalIgnoreCase)
{
["saved"] = new ConnectionProfile
{
Name = "saved", Endpoint = "ep://saved", OutputFormat = "raw",
Identity = "stored",
},
},
};
var result = ConfigurationResolver.Resolve(configuration,
new GlobalOptions("saved", "ep://explicit", null, "explicit", "json", TimeSpan.FromSeconds(4), false, false));
Assert.Equal("ep://explicit", result.Endpoint);
Assert.Equal("explicit", result.Identity);
Assert.Equal("json", result.OutputFormat);
Assert.Equal(TimeSpan.FromSeconds(4), result.Timeout);
}
[Theory]
[InlineData("sys/service", "sys/service")]
[InlineData("/sys/service/", "sys/service")]
public void ResourcePathsAreNormalized(string value, string expected) =>
Assert.Equal(expected, ResourceInspectionService.NormalizePath(value));
[Fact]
public void SecretsAreRedactedFromDiagnostics()
{
var value = CliApplication.RedactSecrets("password=secret token: abc123");
Assert.DoesNotContain("secret", value);
Assert.DoesNotContain("abc123", value);
Assert.Equal("password=*** token: ***", value);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Tools\Esiur.CLI\Esiur.CLI.csproj" />
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Sockets;
using Esiur.CLI.Authentication;
using Esiur.CLI.Client;
using Esiur.CLI.Configuration;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Stores;
namespace Esiur.CLI.Tests;
[Resource]
public partial class CliTestResource
{
[Export] public string Name { get; set; } = "Main";
[Export, ReadOnly] public bool Running { get; set; } = true;
}
public sealed class ReadOnlyIntegrationTests
{
[Fact]
public async Task QueryDescribeAndGetWorkAgainstInProcessServer()
{
var server = new Warehouse();
EsiurSession? session = null;
try
{
var port = FindAvailablePort();
await server.Put("sys", new MemoryStore());
await server.Put("sys/server", new EpServer { Port = port, AllowUnauthorizedAccess = true });
await server.Put("sys/service", new CliTestResource());
await server.Open();
var credentials = new PromptCredentialService();
var factory = new EsiurSessionFactory(credentials);
var settings = new ResolvedConnection(
"test", $"ep://localhost:{port}", null, null, null,
"json", TimeSpan.FromSeconds(10), null);
session = await factory.ConnectAsync(
settings, false, TextReader.Null, TextWriter.Null, default);
var service = new ResourceInspectionService();
var query = await service.QueryAsync(session, "sys", 1, null, default);
Assert.Contains(query, item => item.Path == "sys/service" && item.Type.Contains(nameof(CliTestResource)));
var description = await service.DescribeAsync(session, "sys/service", true, default);
Assert.Equal("sys/service", description.Path);
Assert.Contains(description.Properties, item => item.Name == nameof(CliTestResource.Name) && Equals(item.Value, "Main"));
var values = await service.GetAsync(session, "sys/service", new[] { "Name", "1" }, default);
Assert.Equal("Main", values[0].Value);
Assert.Equal(true, values[1].Value);
}
finally
{
if (session is not null) await session.DisposeAsync();
try { await server.Close(); } catch { }
}
}
static ushort FindAvailablePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.Text.Json;
using Esiur.CLI;
using Esiur.CLI.Client;
using Esiur.CLI.Rendering;
namespace Esiur.CLI.Tests;
public sealed class RenderingTests
{
[Fact]
public async Task JsonOutputUsesStableCamelCaseShape()
{
var writer = new StringWriter();
await new OutputRenderer(writer).RenderAsync(
new[] { new PropertyResult("sys/service", "Name", 0, "Main") },
OutputFormat.Json, default);
using var document = JsonDocument.Parse(writer.ToString());
var value = document.RootElement[0];
Assert.Equal("sys/service", value.GetProperty("resource").GetString());
Assert.Equal("Main", value.GetProperty("value").GetString());
}
[Fact]
public async Task RawOutputPrintsOnlyPropertyValues()
{
var writer = new StringWriter();
await new OutputRenderer(writer).RenderAsync(
new[]
{
new PropertyResult("sys/service", "Name", 0, "Main"),
new PropertyResult("sys/service", "Running", 1, true),
}, OutputFormat.Raw, default);
Assert.Equal($"Main{Environment.NewLine}true{Environment.NewLine}", writer.ToString());
}
[Fact]
public async Task TableOutputContainsHeadersAndRows()
{
var writer = new StringWriter();
await new OutputRenderer(writer).RenderAsync(
new[] { new ResourceSummary("service", "sys/service", 3, "Demo.Service", "0x01", 0) },
OutputFormat.Table, default);
Assert.Contains("Session Id", writer.ToString(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("sys/service", writer.ToString());
}
[Theory]
[InlineData("table", OutputFormat.Table)]
[InlineData("json", OutputFormat.Json)]
[InlineData("jsonl", OutputFormat.JsonLines)]
[InlineData("raw", OutputFormat.Raw)]
public void OutputFormatsParse(string value, OutputFormat expected) =>
Assert.Equal(expected, OutputFormatExtensions.Parse(value));
}
@@ -0,0 +1,122 @@
using System.ComponentModel.DataAnnotations;
using Esiur.Core;
using Esiur.Resource;
using Esiur.Stores.EntityCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Esiur.Tests.EntityCore;
public sealed class EntityCoreProviderTests
{
[Fact]
public async Task Sqlite_PersistsAndMaterializesEsiurResources()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var warehouse = new Warehouse();
var store = await warehouse.Put("database", new EntityStore());
DbContextOptions<ResourceDbContext>? options = null;
options = new DbContextOptionsBuilder<ResourceDbContext>()
.UseSqlite(connection)
.UseEsiur(store, () => new ResourceDbContext(options!))
.Options;
try
{
await warehouse.Open();
await using var context = new ResourceDbContext(options);
await context.Database.EnsureCreatedAsync();
var added = await context.Resources.AddResourceAsync(new ProviderResource
{
Name = "SQLite resource",
});
Assert.True(added.Id > 0);
Assert.NotNull(added.Instance);
Assert.Same(store, added.Instance!.Store);
context.ChangeTracker.Clear();
var loaded = await context.Resources.SingleAsync(x => x.Id == added.Id);
Assert.Same(added, loaded);
Assert.Equal("SQLite resource", loaded.Name);
Assert.NotNull(await warehouse.Get<IResource>($"database/{nameof(ProviderResource)}/{added.Id}"));
}
finally
{
if (warehouse.IsOpen)
await warehouse.Close();
}
}
[Fact]
public async Task PostgreSql_BuildsModelAndTranslatesQuery()
{
await AssertProviderCompatibility(
options => options.UseNpgsql(
"Host=localhost;Database=esiur_tests;Username=esiur;Password=unused"),
"Npgsql.EntityFrameworkCore.PostgreSQL");
}
[Fact]
public async Task MySql_BuildsModelAndTranslatesQuery()
{
await AssertProviderCompatibility(
options => options.UseMySQL(
"Server=localhost;Database=esiur_tests;User=esiur;Password=unused"),
"MySql.EntityFrameworkCore");
}
private static async Task AssertProviderCompatibility(
Action<DbContextOptionsBuilder<ResourceDbContext>> configureProvider,
string expectedProvider)
{
var warehouse = new Warehouse();
var store = await warehouse.Put("database", new EntityStore());
var builder = new DbContextOptionsBuilder<ResourceDbContext>();
configureProvider(builder);
builder.UseEsiur(store);
await using var context = new ResourceDbContext(builder.Options);
var entity = context.Model.FindEntityType(typeof(ProviderResource));
var sql = context.Resources
.Where(x => x.Name == "provider check")
.ToQueryString();
Assert.NotNull(entity);
Assert.Equal(expectedProvider, context.Database.ProviderName);
Assert.Contains(entity!.GetTableName()!, sql, StringComparison.OrdinalIgnoreCase);
}
private sealed class ResourceDbContext(DbContextOptions<ResourceDbContext> options)
: DbContext(options)
{
public DbSet<ProviderResource> Resources => Set<ProviderResource>();
}
}
public class ProviderResource : IResource
{
[Key]
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public Instance? Instance { get; set; }
public event DestroyedEvent? OnDestroy;
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
=> new(true);
public void Destroy() => OnDestroy?.Invoke(this);
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Stores\Esiur.Stores.EntityCore\Esiur.Stores.EntityCore.csproj" />
</ItemGroup>
</Project>
+62
View File
@@ -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);
}
+398
View File
@@ -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
""");
}
+9
View File
@@ -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; }
}
+135
View File
@@ -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";
}
+7 -5
View File
@@ -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="\" />
+17
View File
@@ -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;
}
-52
View File
@@ -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 -93
View File
@@ -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);
+111 -18
View File
@@ -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.
+17
View File
@@ -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),
};
}
+171
View File
@@ -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}";
}