Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save smdn/2c6a8722d46b34f3bcf6410211c4ac11 to your computer and use it in GitHub Desktop.
Save smdn/2c6a8722d46b34f3bcf6410211c4ac11 to your computer and use it in GitHub Desktop.
Smdn.Net.EchonetLite.RouteB 2.0.0-preview2 Release Notes

main/Smdn.Net.EchonetLite.RouteB-2.0.0-preview2

diff --git a/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net6.0.apilist.cs b/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net6.0.apilist.cs
index ed56b16..ed9997a 100644
--- a/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net6.0.apilist.cs
+++ b/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net6.0.apilist.cs
@@ -1,74 +1,75 @@
-// Smdn.Net.EchonetLite.RouteB.dll (Smdn.Net.EchonetLite.RouteB-2.0.0-preview1)
+// Smdn.Net.EchonetLite.RouteB.dll (Smdn.Net.EchonetLite.RouteB-2.0.0-preview2)
// Name: Smdn.Net.EchonetLite.RouteB
// AssemblyVersion: 2.0.0.0
-// InformationalVersion: 2.0.0-preview1+72e57d7daf6b52fc6ecc4ed745e175a1893e8d90
+// InformationalVersion: 2.0.0-preview2+c9161acca48757584c059440b4e2b704c3a80505
// TargetFramework: .NETCoreApp,Version=v6.0
// Configuration: Release
// Referenced assemblies:
-// Microsoft.Extensions.DependencyInjection.Abstractions, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
+// Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
// Smdn.Net.EchonetLite.Transport, Version=2.0.0.0, Culture=neutral
// System.Memory, Version=6.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
// System.Net.Primitives, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
// System.Runtime, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
#nullable enable annotations
using System;
using System.Buffers;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Smdn.Net.EchonetLite.RouteB.Credentials;
using Smdn.Net.EchonetLite.RouteB.Transport;
using Smdn.Net.EchonetLite.Transport;
namespace Smdn.Net.EchonetLite.RouteB.Credentials {
public interface IRouteBCredential : IDisposable {
void WriteIdTo(IBufferWriter<byte> buffer);
void WritePasswordTo(IBufferWriter<byte> buffer);
}
public interface IRouteBCredentialIdentity {
}
public interface IRouteBCredentialProvider {
IRouteBCredential GetCredential(IRouteBCredentialIdentity identity);
}
public static class RouteBCredentialServiceCollectionExtensions {
- public static IServiceCollection AddRouteBCredential(this IServiceCollection services, IRouteBCredentialProvider credentialProvider) {}
public static IServiceCollection AddRouteBCredential(this IServiceCollection services, string id, string password) {}
+ public static IServiceCollection AddRouteBCredentialFromEnvironmentVariable(this IServiceCollection services, string envVarForId, string envVarForPassword) {}
+ public static IServiceCollection AddRouteBCredentialProvider(this IServiceCollection services, IRouteBCredentialProvider credentialProvider) {}
}
public static class RouteBCredentials {
public const int AuthenticationIdLength = 32;
public const int PasswordLength = 12;
}
}
namespace Smdn.Net.EchonetLite.RouteB.Transport {
public interface IRouteBEchonetLiteHandlerBuilder {
IServiceCollection Services { get; }
}
public interface IRouteBEchonetLiteHandlerFactory {
ValueTask<RouteBEchonetLiteHandler> CreateAsync(CancellationToken cancellationToken);
}
public abstract class RouteBEchonetLiteHandler : EchonetLiteHandler {
protected RouteBEchonetLiteHandler() {}
public abstract IPAddress? PeerAddress { get; }
public ValueTask ConnectAsync(IRouteBCredential credential, CancellationToken cancellationToken = default) {}
protected abstract ValueTask ConnectAsyncCore(IRouteBCredential credential, CancellationToken cancellationToken);
public ValueTask DisconnectAsync(CancellationToken cancellationToken = default) {}
protected abstract ValueTask DisconnectAsyncCore(CancellationToken cancellationToken);
}
public static class RouteBEchonetLiteHandlerBuilderServiceCollectionExtensions {
public static IServiceCollection AddRouteBHandler(this IServiceCollection services, Action<IRouteBEchonetLiteHandlerBuilder> configure) {}
}
}
// API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.4.1.0.
// Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)
diff --git a/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net8.0.apilist.cs b/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net8.0.apilist.cs
index b9ccbb0..4dbf84a 100644
--- a/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net8.0.apilist.cs
+++ b/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-net8.0.apilist.cs
@@ -1,74 +1,75 @@
-// Smdn.Net.EchonetLite.RouteB.dll (Smdn.Net.EchonetLite.RouteB-2.0.0-preview1)
+// Smdn.Net.EchonetLite.RouteB.dll (Smdn.Net.EchonetLite.RouteB-2.0.0-preview2)
// Name: Smdn.Net.EchonetLite.RouteB
// AssemblyVersion: 2.0.0.0
-// InformationalVersion: 2.0.0-preview1+72e57d7daf6b52fc6ecc4ed745e175a1893e8d90
+// InformationalVersion: 2.0.0-preview2+c9161acca48757584c059440b4e2b704c3a80505
// TargetFramework: .NETCoreApp,Version=v8.0
// Configuration: Release
// Referenced assemblies:
-// Microsoft.Extensions.DependencyInjection.Abstractions, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
+// Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
// Smdn.Net.EchonetLite.Transport, Version=2.0.0.0, Culture=neutral
// System.Memory, Version=8.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
// System.Net.Primitives, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
// System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
#nullable enable annotations
using System;
using System.Buffers;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Smdn.Net.EchonetLite.RouteB.Credentials;
using Smdn.Net.EchonetLite.RouteB.Transport;
using Smdn.Net.EchonetLite.Transport;
namespace Smdn.Net.EchonetLite.RouteB.Credentials {
public interface IRouteBCredential : IDisposable {
void WriteIdTo(IBufferWriter<byte> buffer);
void WritePasswordTo(IBufferWriter<byte> buffer);
}
public interface IRouteBCredentialIdentity {
}
public interface IRouteBCredentialProvider {
IRouteBCredential GetCredential(IRouteBCredentialIdentity identity);
}
public static class RouteBCredentialServiceCollectionExtensions {
- public static IServiceCollection AddRouteBCredential(this IServiceCollection services, IRouteBCredentialProvider credentialProvider) {}
public static IServiceCollection AddRouteBCredential(this IServiceCollection services, string id, string password) {}
+ public static IServiceCollection AddRouteBCredentialFromEnvironmentVariable(this IServiceCollection services, string envVarForId, string envVarForPassword) {}
+ public static IServiceCollection AddRouteBCredentialProvider(this IServiceCollection services, IRouteBCredentialProvider credentialProvider) {}
}
public static class RouteBCredentials {
public const int AuthenticationIdLength = 32;
public const int PasswordLength = 12;
}
}
namespace Smdn.Net.EchonetLite.RouteB.Transport {
public interface IRouteBEchonetLiteHandlerBuilder {
IServiceCollection Services { get; }
}
public interface IRouteBEchonetLiteHandlerFactory {
ValueTask<RouteBEchonetLiteHandler> CreateAsync(CancellationToken cancellationToken);
}
public abstract class RouteBEchonetLiteHandler : EchonetLiteHandler {
protected RouteBEchonetLiteHandler() {}
public abstract IPAddress? PeerAddress { get; }
public ValueTask ConnectAsync(IRouteBCredential credential, CancellationToken cancellationToken = default) {}
protected abstract ValueTask ConnectAsyncCore(IRouteBCredential credential, CancellationToken cancellationToken);
public ValueTask DisconnectAsync(CancellationToken cancellationToken = default) {}
protected abstract ValueTask DisconnectAsyncCore(CancellationToken cancellationToken);
}
public static class RouteBEchonetLiteHandlerBuilderServiceCollectionExtensions {
public static IServiceCollection AddRouteBHandler(this IServiceCollection services, Action<IRouteBEchonetLiteHandlerBuilder> configure) {}
}
}
// API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.4.1.0.
// Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)
diff --git a/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-netstandard2.1.apilist.cs b/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-netstandard2.1.apilist.cs
index 19dcb57..c942679 100644
--- a/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-netstandard2.1.apilist.cs
+++ b/doc/api-list/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB-netstandard2.1.apilist.cs
@@ -1,72 +1,73 @@
-// Smdn.Net.EchonetLite.RouteB.dll (Smdn.Net.EchonetLite.RouteB-2.0.0-preview1)
+// Smdn.Net.EchonetLite.RouteB.dll (Smdn.Net.EchonetLite.RouteB-2.0.0-preview2)
// Name: Smdn.Net.EchonetLite.RouteB
// AssemblyVersion: 2.0.0.0
-// InformationalVersion: 2.0.0-preview1+72e57d7daf6b52fc6ecc4ed745e175a1893e8d90
+// InformationalVersion: 2.0.0-preview2+c9161acca48757584c059440b4e2b704c3a80505
// TargetFramework: .NETStandard,Version=v2.1
// Configuration: Release
// Referenced assemblies:
-// Microsoft.Extensions.DependencyInjection.Abstractions, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
+// Microsoft.Extensions.DependencyInjection.Abstractions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
// Smdn.Net.EchonetLite.Transport, Version=2.0.0.0, Culture=neutral
// netstandard, Version=2.1.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
#nullable enable annotations
using System;
using System.Buffers;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Smdn.Net.EchonetLite.RouteB.Credentials;
using Smdn.Net.EchonetLite.RouteB.Transport;
using Smdn.Net.EchonetLite.Transport;
namespace Smdn.Net.EchonetLite.RouteB.Credentials {
public interface IRouteBCredential : IDisposable {
void WriteIdTo(IBufferWriter<byte> buffer);
void WritePasswordTo(IBufferWriter<byte> buffer);
}
public interface IRouteBCredentialIdentity {
}
public interface IRouteBCredentialProvider {
IRouteBCredential GetCredential(IRouteBCredentialIdentity identity);
}
public static class RouteBCredentialServiceCollectionExtensions {
- public static IServiceCollection AddRouteBCredential(this IServiceCollection services, IRouteBCredentialProvider credentialProvider) {}
public static IServiceCollection AddRouteBCredential(this IServiceCollection services, string id, string password) {}
+ public static IServiceCollection AddRouteBCredentialFromEnvironmentVariable(this IServiceCollection services, string envVarForId, string envVarForPassword) {}
+ public static IServiceCollection AddRouteBCredentialProvider(this IServiceCollection services, IRouteBCredentialProvider credentialProvider) {}
}
public static class RouteBCredentials {
public const int AuthenticationIdLength = 32;
public const int PasswordLength = 12;
}
}
namespace Smdn.Net.EchonetLite.RouteB.Transport {
public interface IRouteBEchonetLiteHandlerBuilder {
IServiceCollection Services { get; }
}
public interface IRouteBEchonetLiteHandlerFactory {
ValueTask<RouteBEchonetLiteHandler> CreateAsync(CancellationToken cancellationToken);
}
public abstract class RouteBEchonetLiteHandler : EchonetLiteHandler {
protected RouteBEchonetLiteHandler() {}
public abstract IPAddress? PeerAddress { get; }
public ValueTask ConnectAsync(IRouteBCredential credential, CancellationToken cancellationToken = default) {}
protected abstract ValueTask ConnectAsyncCore(IRouteBCredential credential, CancellationToken cancellationToken);
public ValueTask DisconnectAsync(CancellationToken cancellationToken = default) {}
protected abstract ValueTask DisconnectAsyncCore(CancellationToken cancellationToken);
}
public static class RouteBEchonetLiteHandlerBuilderServiceCollectionExtensions {
public static IServiceCollection AddRouteBHandler(this IServiceCollection services, Action<IRouteBEchonetLiteHandlerBuilder> configure) {}
}
}
// API list generated by Smdn.Reflection.ReverseGenerating.ListApi.MSBuild.Tasks v1.4.1.0.
// Smdn.Reflection.ReverseGenerating.ListApi.Core v1.3.1.0 (https://github.com/smdn/Smdn.Reflection.ReverseGenerating)
diff --git a/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.Credentials/RouteBCredentialServiceCollectionExtensions.cs b/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.Credentials/RouteBCredentialServiceCollectionExtensions.cs
index 1440b9e..43f7f24 100644
--- a/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.Credentials/RouteBCredentialServiceCollectionExtensions.cs
+++ b/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.Credentials/RouteBCredentialServiceCollectionExtensions.cs
@@ -20,7 +20,7 @@ public static class RouteBCredentialServiceCollectionExtensions {
string id,
string password
)
- => AddRouteBCredential(
+ => AddRouteBCredentialProvider(
services: services ?? throw new ArgumentNullException(nameof(services)),
#pragma warning disable CA2000
credentialProvider: new SingleIdentityPlainTextRouteBCredentialProvider(
@@ -30,12 +30,34 @@ public static class RouteBCredentialServiceCollectionExtensions {
#pragma warning restore CA2000
);
+ /// <summary>
+ /// Adds <see cref="IRouteBCredentialProvider"/> to <see cref="IServiceCollection"/>.
+ /// This overload creates <see cref="IRouteBCredentialProvider"/> that retrieves route-B ID and password from environment variables.
+ /// </summary>
+ /// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
+ /// <param name="envVarForId">An environment variable name for the route-B ID used for the route-B authentication.</param>
+ /// <param name="envVarForPassword">An environment variable name for the password used for the route-B authentication.</param>
+ public static IServiceCollection AddRouteBCredentialFromEnvironmentVariable(
+ this IServiceCollection services,
+ string envVarForId,
+ string envVarForPassword
+ )
+ => AddRouteBCredentialProvider(
+ services: services ?? throw new ArgumentNullException(nameof(services)),
+#pragma warning disable CA2000
+ credentialProvider: new SingleIdentityEnvironmentVariableRouteBCredentialProvider(
+ envVarForId: envVarForId,
+ envVarForPassword: envVarForPassword
+ )
+#pragma warning restore CA2000
+ );
+
/// <summary>
/// Adds <see cref="IRouteBCredentialProvider"/> to <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="credentialProvider">A <see cref="IRouteBCredentialProvider"/> used for authentication to the route B for the smart meter.</param>
- public static IServiceCollection AddRouteBCredential(
+ public static IServiceCollection AddRouteBCredentialProvider(
this IServiceCollection services,
IRouteBCredentialProvider credentialProvider
)
diff --git a/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.Credentials/SingleIdentityEnvironmentVariableRouteBCredentialProvider.cs b/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.Credentials/SingleIdentityEnvironmentVariableRouteBCredentialProvider.cs
new file mode 100644
index 0000000..6ca74e8
--- /dev/null
+++ b/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.Credentials/SingleIdentityEnvironmentVariableRouteBCredentialProvider.cs
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2023 smdn <smdn@smdn.jp>
+// SPDX-License-Identifier: MIT
+using System;
+using System.Buffers;
+using System.Text;
+
+namespace Smdn.Net.EchonetLite.RouteB.Credentials;
+
+internal sealed class SingleIdentityEnvironmentVariableRouteBCredentialProvider : IRouteBCredentialProvider, IRouteBCredential {
+ private readonly string envVarForId;
+ private readonly string envVarForPassword;
+
+ public SingleIdentityEnvironmentVariableRouteBCredentialProvider(
+ string envVarForId,
+ string envVarForPassword
+ )
+ {
+#if SYSTEM_ARGUMENTEXCEPTION_THROWIFNULLOREMPTY
+ ArgumentException.ThrowIfNullOrEmpty(envVarForId, nameof(envVarForId));
+ ArgumentException.ThrowIfNullOrEmpty(envVarForPassword, nameof(envVarForPassword));
+#else
+ if (envVarForId is null)
+ throw new ArgumentNullException(nameof(envVarForId));
+ if (envVarForId.Length == 0)
+ throw new ArgumentException(message: "must be non-empty string", paramName: nameof(envVarForId));
+
+ if (envVarForPassword is null)
+ throw new ArgumentNullException(nameof(envVarForPassword));
+ if (envVarForPassword.Length == 0)
+ throw new ArgumentException(message: "must be non-empty string", paramName: nameof(envVarForPassword));
+#endif
+
+ this.envVarForId = envVarForId;
+ this.envVarForPassword = envVarForPassword;
+ }
+
+ IRouteBCredential IRouteBCredentialProvider.GetCredential(IRouteBCredentialIdentity identity) => this;
+
+ void IDisposable.Dispose() { /* nothing to do */ }
+
+ void IRouteBCredential.WriteIdTo(IBufferWriter<byte> buffer)
+ => WriteEnvVar(envVarForId, buffer);
+
+ void IRouteBCredential.WritePasswordTo(IBufferWriter<byte> buffer)
+ => WriteEnvVar(envVarForPassword, buffer);
+
+ private static void WriteEnvVar(string variable, IBufferWriter<byte> buffer)
+ {
+ if (buffer is null)
+ throw new ArgumentNullException(nameof(buffer));
+
+ // TODO: read env var to buffer and clean it after write
+ var value = Environment.GetEnvironmentVariable(variable);
+
+ if (string.IsNullOrEmpty(value))
+ throw new InvalidOperationException($"environment variable '{variable}' is not set or is empty");
+
+ var bytesWritten = Encoding.ASCII.GetBytes(
+ value,
+ buffer.GetSpan(Encoding.ASCII.GetByteCount(value))
+ );
+
+ buffer.Advance(bytesWritten);
+ }
+}
diff --git a/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.csproj b/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.csproj
index 85b0cee..ea0f3a6 100644
--- a/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.csproj
+++ b/src/Smdn.Net.EchonetLite.RouteB/Smdn.Net.EchonetLite.RouteB.csproj
@@ -7,20 +7,18 @@ SPDX-License-Identifier: MIT
<PropertyGroup>
<TargetFrameworks>netstandard2.1;net6.0;net8.0</TargetFrameworks>
<VersionPrefix>2.0.0</VersionPrefix>
- <VersionSuffix>preview1</VersionSuffix>
+ <VersionSuffix>preview2</VersionSuffix>
+ <!-- <PackageValidationBaselineVersion>2.0.0</PackageValidationBaselineVersion> -->
<Nullable>enable</Nullable>
- <GenerateDocumentationFile>true</GenerateDocumentationFile>
- <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
- <NoWarn>CS1591;$(NoWarn)</NoWarn> <!-- CS1591: Missing XML comment for publicly visible type or member 'Type_or_Member' -->
<RootNamespace/> <!-- empty the root namespace so that the namespace is determined only by the directory name, for code style rule IDE0030 -->
+ <NoWarn>CS1591;$(NoWarn)</NoWarn> <!-- CS1591: Missing XML comment for publicly visible type or member 'Type_or_Member' -->
</PropertyGroup>
<PropertyGroup Label="assembly attributes">
- <Authors>smdn</Authors>
- <Copyright>Copyright © 2024 smdn.</Copyright>
<Description>
<![CDATA[スマート電力量メータとの情報伝達手段である「Bルート」を介してECHONET Lite規格の通信を扱うための抽象クラス`RouteBEchonetLiteHandler`を提供します。 また、その際に使用される認証情報を扱うための抽象インターフェイス`IRouteBCredential`を提供します。]]>
</Description>
+ <CopyrightYear>2024</CopyrightYear>
</PropertyGroup>
<PropertyGroup Label="package properties">
@@ -29,8 +27,8 @@ SPDX-License-Identifier: MIT
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
- <ProjectOrPackageReference ReferencePackageVersion="2.0.0-preview1" Include="..\Smdn.Net.EchonetLite.Transport\Smdn.Net.EchonetLite.Transport.csproj" />
+ <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
+ <ProjectOrPackageReference Include="$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)..\Smdn.Net.EchonetLite.Transport\Smdn.Net.EchonetLite.Transport.csproj'))" />
</ItemGroup>
<Target Name="GenerateReadmeFileContent">
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment