From 55a62e3a4be521efd4d0dae46cc06adea55cde25 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sat, 10 May 2025 01:01:28 -0300 Subject: [PATCH] Initial CLI with basic command for help/sponsoring --- .netconfig | 5 + Extensions.AI.sln | 14 ++ src/Directory.props | 2 + src/Directory.targets | 4 - src/dotnet-meai/App.cs | 72 ++++++++ src/dotnet-meai/AppExtensions.cs | 108 ++++++++++++ src/dotnet-meai/Program.cs | 93 ++++++++++ src/dotnet-meai/Properties/.gitignore | 1 + src/dotnet-meai/Sponsors/App.cs | 12 ++ src/dotnet-meai/Sponsors/CheckCommand.cs | 90 ++++++++++ src/dotnet-meai/Sponsors/SponsorManifest.cs | 160 ++++++++++++++++++ src/dotnet-meai/Sponsors/Sponsors.Designer.cs | 135 +++++++++++++++ src/dotnet-meai/Sponsors/Sponsors.es-AR.resx | 141 +++++++++++++++ src/dotnet-meai/Sponsors/Sponsors.es.resx | 144 ++++++++++++++++ src/dotnet-meai/Sponsors/Sponsors.resx | 144 ++++++++++++++++ .../Sponsors/SponsorsAppExtensions.cs | 37 ++++ src/dotnet-meai/Sponsors/SyncCommand.cs | 29 ++++ src/dotnet-meai/Sponsors/ViewCommand.cs | 26 +++ src/dotnet-meai/TypeRegistrar.cs | 59 +++++++ src/dotnet-meai/dotnet-meai.csproj | 81 +++++++++ 20 files changed, 1353 insertions(+), 4 deletions(-) create mode 100644 src/dotnet-meai/App.cs create mode 100644 src/dotnet-meai/AppExtensions.cs create mode 100644 src/dotnet-meai/Program.cs create mode 100644 src/dotnet-meai/Properties/.gitignore create mode 100644 src/dotnet-meai/Sponsors/App.cs create mode 100644 src/dotnet-meai/Sponsors/CheckCommand.cs create mode 100644 src/dotnet-meai/Sponsors/SponsorManifest.cs create mode 100644 src/dotnet-meai/Sponsors/Sponsors.Designer.cs create mode 100644 src/dotnet-meai/Sponsors/Sponsors.es-AR.resx create mode 100644 src/dotnet-meai/Sponsors/Sponsors.es.resx create mode 100644 src/dotnet-meai/Sponsors/Sponsors.resx create mode 100644 src/dotnet-meai/Sponsors/SponsorsAppExtensions.cs create mode 100644 src/dotnet-meai/Sponsors/SyncCommand.cs create mode 100644 src/dotnet-meai/Sponsors/ViewCommand.cs create mode 100644 src/dotnet-meai/TypeRegistrar.cs create mode 100644 src/dotnet-meai/dotnet-meai.csproj diff --git a/.netconfig b/.netconfig index 3b6edfa..d621926 100644 --- a/.netconfig +++ b/.netconfig @@ -141,3 +141,8 @@ etag = ec1645067cc2319c2ce3304900c260eb8ec700d50b6d8d62285914a6c96e01f9 weak +[file "src/dotnet-meai/Sponsors/SponsorManifest.cs"] + url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorManifest.cs + sha = a755e4be0f7cb73cfde208857e28f7cfeba2dcc3 + etag = 55ef89e8441156541c1c74a50675b7f56633b56493031f0ffa877460839e3536 + weak diff --git a/Extensions.AI.sln b/Extensions.AI.sln index 7af11f4..ffed918 100644 --- a/Extensions.AI.sln +++ b/Extensions.AI.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI", "src\AI\AI.csproj", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI.Tests", "src\AI.Tests\AI.Tests.csproj", "{3553B2FB-B06C-4766-93FD-1B7004761080}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-meai", "src\dotnet-meai\dotnet-meai.csproj", "{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +43,18 @@ Global {3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x64.Build.0 = Release|Any CPU {3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x86.ActiveCfg = Release|Any CPU {3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x86.Build.0 = Release|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x64.ActiveCfg = Debug|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x64.Build.0 = Debug|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x86.ActiveCfg = Debug|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x86.Build.0 = Debug|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|Any CPU.Build.0 = Release|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x64.ActiveCfg = Release|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x64.Build.0 = Release|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x86.ActiveCfg = Release|Any CPU + {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Directory.props b/src/Directory.props index 042a953..caa219a 100644 --- a/src/Directory.props +++ b/src/Directory.props @@ -5,6 +5,8 @@ Devlooped.Extensions.AI enable 6eb457f9-16bc-49c5-81f2-33399b254e04 + + https://api.nuget.org/v3/index.json;https://pkg.kzu.app/index.json \ No newline at end of file diff --git a/src/Directory.targets b/src/Directory.targets index 2b17673..4de98b5 100644 --- a/src/Directory.targets +++ b/src/Directory.targets @@ -1,7 +1,3 @@ - - - - \ No newline at end of file diff --git a/src/dotnet-meai/App.cs b/src/dotnet-meai/App.cs new file mode 100644 index 0000000..3c34a41 --- /dev/null +++ b/src/dotnet-meai/App.cs @@ -0,0 +1,72 @@ +using System.Diagnostics.CodeAnalysis; +using Devlooped.Sponsors; +using GitCredentialManager; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Devlooped.Extensions.AI; + +public static class App +{ + static readonly CancellationTokenSource shutdownCancellation = new(); + + static App() => Console.CancelKeyPress += (s, e) => shutdownCancellation.Cancel(); + + /// + /// Whether the CLI app is not interactive (i.e. part of a script run, + /// running in CI, or in a non-interactive user session). + /// + public static bool IsNonInteractive => !Environment.UserInteractive + || Console.IsInputRedirected + || Console.IsOutputRedirected; + + public static CommandApp Create() => Create(out _); + + public static CommandApp Create([NotNull] out ITypeRegistrar registrar) + => Create(new ServiceCollection(), out registrar); + + public static CommandApp Create(IAnsiConsole console) => Create(console, out _); + + public static CommandApp Create(IAnsiConsole console, [NotNull] out ITypeRegistrar registrar) + { +#pragma warning disable DDI001 + var app = Create(new ServiceCollection().AddSingleton(console), out registrar); +#pragma warning restore DDI001 + app.Configure(config => config.ConfigureConsole(console)); + return app; + } + + static CommandApp Create(IServiceCollection collection, [NotNull] out ITypeRegistrar registrar) + { + var config = new ConfigurationBuilder() + .AddEnvironmentVariables() +#if DEBUG + .AddUserSecrets() +#endif + .Build(); + + collection.AddSingleton(config) + .AddSingleton(_ => config) + .AddSingleton(_ => CredentialManager.Create(ThisAssembly.Project.PackageId)); + + collection.AddSingleton(shutdownCancellation); + collection.AddServices(); + collection.ConfigureSponsors(); + + registrar = new TypeRegistrar(collection); + var app = new CommandApp(registrar); + + app.Configure(config => + { + // Allows emitting help markdown on build + if (Environment.GetEnvironmentVariables().Contains("NO_COLOR")) + config.Settings.HelpProviderStyles = null; + }); + + app.UseSponsors(); + + return app; + } +} diff --git a/src/dotnet-meai/AppExtensions.cs b/src/dotnet-meai/AppExtensions.cs new file mode 100644 index 0000000..3a6aadb --- /dev/null +++ b/src/dotnet-meai/AppExtensions.cs @@ -0,0 +1,108 @@ +using DotNetConfig; +using NuGet.Configuration; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Devlooped.Extensions.AI; + +static class AppExtensions +{ + /// + /// Runs the app and update check in paralell, and renders any update messages if available + /// after the command app completes. + /// + public static async Task RunWithUpdatesAsync(this ICommandApp app, string[] args) + { + var updates = Task.Run(() => GetUpdatesAsync(args)); + var exit = await app.RunAsync(args); + + if (await updates is { Length: > 0 } messages) + { + foreach (var message in messages) + AnsiConsole.MarkupLine(message); + } + + return exit; + } + + /// + /// Checks for updates to the tool and shows them if available. + /// + public static async Task ShowUpdatesAsync(this ICommandApp app, string[] args) + { + if (await GetUpdatesAsync(args, true) is { Length: > 0 } messages) + { + foreach (var message in messages) + AnsiConsole.MarkupLine(message); + } + } + + /// + /// Shows the app version, build date and release link. + /// + /// + public static void ShowVersion(this ICommandApp app) + { + AnsiConsole.MarkupLine($"{ThisAssembly.Project.ToolCommandName} version [lime]{ThisAssembly.Project.Version}[/] ({ThisAssembly.Project.BuildDate})"); + AnsiConsole.MarkupLine($"[link]{ThisAssembly.Git.Url}/releases/tag/{ThisAssembly.Project.BuildRef}[/]"); + } + + static async Task GetUpdatesAsync(string[] args, bool forced = false) + { + if (args.Contains("-u") || args.Contains("--unattended")) + return []; + + var config = Config.Build(ConfigLevel.Global).GetSection(ThisAssembly.Project.ToolCommandName); + + // Check once a day max + if (!forced) + { + var lastCheck = config.GetDateTime("checked") ?? DateTime.UtcNow.AddDays(-2); + // if it's been > 24 hours since the last check, we'll check again + if (lastCheck > DateTime.UtcNow.AddDays(-1)) + return []; + } + + // We check from a different feed in this case. + var civersion = ThisAssembly.Project.VersionPrefix.StartsWith("42.42."); + + var providers = Repository.Provider.GetCoreV3(); + var repository = new SourceRepository(new PackageSource( + // use CI feed rather than production feed depending on which version we're using + civersion && !string.IsNullOrEmpty(ThisAssembly.Project.SLEET_FEED_URL) ? + ThisAssembly.Project.SLEET_FEED_URL : + "https://api.nuget.org/v3/index.json"), providers); + var resource = await repository.GetResourceAsync(); + var localVersion = new NuGetVersion(ThisAssembly.Project.Version); + // Only update to stable versions, not pre-releases + var metadata = await resource.GetMetadataAsync(ThisAssembly.Project.PackageId, includePrerelease: false, false, + new SourceCacheContext + { + NoCache = true, + RefreshMemoryCache = true, + }, + NuGet.Common.NullLogger.Instance, CancellationToken.None); + + var update = metadata + .Select(x => x.Identity) + .Where(x => x.Version > localVersion) + .OrderByDescending(x => x.Version) + .Select(x => x.Version) + .FirstOrDefault(); + + config.SetDateTime("checked", DateTime.UtcNow); + + if (update != null) + { + return [ + $"There is a new version of [yellow]{ThisAssembly.Project.PackageId}[/]: [dim]v{localVersion.ToNormalizedString()}[/] -> [lime]v{update.ToNormalizedString()}[/]", + $"Update with: [yellow]dotnet[/] tool update -g {ThisAssembly.Project.PackageId}" + + (civersion ? $" --source {ThisAssembly.Project.SLEET_FEED_URL}" : ""), + ]; + } + + return []; + } +} diff --git a/src/dotnet-meai/Program.cs b/src/dotnet-meai/Program.cs new file mode 100644 index 0000000..15d6cd5 --- /dev/null +++ b/src/dotnet-meai/Program.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Devlooped.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +// Some users reported not getting emoji on Windows, so we force UTF-8 encoding. +// This not great, but I couldn't find a better way to do it. +if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8; + +args = [.. ExpandResponseFiles(args)]; + +// Alias -? to -h for help +if (args.Contains("-?")) + args = [.. args.Select(x => x == "-?" ? "-h" : x)]; + +if (args.Contains("--debug")) +{ + Debugger.Launch(); + args = [.. args.Where(x => x != "--debug")]; +} + +var app = App.Create(out var registrar); + +#if DEBUG +app.Configure(c => c.PropagateExceptions()); +#else +if (args.Contains("--exceptions")) +{ + app.Configure(c => c.PropagateExceptions()); + args = [.. args.Where(x => x != "--exceptions")]; +} +#endif + +app.Configure(config => config.SetApplicationName(ThisAssembly.Project.ToolCommandName)); + +if (args.Contains("--version")) +{ + app.ShowVersion(); + await app.ShowUpdatesAsync(args); + return 0; +} + +var result = 0; + +#if DEBUG +result = await app.RunAsync(args); +#else +result = await app.RunWithUpdatesAsync(args); +#endif + +// --quiet does not report sponsor tier on every run. +await ((IServiceProvider)registrar).GetRequiredService().ExecuteAsync( + new CommandContext(["--quiet"], RemainingArguments.Empty, "check", null), + new Devlooped.Sponsors.CheckCommand.CheckSettings { Quiet = true }); + +return result; + +static IEnumerable ExpandResponseFiles(IEnumerable args) +{ + foreach (var arg in args) + { + if (arg.StartsWith('@')) + { + var filePath = arg[1..]; + + if (!File.Exists(filePath)) + throw new FileNotFoundException($"Response file not found: {filePath}"); + + foreach (var line in File.ReadAllLines(filePath)) + { + yield return line; + } + } + else + { + yield return arg; + } + } +} + +class RemainingArguments : IRemainingArguments +{ + public static IRemainingArguments Empty { get; } = new RemainingArguments(); + + RemainingArguments() { } + + public ILookup Parsed => Enumerable.Empty().ToLookup(x => x, x => default(string)); + + public IReadOnlyList Raw => []; +} \ No newline at end of file diff --git a/src/dotnet-meai/Properties/.gitignore b/src/dotnet-meai/Properties/.gitignore new file mode 100644 index 0000000..8392c90 --- /dev/null +++ b/src/dotnet-meai/Properties/.gitignore @@ -0,0 +1 @@ +launchSettings.json \ No newline at end of file diff --git a/src/dotnet-meai/Sponsors/App.cs b/src/dotnet-meai/Sponsors/App.cs new file mode 100644 index 0000000..c0f0cdf --- /dev/null +++ b/src/dotnet-meai/Sponsors/App.cs @@ -0,0 +1,12 @@ +namespace Devlooped.Sponsors; + +public static class App +{ + /// + /// Whether the CLI app is not interactive (i.e. part of a script run, + /// running in CI, or in a non-interactive user session). + /// + public static bool IsNonInteractive => !Environment.UserInteractive + || Console.IsInputRedirected + || Console.IsOutputRedirected; +} diff --git a/src/dotnet-meai/Sponsors/CheckCommand.cs b/src/dotnet-meai/Sponsors/CheckCommand.cs new file mode 100644 index 0000000..ae4f1d9 --- /dev/null +++ b/src/dotnet-meai/Sponsors/CheckCommand.cs @@ -0,0 +1,90 @@ +using System.ComponentModel; +using DotNetConfig; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; +using Spectre.Console.Cli; +using static Devlooped.Sponsors.CheckCommand; +using static ThisAssembly.Strings; + +namespace Devlooped.Sponsors; + +[Description("Checks the current sponsorship status with [lime]devlooped[/], entirely offline")] +[Service] +public class CheckCommand(Config config, Lazy sync, IAnsiConsole console) : AsyncCommand +{ + public class CheckSettings : CommandSettings + { + [CommandOption("-q|--quiet", IsHidden = true)] + public bool Quiet { get; set; } + } + + public override async Task ExecuteAsync(CommandContext context, CheckSettings settings) + { + // Don't render anything if not interactive, so we don't disrupt usage in CI for example. + // In GH actions, console input/output is redirected, for example, and output is redirected + // when using the app in a powershell pipeline or capturing its output in a variable. + if (App.IsNonInteractive) + return 0; + + // Check if we have a manifest at all + var jwtPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".sponsorlink", "github", "devlooped.jwt"); + + var url = ThisAssembly.Git.Url; + if (url.EndsWith(".git")) + url = url[0..^4]; + var project = $"[blueviolet][link={url}]{ThisAssembly.Info.Product}[/][/]"; + var link = "[link=https://github.com/devlooped/#sponsorlink]devlooped[/]"; + + if (!System.IO.File.Exists(jwtPath)) + return MarkupLine(Unknown.Message(project, link)); + + var manifest = SponsorLink.GetManifest("devlooped", ThisAssembly.Metadata.Funding.GitHub.devlooped, true); + if (manifest.Status == ManifestStatus.Valid) + return 0; + + // If not valid and we can auto-sync, do it now. + if (config.GetBoolean("sponsorlink", "autosync") == true) + { + await sync.Value.ExecuteAsync(context, new() { Unattended = settings.Quiet }); + manifest = SponsorLink.GetManifest("devlooped", ThisAssembly.Metadata.Funding.GitHub.devlooped, true); + if (manifest.Status == ManifestStatus.Valid) + return 0; + } + + if (manifest.Status == ManifestStatus.Unknown || manifest.Status == ManifestStatus.Invalid) + return MarkupLine(Unknown.Message(project, link)); + + if (manifest.Status == ManifestStatus.Expired) + return MarkupLine(Expired.Message); + + if (settings.Quiet) + return 0; + + if (manifest.Principal.IsInRole("team")) + return MarkupLine(Team.Message(link)); + + if (manifest.Principal.IsInRole("user")) + return MarkupLine(ThisAssembly.Strings.Sponsor.Message(project)); + + if (manifest.Principal.IsInRole("contrib")) + return MarkupLine(Contributor.Message(link)); + + if (manifest.Principal.IsInRole("org")) + return MarkupLine(ThisAssembly.Strings.Sponsor.Message(project)); + + if (manifest.Principal.IsInRole("oss")) + return MarkupLine(ThisAssembly.Strings.OpenSource.Message); + + return MarkupLine(Unknown.Message(project, link)); + } + + int MarkupLine(string message) + { + console.WriteLine(); + console.MarkupLine(message); + console.WriteLine(); + return 0; + } +} diff --git a/src/dotnet-meai/Sponsors/SponsorManifest.cs b/src/dotnet-meai/Sponsors/SponsorManifest.cs new file mode 100644 index 0000000..b4aa9d7 --- /dev/null +++ b/src/dotnet-meai/Sponsors/SponsorManifest.cs @@ -0,0 +1,160 @@ +// +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.Sponsors; + +/// +/// The resulting status from validation. +/// +public enum ManifestStatus +{ + /// + /// The manifest couldn't be read at all. + /// + Unknown, + /// + /// The manifest was read and is valid (not expired and properly signed). + /// + Valid, + /// + /// The manifest was read but has expired. + /// + Expired, + /// + /// The manifest was read, but its signature is invalid. + /// + Invalid, +} + +/// +/// Represents the sponsorship status of a user. +/// +/// The status. +/// The principal potentially containing roles validated from the manifest. +/// The security token from the validated manifest. +public record SponsorManifest(ManifestStatus Status, ClaimsPrincipal Principal, SecurityToken? SecurityToken) +{ + /// + /// Whether the manifest is . + /// + public bool IsValid => Status == ManifestStatus.Valid; +} + +static partial class SponsorLink +{ + /// + /// Reads the local manifest (if present) for the specified sponsorable account and validates it + /// against the given JWK key. + /// + /// The sponsorable account to read. + /// The public key to validate the signature on the manifest JWT if found. + /// Whether to validate the manifest expiration. If , + /// an expired manifest will be reported as . The expiration date + /// can be checked in that case via the . + /// A manifest that represents the user status. + public static SponsorManifest GetManifest(string sponsorable, string jwk, bool validateExpiration = true) + { + var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".sponsorlink", "github", sponsorable + ".jwt"); + + if (!File.Exists(path)) + return new SponsorManifest(ManifestStatus.Unknown, new ClaimsPrincipal(), null); + + return ParseManifest(File.ReadAllText(path), jwk, validateExpiration); + } + + internal static SponsorManifest ParseManifest(string jwt, string jwk, bool validateExpiration) + { + var status = Validate(jwt, jwk, out var token, out var identity, validateExpiration); + + if (status == ManifestStatus.Unknown || identity == null) + return new SponsorManifest(status, new ClaimsPrincipal(), token); + + return new SponsorManifest(status, new JwtRolesPrincipal(identity), token); + } + + /// + /// Validates the manifest signature and optional expiration. + /// + /// The JWT to validate. + /// The key to validate the manifest signature with. + /// Except when returning , returns the security token read from the JWT, even if signature check failed. + /// The associated claims, only when return value is not . + /// Whether to check for expiration. + /// The status of the validation. + public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsIdentity? identity, bool validateExpiration) + { + token = default; + identity = default; + + SecurityKey key; + try + { + key = JsonWebKey.Create(jwk); + } + catch (ArgumentException) + { + return ManifestStatus.Unknown; + } + + var handler = new JsonWebTokenHandler { MapInboundClaims = false }; + + if (!handler.CanReadToken(jwt)) + return ManifestStatus.Unknown; + + var validation = new TokenValidationParameters + { + RequireExpirationTime = false, + ValidateLifetime = false, + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + RoleClaimType = "roles", + NameClaimType = "sub", + }; + + var result = handler.ValidateTokenAsync(jwt, validation).Result; + if (!result.IsValid || result.Exception != null) + { + if (result.Exception is SecurityTokenInvalidSignatureException) + { + var jwtToken = handler.ReadJsonWebToken(jwt); + token = jwtToken; + identity = new ClaimsIdentity(jwtToken.Claims); + return ManifestStatus.Invalid; + } + else + { + var jwtToken = handler.ReadJsonWebToken(jwt); + token = jwtToken; + identity = new ClaimsIdentity(jwtToken.Claims); + return ManifestStatus.Invalid; + } + } + + token = result.SecurityToken; + identity = new ClaimsIdentity(result.ClaimsIdentity.Claims, "JWT"); + + if (validateExpiration && token.ValidTo == DateTime.MinValue) + return ManifestStatus.Invalid; + + // The sponsorable manifest does not have an expiration time. + if (validateExpiration && token.ValidTo < DateTimeOffset.UtcNow) + return ManifestStatus.Expired; + + return ManifestStatus.Valid; + } + + class JwtRolesPrincipal(ClaimsIdentity identity) : ClaimsPrincipal([identity]) + { + public override bool IsInRole(string role) => HasClaim("roles", role) || base.IsInRole(role); + } +} diff --git a/src/dotnet-meai/Sponsors/Sponsors.Designer.cs b/src/dotnet-meai/Sponsors/Sponsors.Designer.cs new file mode 100644 index 0000000..614e3c8 --- /dev/null +++ b/src/dotnet-meai/Sponsors/Sponsors.Designer.cs @@ -0,0 +1,135 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Devlooped.Extensions.AI.Sponsors { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Sponsors { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Sponsors() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Devlooped.Extensions.AI.Sponsors.Sponsors", typeof(Sponsors).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Thank you for being part of team {0} with your contributions 💟!. + /// + internal static string Contributor_Message { + get { + return ResourceManager.GetString("Contributor_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sponsor status has expired and automatic sync has not been enabled.. + /// + internal static string Expired_Message { + get { + return ResourceManager.GetString("Expired_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sponsor status needs periodic updating and automatic sync has not been enabled.. + /// + internal static string Expiring_Message { + get { + return ResourceManager.GetString("Expiring_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Grace period ends in {0} days. Enjoy and please consider supporting {1} by sponsoring {2} 🙏. + /// + internal static string Grace_Message { + get { + return ResourceManager.GetString("Grace_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Thank you for being an open source author 💟!. + /// + internal static string OpenSource_Message { + get { + return ResourceManager.GetString("OpenSource_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Thank you for supporting {0} with your sponsorship 💟!. + /// + internal static string Sponsor_Message { + get { + return ResourceManager.GetString("Sponsor_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Team {0} 💪. + /// + internal static string Team_Message { + get { + return ResourceManager.GetString("Team_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please consider supporting {0} by sponsoring {1} 🙏. + /// + internal static string Unknown_Message { + get { + return ResourceManager.GetString("Unknown_Message", resourceCulture); + } + } + } +} diff --git a/src/dotnet-meai/Sponsors/Sponsors.es-AR.resx b/src/dotnet-meai/Sponsors/Sponsors.es-AR.resx new file mode 100644 index 0000000..d2a815d --- /dev/null +++ b/src/dotnet-meai/Sponsors/Sponsors.es-AR.resx @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Gracias por ser parte del equipo {0} con tu contribución 💟! + + + El estado de patrocino ha expirado y la sincronización automática no está habilitada. + + + El estado de patrocino necesita actualización periódica y la sincronización automática no está habilitada. + + + El período de prueba finaliza en {0} día(s). Disfrutá y por favor considerá apoyar {1} patrocinando {2} 🙏 + + + Gracias por apoyar a {0} con tu patrocinio 💟! + + + + + + Por favor considerá apoyar {0} patrocinando {1} 🙏 + + \ No newline at end of file diff --git a/src/dotnet-meai/Sponsors/Sponsors.es.resx b/src/dotnet-meai/Sponsors/Sponsors.es.resx new file mode 100644 index 0000000..7da943b --- /dev/null +++ b/src/dotnet-meai/Sponsors/Sponsors.es.resx @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Gracias por ser parte del equipo {0} con tu contribución 💟! + + + El estado de patrocino ha expirado y la sincronización automática no está habilitada. + + + El estado de patrocino necesita actualización periódica y la sincronización automática no está habilitada. + + + El período de prueba finaliza en {0} día(s). Disfrute y por favor considere apoyar {1} patrocinando {2} 🙏 + + + Gracias por ser autor de código abierto 💟! + + + Gracias por apoyar a {0} con tu patrocinio 💟! + + + + + + Por favor considere apoyar {0} patrocinando {1} 🙏 + + \ No newline at end of file diff --git a/src/dotnet-meai/Sponsors/Sponsors.resx b/src/dotnet-meai/Sponsors/Sponsors.resx new file mode 100644 index 0000000..0bdc13a --- /dev/null +++ b/src/dotnet-meai/Sponsors/Sponsors.resx @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Thank you for being part of team {0} with your contributions 💟! + + + Sponsor status has expired and automatic sync has not been enabled. + + + Sponsor status needs periodic updating and automatic sync has not been enabled. + + + Grace period ends in {0} days. Enjoy and please consider supporting {1} by sponsoring {2} 🙏 + + + Thank you for being an open source author 💟! + + + Thank you for supporting {0} with your sponsorship 💟! + + + Team {0} 💪 + + + Please consider supporting {0} by sponsoring {1} 🙏 + + \ No newline at end of file diff --git a/src/dotnet-meai/Sponsors/SponsorsAppExtensions.cs b/src/dotnet-meai/Sponsors/SponsorsAppExtensions.cs new file mode 100644 index 0000000..bc90f22 --- /dev/null +++ b/src/dotnet-meai/Sponsors/SponsorsAppExtensions.cs @@ -0,0 +1,37 @@ +using DotNetConfig; +using Spectre.Console.Cli; + +namespace Devlooped.Sponsors; + +static class SponsorsAppExtensions +{ + public static ICommandApp UseSponsors(this ICommandApp app) + { + app.Configure(config => + { + config.AddBranch("sponsor", group => + { + group.AddCommand("check"); + group.AddCommand("config"); + group.AddCommand("view"); + group.AddCommand("sync"); + group.AddCommand("welcome").IsHidden(); + }); + }); + return app; + } + + public static bool ShouldRunWelcome(this CommandContext context, Config config, ToSSettings settings) + { + // If we don't have ToS acceptance, we don't run any command other than welcome. + var tos = config.TryGetBoolean("sponsorlink", "tos", out var completed) && completed; + if (!tos && settings.ToS == true) + { + // Implicit acceptance on first run of another tool, like `sponsor sync --tos` + new ConfigCommand(config).Execute(context, new ConfigCommand.ConfigSettings { ToS = true, Quiet = true }); + return false; + } + + return tos == false; + } +} \ No newline at end of file diff --git a/src/dotnet-meai/Sponsors/SyncCommand.cs b/src/dotnet-meai/Sponsors/SyncCommand.cs new file mode 100644 index 0000000..aecc833 --- /dev/null +++ b/src/dotnet-meai/Sponsors/SyncCommand.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using DotNetConfig; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace Devlooped.Sponsors; + +[Description("Synchronizes your sponsorship manifest for [lime]devlooped[/]")] +[Service] +public class DevloopedSyncCommand(Config config, IGraphQueryClient client, IGitHubAppAuthenticator authenticator, IHttpClientFactory httpFactory) + : SyncCommand(config, client, authenticator, httpFactory) +{ + public class DevloopedSyncSettings : SyncSettings, ISponsorableSettings + { + public string[]? Sponsorable { get; set; } = ["devlooped"]; + } + + public override async Task ExecuteAsync(CommandContext context, DevloopedSyncSettings settings) + { + if (context.ShouldRunWelcome(config, settings)) + { + if (new WelcomeCommand(config).Execute(context, new WelcomeCommand.WelcomeSettings { ToS = settings.ToS }) is var result && result != 0) + return result; + } + + settings.Sponsorable = ["devlooped"]; + return await base.ExecuteAsync(context, settings); + } +} diff --git a/src/dotnet-meai/Sponsors/ViewCommand.cs b/src/dotnet-meai/Sponsors/ViewCommand.cs new file mode 100644 index 0000000..ba00142 --- /dev/null +++ b/src/dotnet-meai/Sponsors/ViewCommand.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; +using DotNetConfig; +using Spectre.Console.Cli; + +namespace Devlooped.Sponsors; + +[Description("Validates and displays your sponsor manifest for [lime]devlooped[/], if present")] +class DevloopedViewCommand(Config config, IHttpClientFactory http) : ViewCommand(http) +{ + public class DevloopedViewSettings : ViewSettings, ISponsorableSettings + { + public string[]? Sponsorable { get; set; } = ["devlooped"]; + } + + public override async Task ExecuteAsync(CommandContext context, DevloopedViewSettings settings) + { + if (context.ShouldRunWelcome(config, settings)) + { + if (new WelcomeCommand(config).Execute(context, new WelcomeCommand.WelcomeSettings { ToS = settings.ToS }) is var result && result != 0) + return result; + } + + settings.Sponsorable = ["devlooped"]; + return await base.ExecuteAsync(context, settings); + } +} diff --git a/src/dotnet-meai/TypeRegistrar.cs b/src/dotnet-meai/TypeRegistrar.cs new file mode 100644 index 0000000..4f2dbaa --- /dev/null +++ b/src/dotnet-meai/TypeRegistrar.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace Devlooped.Extensions.AI; + +#pragma warning disable DDI001 +public sealed class TypeRegistrar(IServiceCollection? builder = default) : ITypeRegistrar, IServiceProvider +{ + readonly IServiceCollection builder = builder ?? new ServiceCollection(); + IServiceProvider? services; + + public IServiceCollection Services => builder; + + public object? GetService(Type serviceType) => Build().GetService(serviceType); + + public void Register(Type service, Type implementation) + { + ResetServiceProvider(); + builder.AddSingleton(service, implementation); + } + + public void RegisterInstance(Type service, object implementation) + { + ResetServiceProvider(); + builder.AddSingleton(service, implementation); + } + + public void RegisterLazy(Type service, Func func) + { + ThrowIfNull(func); + ResetServiceProvider(); + builder.AddSingleton(service, (provider) => func()); + } + + IServiceProvider Build() => services ??= builder.BuildServiceProvider(); + + ITypeResolver ITypeRegistrar.Build() => new TypeResolver(this); + + void ResetServiceProvider() + { + // Reset the service provider + lock (this) + { + (services as IDisposable)?.Dispose(); + services = null; + } + } + + sealed class TypeResolver(TypeRegistrar registrar) : ITypeResolver, IServiceProvider, IDisposable + { + IServiceProvider services = registrar.Build(); + + public object? Resolve(Type? type) => type == null ? null : services.GetService(type); + + public void Dispose() => registrar.ResetServiceProvider(); + + public object? GetService(Type serviceType) => services.GetService(serviceType); + } +} \ No newline at end of file diff --git a/src/dotnet-meai/dotnet-meai.csproj b/src/dotnet-meai/dotnet-meai.csproj new file mode 100644 index 0000000..f5b0ced --- /dev/null +++ b/src/dotnet-meai/dotnet-meai.csproj @@ -0,0 +1,81 @@ + + + + Exe + net8.0 + false + dotnet-meai + A Microsoft.Extension.AI-powered CLI + dotnet-meai + meai + ai dotnet-tool + + $([System.DateTime]::Now.ToString("yyyy-MM-dd")) + $(GITHUB_REF_NAME) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + True + Sponsors.resx + + + + + + ResXFileCodeGenerator + Sponsors.Designer.cs + + + + + + + + + + + $([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)devlooped.jwk')) + + + + + + +