diff --git a/cli/Directory.Build.props b/cli/Directory.Build.props new file mode 100644 index 000000000..c9c5763c6 --- /dev/null +++ b/cli/Directory.Build.props @@ -0,0 +1,9 @@ + + + + Duende.$(MSBuildProjectName) + Duende.$(MSBuildProjectName) + Duende.$(MSBuildProjectName) + + + diff --git a/cli/Directory.Build.targets b/cli/Directory.Build.targets new file mode 100644 index 000000000..60390bb19 --- /dev/null +++ b/cli/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/cli/cli-abstractions.slnf b/cli/cli-abstractions.slnf new file mode 100644 index 000000000..d469d1323 --- /dev/null +++ b/cli/cli-abstractions.slnf @@ -0,0 +1,8 @@ +{ + "solution": { + "path": "..\\products.slnx", + "projects": [ + "cli\\src\\Cli.PluginAbstractions\\Cli.PluginAbstractions.csproj" + ] + } +} diff --git a/cli/cli.slnf b/cli/cli.slnf new file mode 100644 index 000000000..11fbd1205 --- /dev/null +++ b/cli/cli.slnf @@ -0,0 +1,12 @@ +{ + "solution": { + "path": "..\\products.slnx", + "projects": [ + "cli\\src\\Cli.PluginAbstractions\\Cli.PluginAbstractions.csproj", + "cli\\src\\Cli\\Cli.csproj", + "cli\\testing\\Cli.TestPlugin\\Cli.TestPlugin.csproj", + "cli\\test\\Cli.IntegrationTests\\Cli.IntegrationTests.csproj", + "cli\\test\\Cli.Tests\\Cli.Tests.csproj" + ] + } +} diff --git a/cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj b/cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj new file mode 100644 index 000000000..433421fba --- /dev/null +++ b/cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj @@ -0,0 +1,13 @@ + + + + cli-abs- + 0.1 + + + + + + + + diff --git a/cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs b/cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs new file mode 100644 index 000000000..35b845761 --- /dev/null +++ b/cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs @@ -0,0 +1,45 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Cli.PluginAbstractions; + +/// +/// Assembly-level attribute that identifies the type implementing . +/// The CLI host scans loaded plugin assemblies for this attribute to discover plugin entry points. +/// +/// +/// +/// [assembly: CliPlugin(typeof(StorageCliPlugin))] +/// +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] +public sealed class CliPluginAttribute : Attribute +{ + /// + /// Gets the type that implements . + /// + public Type PluginType { get; } + + /// + /// Initializes a new instance of . + /// + /// The type implementing . + public CliPluginAttribute(Type pluginType) + { + ArgumentNullException.ThrowIfNull(pluginType); + + if (!typeof(ICliPlugin).IsAssignableFrom(pluginType)) + { + throw new ArgumentException( + $"Type '{pluginType.FullName}' does not implement {nameof(ICliPlugin)}.", nameof(pluginType)); + } + + if (pluginType.IsAbstract) + { + throw new ArgumentException( + $"Type '{pluginType.FullName}' is abstract and cannot be used as a plugin.", nameof(pluginType)); + } + + PluginType = pluginType; + } +} diff --git a/cli/src/Cli.PluginAbstractions/ICliPlugin.cs b/cli/src/Cli.PluginAbstractions/ICliPlugin.cs new file mode 100644 index 000000000..5c3cfadc3 --- /dev/null +++ b/cli/src/Cli.PluginAbstractions/ICliPlugin.cs @@ -0,0 +1,23 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.CommandLine; + +namespace Duende.Cli.PluginAbstractions; + +/// +/// Defines the contract for a CLI plugin that contributes commands to the duende tool. +/// +public interface ICliPlugin +{ + /// + /// Gets the name of the plugin (e.g. "storage"). Used as the subcommand name. + /// + string Name { get; } + + /// + /// Gets the that this plugin contributes to the CLI root command. + /// The returned command and all its subcommands are mounted directly onto the root. + /// + Command GetCommand(); +} diff --git a/cli/src/Cli.PluginAbstractions/IPluginContext.cs b/cli/src/Cli.PluginAbstractions/IPluginContext.cs new file mode 100644 index 000000000..0541a3b6b --- /dev/null +++ b/cli/src/Cli.PluginAbstractions/IPluginContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.Extensions.Logging; + +namespace Duende.Cli.PluginAbstractions; + +/// +/// Provides host services to CLI plugins at runtime. +/// +public interface IPluginContext +{ + /// + /// Gets a logger for the plugin to use. + /// + ILogger Logger { get; } + + /// + /// Gets the version of the duende CLI tool host. + /// + string HostVersion { get; } + + /// + /// Gets a cancellation token that is cancelled when the CLI is shutting down. + /// + CancellationToken CancellationToken { get; } +} diff --git a/cli/src/Cli/Cli.csproj b/cli/src/Cli/Cli.csproj new file mode 100644 index 000000000..ec79da0bd --- /dev/null +++ b/cli/src/Cli/Cli.csproj @@ -0,0 +1,27 @@ + + + + Exe + true + duende + Major + + $(NoWarn);CA1303 + + + + + + + + + + + + + + + + + + diff --git a/cli/src/Cli/Commands/PluginCacheHandler.cs b/cli/src/Cli/Commands/PluginCacheHandler.cs new file mode 100644 index 000000000..78a46e4dd --- /dev/null +++ b/cli/src/Cli/Commands/PluginCacheHandler.cs @@ -0,0 +1,51 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Cli.Plugins; + +using NuGet.Configuration; + +namespace Duende.Cli.Commands; + +/// +/// Handles the duende plugin cache clear command. +/// Removes cached Duende CLI plugin packages from the NuGet global packages folder. +/// +internal static class PluginCacheHandler +{ + internal static void Execute() + { + var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(Settings.LoadDefaultSettings(null)); + var cleared = 0; + + foreach (var (_, info) in PluginRegistry.KnownPlugins) + { +#pragma warning disable CA1308 // NuGet global packages folder uses lowercase package IDs by convention + var packageDir = Path.Combine(globalPackagesFolder, info.PackageId.ToLowerInvariant()); +#pragma warning restore CA1308 + + if (Directory.Exists(packageDir)) + { + try + { + Directory.Delete(packageDir, recursive: true); + Console.WriteLine($"Cleared cache for {info.PackageId}"); + cleared++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Warning: could not clear cache for {info.PackageId}: {ex.Message}"); + } + } + } + + if (cleared == 0) + { + Console.WriteLine("No cached plugins found."); + } + else + { + Console.WriteLine($"Cleared {cleared} cached plugin(s)."); + } + } +} diff --git a/cli/src/Cli/Commands/PluginListHandler.cs b/cli/src/Cli/Commands/PluginListHandler.cs new file mode 100644 index 000000000..932e64e54 --- /dev/null +++ b/cli/src/Cli/Commands/PluginListHandler.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Cli.Plugins; + +namespace Duende.Cli.Commands; + +/// +/// Handles the duende plugin list command. +/// Lists all known plugins and their resolved versions from project context. +/// +internal static class PluginListHandler +{ + internal static void Execute() + { + Console.WriteLine("Known plugins:"); + Console.WriteLine(); + Console.WriteLine($" {"Name",-15} {"Package",-35} {"Version",-15} {"Status"}"); + Console.WriteLine($" {"----",-15} {"-------",-35} {"-------",-15} {"------"}"); + + foreach (var (name, info) in PluginRegistry.KnownPlugins) + { + var resolution = ProjectContextScanner.Resolve(name); + var version = resolution?.Version ?? "(not detected)"; + var status = resolution is not null ? "resolved" : "no project context"; + + Console.WriteLine($" {name,-15} {info.PackageId,-35} {version,-15} {status}"); + } + + Console.WriteLine(); + Console.WriteLine("Tip: Run from a project directory to auto-detect plugin versions."); + } +} diff --git a/cli/src/Cli/Plugins/NuGetPluginResolver.cs b/cli/src/Cli/Plugins/NuGetPluginResolver.cs new file mode 100644 index 000000000..8cd6b04e6 --- /dev/null +++ b/cli/src/Cli/Plugins/NuGetPluginResolver.cs @@ -0,0 +1,162 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Packaging; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace Duende.Cli.Plugins; + +/// +/// Resolves and downloads CLI plugin packages from nuget.org using the NuGet global packages folder as cache. +/// +internal static class NuGetPluginResolver +{ + private static readonly string GlobalPackagesFolder = + SettingsUtility.GetGlobalPackagesFolder(Settings.LoadDefaultSettings(null)); + + /// + /// Ensures the plugin package is available in the NuGet global packages folder, + /// downloading it from nuget.org if not already cached. + /// + /// The resolved plugin package ID and version range (e.g. "7.2.*"). + /// Cancellation token. + /// The path to the plugin's primary assembly. + internal static async Task EnsureDownloadedAsync(PluginResolution resolution, Ct ct) + { + // Check if any version matching the range is already cached + var cachedPath = FindCachedAssembly(resolution.PackageId, resolution.Version); + if (cachedPath is not null) + { + return cachedPath; + } + + // Download from nuget.org + var repository = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json"); + var resource = await repository.GetResourceAsync(ct); + + using var cache = new SourceCacheContext(); + var versions = await resource.GetAllVersionsAsync(resolution.PackageId, cache, NullLogger.Instance, ct); + + if (!VersionRange.TryParse(resolution.Version, out var versionRange)) + { + throw new InvalidOperationException( + $"'{resolution.Version}' is not a valid NuGet version range for '{resolution.PackageId}'."); + } + + var bestMatch = versionRange.FindBestMatch(versions) + ?? throw new InvalidOperationException( + $"No version of '{resolution.PackageId}' matching '{resolution.Version}' was found on nuget.org."); + + Console.WriteLine($"Downloading {resolution.PackageId} {bestMatch}..."); + + using var packageStream = new MemoryStream(); + var downloaded = await resource.CopyNupkgToStreamAsync( + resolution.PackageId, bestMatch, packageStream, cache, NullLogger.Instance, ct); + + if (!downloaded) + { + throw new InvalidOperationException( + $"Failed to download '{resolution.PackageId}' {bestMatch} from nuget.org."); + } + + packageStream.Position = 0; + + // Extract to global packages folder — NuGet convention requires lowercase package IDs in path +#pragma warning disable CA1308 // NuGet global packages folder uses lowercase package IDs by convention + var packageDir = Path.Combine(GlobalPackagesFolder, resolution.PackageId.ToLowerInvariant(), bestMatch.ToString()); +#pragma warning restore CA1308 + + // Use a temp directory + atomic rename to avoid races between concurrent invocations + var tempDir = $"{packageDir}.{Path.GetRandomFileName()}"; + _ = Directory.CreateDirectory(tempDir); + + try + { + using var packageReader = new PackageArchiveReader(packageStream); + var tfmFolder = GetBestTfmFolder(packageReader); + + foreach (var file in packageReader.GetFiles(tfmFolder)) + { + var destPath = Path.Combine(tempDir, Path.GetFileName(file)); + using var fileStream = packageReader.GetStream(file); + using var dest = File.Create(destPath); + await fileStream.CopyToAsync(dest, ct); + } + + try + { + Directory.Move(tempDir, packageDir); + } + catch (IOException) when (Directory.Exists(packageDir)) + { + // Another process won the race — use their copy + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Clean up temp directory on any failure +#pragma warning disable CA1031 // Best-effort cleanup — must not mask the original exception + try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ } +#pragma warning restore CA1031 + throw; + } + + return FindAssemblyInDirectory(packageDir, resolution.PackageId) + ?? throw new InvalidOperationException( + $"Could not find plugin assembly in downloaded package '{resolution.PackageId}'."); + } + + private static string? FindCachedAssembly(string packageId, string versionRange) + { +#pragma warning disable CA1308 // NuGet global packages folder uses lowercase package IDs by convention + var packageDir = Path.Combine(GlobalPackagesFolder, packageId.ToLowerInvariant()); +#pragma warning restore CA1308 + if (!Directory.Exists(packageDir)) + { + return null; + } + + if (!VersionRange.TryParse(versionRange, out var range)) + { + return null; + } + + var installedVersions = Directory.GetDirectories(packageDir) + .Select(d => NuGetVersion.TryParse(Path.GetFileName(d), out var v) ? v : null) + .OfType(); + + var best = range.FindBestMatch(installedVersions); + if (best is null) + { + return null; + } + + var versionDir = Path.Combine(packageDir, best.ToString()); + return FindAssemblyInDirectory(versionDir, packageId); + } + + private static string? FindAssemblyInDirectory(string directory, string packageId) + { + var assemblyName = $"{packageId}.dll"; + var path = Path.Combine(directory, assemblyName); + return File.Exists(path) ? path : null; + } + + private static string GetBestTfmFolder(PackageArchiveReader reader) + { + // Pick the highest TFM version available in the package + var libItems = reader.GetLibItems().ToList(); + var preferred = libItems + .OrderByDescending(g => g.TargetFramework.Version) + .FirstOrDefault(); + + return preferred?.TargetFramework.GetShortFolderName() is { } tfm + ? $"lib/{tfm}" + : "lib"; + } +} diff --git a/cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs b/cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs new file mode 100644 index 000000000..a42da4d7e --- /dev/null +++ b/cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs @@ -0,0 +1,39 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Reflection; +using System.Runtime.Loader; + +namespace Duende.Cli.Plugins; + +/// +/// Isolated for a single plugin. +/// Loads plugin assemblies and their dependencies in isolation, while sharing +/// Duende.Cli.PluginAbstractions and System.CommandLine from the default context. +/// +internal sealed class PluginAssemblyLoadContext(string pluginAssemblyPath) : AssemblyLoadContext( + name: Path.GetFileNameWithoutExtension(pluginAssemblyPath), + isCollectible: true) +{ + private readonly AssemblyDependencyResolver _resolver = new(pluginAssemblyPath); + + // Assemblies shared between host and plugin — must not be loaded twice + private static readonly HashSet SharedAssemblies = + [ + "Duende.Cli.PluginAbstractions", + "Microsoft.Extensions.Logging.Abstractions", + "System.CommandLine", + ]; + + protected override Assembly? Load(AssemblyName assemblyName) + { + // Delegate shared assemblies to the default context + if (assemblyName.Name is not null && SharedAssemblies.Contains(assemblyName.Name)) + { + return null; // null = fall through to default context + } + + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + return assemblyPath is not null ? LoadFromAssemblyPath(assemblyPath) : null; + } +} diff --git a/cli/src/Cli/Plugins/PluginLoader.cs b/cli/src/Cli/Plugins/PluginLoader.cs new file mode 100644 index 000000000..9366e7142 --- /dev/null +++ b/cli/src/Cli/Plugins/PluginLoader.cs @@ -0,0 +1,54 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Runtime.Loader; +using Duende.Cli.PluginAbstractions; + +namespace Duende.Cli.Plugins; + +/// +/// Loads a plugin assembly from a file path into an isolated +/// and returns the implementation. +/// +internal static class PluginLoader +{ + internal static ICliPlugin Load(string assemblyPath) + { + var context = new PluginAssemblyLoadContext(assemblyPath); + var assembly = context.LoadFromAssemblyPath(assemblyPath); + + var attribute = assembly.GetCustomAttributes(typeof(CliPluginAttribute), false) + .OfType() + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Assembly '{assemblyPath}' does not have a [CliPlugin] attribute. " + + "Ensure the plugin assembly declares [assembly: CliPlugin(typeof(YourPlugin))]."); + + if (!typeof(ICliPlugin).IsAssignableFrom(attribute.PluginType)) + { + throw new InvalidOperationException( + $"Plugin type '{attribute.PluginType.FullName}' does not implement {nameof(ICliPlugin)}. " + + $"Ensure the type specified in [CliPlugin(typeof(...))] implements {nameof(ICliPlugin)}."); + } + + ICliPlugin instance; + try + { + instance = (ICliPlugin)(Activator.CreateInstance(attribute.PluginType) + ?? throw new InvalidOperationException( + $"Activator.CreateInstance returned null for '{attribute.PluginType.FullName}'.")); + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to create an instance of '{attribute.PluginType.FullName}'. " + + "Ensure the plugin type has a public parameterless constructor.", ex); + } + + return instance; + } +} diff --git a/cli/src/Cli/Plugins/PluginOrchestrator.cs b/cli/src/Cli/Plugins/PluginOrchestrator.cs new file mode 100644 index 000000000..28860a32c --- /dev/null +++ b/cli/src/Cli/Plugins/PluginOrchestrator.cs @@ -0,0 +1,65 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.CommandLine; + +namespace Duende.Cli.Plugins; + +/// +/// Orchestrates lazy plugin loading: resolves the plugin version from project context, +/// deployed assemblies, or explicit version flag, downloads via NuGet if not cached, +/// loads via AssemblyLoadContext, and re-invokes parsing. +/// +internal static class PluginOrchestrator +{ + internal static async Task LoadAndReInvokeAsync( + string pluginName, + string? pluginPath, + string? explicitVersion, + string[] originalArgs, + CancellationToken ct) + { + // Phase 1: resolve the plugin assembly path + string assemblyPath; + + if (pluginPath is not null) + { + // Local dev: load directly from path + assemblyPath = Path.GetFullPath(pluginPath); + } + else + { + // Try project context + deployed assembly scanning + var resolution = ProjectContextScanner.Resolve(pluginName); + + // Fallback to explicit --plugin-version + if (resolution is null && explicitVersion is not null) + { + resolution = ProjectContextScanner.ResolveExplicit(pluginName, explicitVersion); + } + + if (resolution is null) + { + throw new InvalidOperationException( + $"Could not determine the version for plugin '{pluginName}'. " + + "No project context (Directory.Packages.props / .csproj) or deployed anchor assembly was found. " + + "Use --plugin-version to specify the plugin version, " + + "or --plugin-path to load a plugin directly."); + } + + // Download from nuget.org if not already cached + assemblyPath = await NuGetPluginResolver.EnsureDownloadedAsync(resolution, ct); + } + + // Phase 2: load plugin via ALC + var plugin = PluginLoader.Load(assemblyPath); + + // Phase 3: build a fresh root with the real plugin command and re-invoke + var reInvokeRoot = new RootCommand(); + reInvokeRoot.Subcommands.Add(plugin.GetCommand()); + // InvokeAsync does not accept a CancellationToken in System.CommandLine 2.0.x +#pragma warning disable CA2016 // CancellationToken not supported by this overload + return await reInvokeRoot.Parse(originalArgs).InvokeAsync(); +#pragma warning restore CA2016 + } +} diff --git a/cli/src/Cli/Plugins/PluginPreloader.cs b/cli/src/Cli/Plugins/PluginPreloader.cs new file mode 100644 index 000000000..676754140 --- /dev/null +++ b/cli/src/Cli/Plugins/PluginPreloader.cs @@ -0,0 +1,153 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.CommandLine; + +namespace Duende.Cli.Plugins; + +/// +/// Detects which plugin the user is invoking from raw args and eagerly loads it +/// before the command tree is built. This ensures duende storage --help +/// shows real subcommands from the plugin rather than the stub help. +/// +internal static class PluginPreloader +{ + private const string PluginPathFlag = "--plugin-path"; + private const string VersionFlag = "--plugin-version"; + + /// + /// Scans for a known plugin name as the first + /// non-option argument (e.g. "storage" in "duende storage --help"). + /// Returns the plugin name or null if none detected. + /// + internal static string? DetectRequestedPlugin(string[] args) + { + var skipNext = false; + foreach (var arg in args) + { + if (skipNext) + { + skipNext = false; + continue; + } + + // Skip option flags that take a value + if (arg is PluginPathFlag or VersionFlag) + { + skipNext = true; + continue; + } + + if (arg.StartsWith('-')) + { + continue; + } + + // First positional arg — check if it's a known plugin + if (PluginRegistry.KnownPlugins.ContainsKey(arg)) + { + return arg; + } + + // First positional arg that isn't a plugin (e.g. "version", "plugin") + return null; + } + + return null; + } + + /// + /// Extracts the value of --plugin-path directly from raw args. + /// + internal static string? DetectPluginPath(string[] args) + { + for (var i = 0; i < args.Length - 1; i++) + { + if (args[i] == PluginPathFlag) + { + return args[i + 1]; + } + } + + return null; + } + + /// + /// Extracts the value of --plugin-version directly from raw args. + /// + internal static string? DetectVersion(string[] args) + { + for (var i = 0; i < args.Length - 1; i++) + { + if (args[i] == VersionFlag) + { + return args[i + 1]; + } + } + + return null; + } + + /// + /// Eagerly loads the real for the + /// named plugin. Resolution chain: --plugin-path → project context + /// → deployed assembly → --plugin-versionnull. + /// + internal static async Task LoadPluginCommandAsync( + string pluginName, + string? pluginPath, + string? explicitVersion, + CancellationToken ct) + { + try + { + string assemblyPath; + + if (pluginPath is not null) + { + assemblyPath = Path.GetFullPath(pluginPath); + } + else + { + // Try project context + deployed assembly scanning + var resolution = ProjectContextScanner.Resolve(pluginName); + + // Fallback to explicit --plugin-version + if (resolution is null && explicitVersion is not null) + { + resolution = ProjectContextScanner.ResolveExplicit(pluginName, explicitVersion); + } + + if (resolution is null) + { + return null; + } + + assemblyPath = await NuGetPluginResolver.EnsureDownloadedAsync(resolution, ct); + } + + var plugin = PluginLoader.Load(assemblyPath); + return plugin.GetCommand(); + } + catch (InvalidOperationException ex) + { + await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}"); + return null; + } + catch (HttpRequestException ex) + { + await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}"); + return null; + } + catch (TaskCanceledException ex) + { + await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}"); + return null; + } + catch (IOException ex) + { + await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}"); + return null; + } + } +} diff --git a/cli/src/Cli/Plugins/PluginRegistry.cs b/cli/src/Cli/Plugins/PluginRegistry.cs new file mode 100644 index 000000000..f528c9cad --- /dev/null +++ b/cli/src/Cli/Plugins/PluginRegistry.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Cli.Plugins; + +/// +/// Information about a known CLI plugin. +/// +internal sealed record PluginInfo(string PackageId, string AnchorPackage, string Description); + +/// +/// The resolved plugin package ID and version, ready for NuGet download. +/// +internal sealed record PluginResolution(string PackageId, string Version); + +/// +/// Hardcoded registry of all known first-party Duende CLI plugins. +/// Maps the subcommand name to plugin NuGet package information. +/// +internal static class PluginRegistry +{ + internal static readonly IReadOnlyDictionary KnownPlugins = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["storage"] = new( + PackageId: "Duende.Storage.CliPlugin", + AnchorPackage: "Duende.Storage", + Description: "Duende Storage"), + }; +} diff --git a/cli/src/Cli/Plugins/PluginStubRegistrar.cs b/cli/src/Cli/Plugins/PluginStubRegistrar.cs new file mode 100644 index 000000000..5f4cadbd5 --- /dev/null +++ b/cli/src/Cli/Plugins/PluginStubRegistrar.cs @@ -0,0 +1,60 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.CommandLine; + +namespace Duende.Cli.Plugins; + +/// +/// Registers stub commands for known plugins onto the root command. +/// Each stub command lazily downloads and loads the real plugin on first invocation. +/// +internal static class PluginStubRegistrar +{ + /// Registers stubs for all known plugins. + internal static void Register( + RootCommand rootCommand, + Option pluginPathOption, + Option pluginVersionOption, + string[] originalArgs) => + Register(rootCommand, pluginPathOption, pluginVersionOption, originalArgs, onlyPlugin: null, excludePlugin: null); + + /// + /// Registers stubs for known plugins, filtered by + /// or . Pass onlyPlugin to register a + /// single stub; pass excludePlugin to register all except one. + /// + internal static void Register( + RootCommand rootCommand, + Option pluginPathOption, + Option pluginVersionOption, + string[] originalArgs, + string? onlyPlugin = null, + string? excludePlugin = null) + { + foreach (var (name, info) in PluginRegistry.KnownPlugins) + { + if (onlyPlugin is not null && !string.Equals(name, onlyPlugin, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (excludePlugin is not null && string.Equals(name, excludePlugin, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var stubCommand = new Command(name, $"Commands from the {info.Description} plugin. (Downloads on first use.)"); + var pluginNameCapture = name; + + stubCommand.SetAction(async (parseResult, ct) => + { + var pluginPath = parseResult.GetValue(pluginPathOption); + var explicitVersion = parseResult.GetValue(pluginVersionOption); + Environment.ExitCode = await PluginOrchestrator.LoadAndReInvokeAsync(pluginNameCapture, pluginPath, explicitVersion, originalArgs, ct); + }); + + rootCommand.Subcommands.Add(stubCommand); + } + } +} diff --git a/cli/src/Cli/Plugins/ProjectContextScanner.cs b/cli/src/Cli/Plugins/ProjectContextScanner.cs new file mode 100644 index 000000000..2fc308d32 --- /dev/null +++ b/cli/src/Cli/Plugins/ProjectContextScanner.cs @@ -0,0 +1,186 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Xml; +using System.Xml.Linq; + +namespace Duende.Cli.Plugins; + +/// +/// Scans the current directory and its ancestors for NuGet package references +/// to determine the version of a plugin's anchor package. +/// Supports Central Package Management (Directory.Packages.props), +/// direct .csproj references, and deployed assembly scanning. +/// +internal static class ProjectContextScanner +{ + private const string DirectoryPackagesProps = "Directory.Packages.props"; + + /// + /// Resolves the plugin version using the full fallback chain: + /// project context → deployed assembly scanning. + /// + /// The plugin name (e.g. "storage"). + /// The directory to start scanning from. Defaults to current directory. + /// A if the anchor package is found; otherwise null. + internal static PluginResolution? Resolve(string pluginName, string? searchDirectory = null) + { + if (!PluginRegistry.KnownPlugins.TryGetValue(pluginName, out var info)) + { + return null; + } + + var directory = searchDirectory ?? Directory.GetCurrentDirectory(); + + // Strategy 1: scan project files (Directory.Packages.props, .csproj) + var anchorVersion = FindAnchorVersionFromProjectFiles(info.AnchorPackage, directory); + + // Strategy 2: scan for deployed anchor assembly + anchorVersion ??= FindAnchorVersionFromDeployedAssembly(info.AnchorPackage, directory); + + if (anchorVersion is null) + { + return null; + } + + var pluginVersion = ToMajorMinorWildcard(anchorVersion); + return new PluginResolution(info.PackageId, pluginVersion); + } + + /// + /// Creates a from an explicit version string (e.g. from --plugin-version). + /// + internal static PluginResolution? ResolveExplicit(string pluginName, string version) + { + if (!PluginRegistry.KnownPlugins.TryGetValue(pluginName, out var info)) + { + return null; + } + + var pluginVersion = ToMajorMinorWildcard(version); + return new PluginResolution(info.PackageId, pluginVersion); + } + + private static string? FindAnchorVersionFromProjectFiles(string anchorPackage, string startDirectory) + { + var directory = new DirectoryInfo(startDirectory); + + while (directory is not null) + { + // Check Directory.Packages.props (Central Package Management) + var packagesProps = Path.Combine(directory.FullName, DirectoryPackagesProps); + if (File.Exists(packagesProps)) + { + var version = ExtractVersionFromPackagesProps(packagesProps, anchorPackage); + if (version is not null) + { + return version; + } + } + + // Check .csproj files in this directory + try + { + foreach (var csproj in directory.GetFiles("*.csproj")) + { + var version = ExtractVersionFromCsproj(csproj.FullName, anchorPackage); + if (version is not null) + { + return version; + } + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException) + { + // Skip inaccessible directories + } + + directory = directory.Parent; + } + + return null; + } + + /// + /// Scans the directory for a deployed anchor assembly (e.g. Duende.Storage.dll) + /// and reads its version information to determine the product version. + /// Uses to avoid loading the assembly. + /// + private static string? FindAnchorVersionFromDeployedAssembly(string anchorPackage, string directory) + { + var assemblyFileName = $"{anchorPackage}.dll"; + var assemblyPath = Path.Combine(directory, assemblyFileName); + + if (!File.Exists(assemblyPath)) + { + return null; + } + + try + { + // FileVersionInfo reads the PE version resource without loading the assembly + var fileInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(assemblyPath); + + // ProductVersion corresponds to AssemblyInformationalVersionAttribute + if (fileInfo.ProductVersion is { Length: > 0 } productVersion) + { + // Strip build metadata (e.g. "7.2.1+abc123" → "7.2.1") + var plusIndex = productVersion.IndexOf('+', StringComparison.Ordinal); + return plusIndex >= 0 ? productVersion[..plusIndex] : productVersion; + } + + // Fall back to FileVersion (corresponds to AssemblyFileVersionAttribute) + if (fileInfo.FileMajorPart > 0) + { + return $"{fileInfo.FileMajorPart}.{fileInfo.FileMinorPart}.{fileInfo.FileBuildPart}"; + } + + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static string? ExtractVersionFromPackagesProps(string filePath, string packageId) + { + try + { + var doc = XDocument.Load(filePath); + return doc.Descendants("PackageVersion") + .FirstOrDefault(e => + string.Equals(e.Attribute("Include")?.Value, packageId, StringComparison.OrdinalIgnoreCase)) + ?.Attribute("Version")?.Value; + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static string? ExtractVersionFromCsproj(string filePath, string packageId) + { + try + { + var doc = XDocument.Load(filePath); + return doc.Descendants("PackageReference") + .FirstOrDefault(e => + string.Equals(e.Attribute("Include")?.Value, packageId, StringComparison.OrdinalIgnoreCase)) + ?.Attribute("Version")?.Value; + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Converts a version string like "7.2.1" to "7.2.*" for NuGet floating version resolution. + /// + internal static string ToMajorMinorWildcard(string version) + { + var parts = version.Split('.'); + return parts.Length >= 2 ? $"{parts[0]}.{parts[1]}.*" : $"{version}.*"; + } +} diff --git a/cli/src/Cli/Program.cs b/cli/src/Cli/Program.cs new file mode 100644 index 000000000..a1daa1024 --- /dev/null +++ b/cli/src/Cli/Program.cs @@ -0,0 +1,83 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.CommandLine; +using System.Reflection; +using Duende.Cli.Commands; +using Duende.Cli.Plugins; + +var rootCommand = new RootCommand("Duende CLI — tooling for Duende Software products."); + +// Built-in: version +var versionCommand = new Command("version", "Show the Duende CLI version."); +versionCommand.SetAction(_ => +{ + var version = Assembly.GetExecutingAssembly() + .GetCustomAttribute() + ?.InformationalVersion ?? "unknown"; + Console.WriteLine($"duende {version}"); +}); +rootCommand.Subcommands.Add(versionCommand); + +// Built-in: plugin +var pluginCommand = new Command("plugin", "Manage CLI plugins."); +rootCommand.Subcommands.Add(pluginCommand); + +// plugin list +var pluginListCommand = new Command("list", "List known plugins and their resolved versions."); +pluginListCommand.SetAction(_ => PluginListHandler.Execute()); +pluginCommand.Subcommands.Add(pluginListCommand); + +// plugin cache +var pluginCacheCommand = new Command("cache", "Manage the plugin cache."); +pluginCommand.Subcommands.Add(pluginCacheCommand); + +var pluginCacheClearCommand = new Command("clear", "Clear the NuGet global packages cache entries for Duende plugins."); +pluginCacheClearCommand.SetAction(_ => PluginCacheHandler.Execute()); +pluginCacheCommand.Subcommands.Add(pluginCacheClearCommand); + +// --plugin-path option (for local development — bypasses NuGet resolution) +var pluginPathOption = new Option("--plugin-path") +{ + Description = "Load a plugin directly from a DLL path, bypassing NuGet resolution.", +}; +rootCommand.Options.Add(pluginPathOption); + +// --plugin-version option (explicit plugin version when no project context or deployed assembly is available) +var pluginVersionOption = new Option("--plugin-version") +{ + Description = "Specify the plugin version to download (e.g. 7.2.1). Used when no project context or deployed assembly is found.", +}; +rootCommand.Options.Add(pluginVersionOption); + +// Detect which plugin (if any) the user is invoking so we can register it before parsing. +// This ensures `duende storage --help` shows the real subcommands, not just the stub. +var requestedPlugin = PluginPreloader.DetectRequestedPlugin(args); +var pluginPath = PluginPreloader.DetectPluginPath(args); +var explicitVersion = PluginPreloader.DetectVersion(args); + +if (requestedPlugin is not null) +{ + // Eagerly load the real plugin command and register it in place of the stub + var realCommand = await PluginPreloader.LoadPluginCommandAsync( + requestedPlugin, pluginPath, explicitVersion, CancellationToken.None); + if (realCommand is not null) + { + rootCommand.Subcommands.Add(realCommand); + } + else + { + // Fallback to stub if loading fails (e.g. no project context, no --plugin-path, no --plugin-version) + PluginStubRegistrar.Register(rootCommand, pluginPathOption, pluginVersionOption, args, onlyPlugin: requestedPlugin); + } + + // Register stubs for all other plugins + PluginStubRegistrar.Register(rootCommand, pluginPathOption, pluginVersionOption, args, excludePlugin: requestedPlugin); +} +else +{ + // No specific plugin invoked — register stubs for all known plugins + PluginStubRegistrar.Register(rootCommand, pluginPathOption, pluginVersionOption, args); +} + +return await rootCommand.Parse(args).InvokeAsync(); diff --git a/cli/src/Directory.Build.props b/cli/src/Directory.Build.props new file mode 100644 index 000000000..d004ddc20 --- /dev/null +++ b/cli/src/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + 0.1 + cli- + Duende CLI + + + diff --git a/cli/src/Directory.Build.targets b/cli/src/Directory.Build.targets new file mode 100644 index 000000000..acbac1881 --- /dev/null +++ b/cli/src/Directory.Build.targets @@ -0,0 +1,4 @@ + + + + diff --git a/cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj b/cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj new file mode 100644 index 000000000..0f0f66395 --- /dev/null +++ b/cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cli/test/Cli.IntegrationTests/PluginLoaderTests.cs b/cli/test/Cli.IntegrationTests/PluginLoaderTests.cs new file mode 100644 index 000000000..4494f795c --- /dev/null +++ b/cli/test/Cli.IntegrationTests/PluginLoaderTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Runtime.Loader; +using Duende.Cli.Plugins; + +namespace Duende.Cli.IntegrationTests; + +public class PluginLoaderTests +{ + private static readonly string AssemblyPath = + typeof(Duende.Cli.TestPlugin.TestCliPlugin).Assembly.Location; + + [Fact] + public void PluginLoadsViaPluginLoader() + { + var plugin = PluginLoader.Load(AssemblyPath); + + plugin.Name.ShouldBe("test"); + } + + [Fact] + public void PluginReturnsCommandWithCorrectName() + { + var plugin = PluginLoader.Load(AssemblyPath); + + var command = plugin.GetCommand(); + + command.Name.ShouldBe("test"); + } + + [Fact] + public void PluginCommandHasSubcommand() + { + var plugin = PluginLoader.Load(AssemblyPath); + + var command = plugin.GetCommand(); + + command.Subcommands.ShouldContain(c => c.Name == "hello"); + } + + [Fact] + public void PluginLoadsInIsolatedAlc() + { + var plugin = PluginLoader.Load(AssemblyPath); + + var context = AssemblyLoadContext.GetLoadContext(plugin.GetType().Assembly); + + context.ShouldNotBe(AssemblyLoadContext.Default); + } +} diff --git a/cli/test/Cli.Tests/Cli.Tests.csproj b/cli/test/Cli.Tests/Cli.Tests.csproj new file mode 100644 index 000000000..2c164a755 --- /dev/null +++ b/cli/test/Cli.Tests/Cli.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/cli/test/Cli.Tests/PluginPreloaderTests.cs b/cli/test/Cli.Tests/PluginPreloaderTests.cs new file mode 100644 index 000000000..495997f9a --- /dev/null +++ b/cli/test/Cli.Tests/PluginPreloaderTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Cli.Plugins; + +namespace Duende.Cli.Tests; + +public class PluginPreloaderTests +{ + [Fact] + public void DetectRequestedPlugin_returns_known_plugin_name() + { + var result = PluginPreloader.DetectRequestedPlugin(["storage", "--help"]); + + result.ShouldBe("storage"); + } + + [Fact] + public void DetectRequestedPlugin_skips_plugin_path_value() + { + var result = PluginPreloader.DetectRequestedPlugin(["--plugin-path", "foo.dll", "storage"]); + + result.ShouldBe("storage"); + } + + [Fact] + public void DetectRequestedPlugin_returns_null_for_non_plugin_command() + { + var result = PluginPreloader.DetectRequestedPlugin(["version"]); + + result.ShouldBeNull(); + } + + [Fact] + public void DetectRequestedPlugin_returns_null_for_option_flag() + { + var result = PluginPreloader.DetectRequestedPlugin(["--help"]); + + result.ShouldBeNull(); + } + + [Fact] + public void DetectRequestedPlugin_returns_null_for_empty_args() + { + var result = PluginPreloader.DetectRequestedPlugin([]); + + result.ShouldBeNull(); + } + + [Fact] + public void DetectPluginPath_returns_path_when_present() + { + var result = PluginPreloader.DetectPluginPath( + ["--plugin-path", "/path/to/plugin.dll", "storage"]); + + result.ShouldBe("/path/to/plugin.dll"); + } + + [Fact] + public void DetectPluginPath_returns_null_when_not_present() + { + var result = PluginPreloader.DetectPluginPath(["storage", "--help"]); + + result.ShouldBeNull(); + } + + [Fact] + public void DetectVersion_returns_version_when_present() + { + var result = PluginPreloader.DetectVersion(["--plugin-version", "7.2.1", "storage"]); + + result.ShouldBe("7.2.1"); + } + + [Fact] + public void DetectVersion_returns_null_when_not_present() + { + var result = PluginPreloader.DetectVersion(["storage", "--help"]); + + result.ShouldBeNull(); + } + + [Fact] + public void DetectRequestedPlugin_skips_version_flag_value() + { + var result = PluginPreloader.DetectRequestedPlugin(["--plugin-version", "7.2.1", "storage"]); + + result.ShouldBe("storage"); + } +} diff --git a/cli/test/Cli.Tests/PluginRegistryTests.cs b/cli/test/Cli.Tests/PluginRegistryTests.cs new file mode 100644 index 000000000..714f3192c --- /dev/null +++ b/cli/test/Cli.Tests/PluginRegistryTests.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Cli.Plugins; + +namespace Duende.Cli.Tests; + +public class PluginRegistryTests +{ + [Fact] + public void KnownPlugins_contains_storage_key() => + PluginRegistry.KnownPlugins.ShouldContainKey("storage"); + + [Fact] + public void Storage_entry_has_correct_PackageId() => + PluginRegistry.KnownPlugins["storage"].PackageId.ShouldBe("Duende.Storage.CliPlugin"); + + [Fact] + public void Storage_entry_has_correct_AnchorPackage() => + PluginRegistry.KnownPlugins["storage"].AnchorPackage.ShouldBe("Duende.Storage"); + + [Theory] + [InlineData("Storage")] + [InlineData("STORAGE")] + [InlineData("sToRaGe")] + public void Lookup_is_case_insensitive(string key) => + PluginRegistry.KnownPlugins.ShouldContainKey(key); +} diff --git a/cli/test/Cli.Tests/ProjectContextScannerTests.cs b/cli/test/Cli.Tests/ProjectContextScannerTests.cs new file mode 100644 index 000000000..7b096c22c --- /dev/null +++ b/cli/test/Cli.Tests/ProjectContextScannerTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Cli.Plugins; + +namespace Duende.Cli.Tests; + +public class ProjectContextScannerTests : IDisposable +{ + private readonly string _tempDir; + + public ProjectContextScannerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "CliTests_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public void Resolve_finds_anchor_version_from_Directory_Packages_props() + { + var propsContent = """ + + + + + + """; + File.WriteAllText(Path.Combine(_tempDir, "Directory.Packages.props"), propsContent); + + var result = ProjectContextScanner.Resolve("storage", _tempDir); + + var resolved = result.ShouldNotBeNull(); + resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin"); + resolved.Version.ShouldBe("7.2.*"); + } + + [Fact] + public void Resolve_returns_null_when_no_anchor_package_exists() + { + var propsContent = """ + + + + + + """; + File.WriteAllText(Path.Combine(_tempDir, "Directory.Packages.props"), propsContent); + + var result = ProjectContextScanner.Resolve("storage", _tempDir); + + result.ShouldBeNull(); + } + + [Fact] + public void Resolve_returns_null_for_unknown_plugin_name() + { + var result = ProjectContextScanner.Resolve("nonexistent", _tempDir); + + result.ShouldBeNull(); + } + + [Fact] + public void Resolve_finds_anchor_version_from_csproj() + { + var csprojContent = """ + + + + + + """; + File.WriteAllText(Path.Combine(_tempDir, "Test.csproj"), csprojContent); + + var result = ProjectContextScanner.Resolve("storage", _tempDir); + + var resolved = result.ShouldNotBeNull(); + resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin"); + resolved.Version.ShouldBe("8.0.*"); + } + + [Fact] + public void Resolve_finds_version_from_deployed_assembly() + { + // Copy the Storage.CliPlugin assembly as a stand-in for "Duende.Storage.dll" + // We need a real .NET assembly with version info — use the test assembly itself + var sourceAssembly = typeof(ProjectContextScannerTests).Assembly.Location; + var anchorPath = Path.Combine(_tempDir, "Duende.Storage.dll"); + File.Copy(sourceAssembly, anchorPath); + + // The test assembly won't have the right version, but we can verify the scanner + // finds *something* from the deployed assembly (non-null result) + var result = ProjectContextScanner.Resolve("storage", _tempDir); + + // Should resolve — the assembly has version info even if it's not "7.2.1" + var resolved = result.ShouldNotBeNull(); + resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin"); + } + + [Fact] + public void Resolve_prefers_project_context_over_deployed_assembly() + { + // Set up both project context and deployed assembly + var propsContent = """ + + + + + + """; + File.WriteAllText(Path.Combine(_tempDir, "Directory.Packages.props"), propsContent); + + var sourceAssembly = typeof(ProjectContextScannerTests).Assembly.Location; + File.Copy(sourceAssembly, Path.Combine(_tempDir, "Duende.Storage.dll")); + + var result = ProjectContextScanner.Resolve("storage", _tempDir); + + // Should use project context version, not assembly version + var resolved = result.ShouldNotBeNull(); + resolved.Version.ShouldBe("7.2.*"); + } + + [Fact] + public void ResolveExplicit_creates_resolution_from_version_string() + { + var result = ProjectContextScanner.ResolveExplicit("storage", "7.2.1"); + + var resolved = result.ShouldNotBeNull(); + resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin"); + resolved.Version.ShouldBe("7.2.*"); + } + + [Fact] + public void ResolveExplicit_returns_null_for_unknown_plugin() + { + var result = ProjectContextScanner.ResolveExplicit("nonexistent", "1.0.0"); + + result.ShouldBeNull(); + } +} diff --git a/cli/test/Directory.Build.props b/cli/test/Directory.Build.props new file mode 100644 index 000000000..0047a9176 --- /dev/null +++ b/cli/test/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + + $(NoWarn);IDE1006 + + diff --git a/cli/testing/Cli.TestPlugin/AssemblyInfo.cs b/cli/testing/Cli.TestPlugin/AssemblyInfo.cs new file mode 100644 index 000000000..fc308c265 --- /dev/null +++ b/cli/testing/Cli.TestPlugin/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Cli.PluginAbstractions; +using Duende.Cli.TestPlugin; + +[assembly: CliPlugin(typeof(TestCliPlugin))] diff --git a/cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj b/cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj new file mode 100644 index 000000000..431a03475 --- /dev/null +++ b/cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/cli/testing/Cli.TestPlugin/TestCliPlugin.cs b/cli/testing/Cli.TestPlugin/TestCliPlugin.cs new file mode 100644 index 000000000..ef3c4c838 --- /dev/null +++ b/cli/testing/Cli.TestPlugin/TestCliPlugin.cs @@ -0,0 +1,24 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.CommandLine; +using Duende.Cli.PluginAbstractions; + +namespace Duende.Cli.TestPlugin; + +/// +/// A minimal CLI plugin used for integration testing the plugin loading infrastructure. +/// +public sealed class TestCliPlugin : ICliPlugin +{ + /// + public string Name => "test"; + + /// + public Command GetCommand() + { + var command = new Command("test", "A test plugin for integration testing."); + command.Subcommands.Add(new Command("hello", "A sample subcommand.")); + return command; + } +} diff --git a/products.slnx b/products.slnx index 8b8803f2f..8f03cf59c 100644 --- a/products.slnx +++ b/products.slnx @@ -27,6 +27,9 @@ + + +