Publish - 2026-05-21 19:55:48 UTC

This commit is contained in:
Duende Bot 2026-05-21 19:55:59 +00:00
parent d1f96fb8cf
commit adae547d62
33 changed files with 1480 additions and 0 deletions

View file

@ -0,0 +1,9 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..\'))" />
<PropertyGroup>
<AssemblyName>Duende.$(MSBuildProjectName)</AssemblyName>
<RootNamespace>Duende.$(MSBuildProjectName)</RootNamespace>
<PackageId>Duende.$(MSBuildProjectName)</PackageId>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,3 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..\'))" />
</Project>

View file

@ -0,0 +1,8 @@
{
"solution": {
"path": "..\\products.slnx",
"projects": [
"cli\\src\\Cli.PluginAbstractions\\Cli.PluginAbstractions.csproj"
]
}
}

12
cli/cli.slnf Normal file
View file

@ -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"
]
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<MinVerTagPrefix>cli-abs-</MinVerTagPrefix>
<MinVerMinimumMajorMinor>0.1</MinVerMinimumMajorMinor>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,45 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Cli.PluginAbstractions;
/// <summary>
/// Assembly-level attribute that identifies the type implementing <see cref="ICliPlugin"/>.
/// The CLI host scans loaded plugin assemblies for this attribute to discover plugin entry points.
/// </summary>
/// <example>
/// <code>
/// [assembly: CliPlugin(typeof(StorageCliPlugin))]
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)]
public sealed class CliPluginAttribute : Attribute
{
/// <summary>
/// Gets the type that implements <see cref="ICliPlugin"/>.
/// </summary>
public Type PluginType { get; }
/// <summary>
/// Initializes a new instance of <see cref="CliPluginAttribute"/>.
/// </summary>
/// <param name="pluginType">The type implementing <see cref="ICliPlugin"/>.</param>
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;
}
}

View file

@ -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;
/// <summary>
/// Defines the contract for a CLI plugin that contributes commands to the <c>duende</c> tool.
/// </summary>
public interface ICliPlugin
{
/// <summary>
/// Gets the name of the plugin (e.g. "storage"). Used as the subcommand name.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the <see cref="Command"/> that this plugin contributes to the CLI root command.
/// The returned command and all its subcommands are mounted directly onto the root.
/// </summary>
Command GetCommand();
}

View file

@ -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;
/// <summary>
/// Provides host services to CLI plugins at runtime.
/// </summary>
public interface IPluginContext
{
/// <summary>
/// Gets a logger for the plugin to use.
/// </summary>
ILogger Logger { get; }
/// <summary>
/// Gets the version of the <c>duende</c> CLI tool host.
/// </summary>
string HostVersion { get; }
/// <summary>
/// Gets a cancellation token that is cancelled when the CLI is shutting down.
/// </summary>
CancellationToken CancellationToken { get; }
}

27
cli/src/Cli/Cli.csproj Normal file
View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<PackAsTool>true</PackAsTool>
<ToolCommandName>duende</ToolCommandName>
<RollForward>Major</RollForward>
<!-- CLI tools write literal strings to console by design -->
<NoWarn>$(NoWarn);CA1303</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NuGet.Packaging" />
<PackageReference Include="NuGet.Protocol" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Cli.PluginAbstractions/Cli.PluginAbstractions.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Duende.Cli.Tests" />
<InternalsVisibleTo Include="Duende.Cli.IntegrationTests" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Handles the <c>duende plugin cache clear</c> command.
/// Removes cached Duende CLI plugin packages from the NuGet global packages folder.
/// </summary>
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).");
}
}
}

View file

@ -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;
/// <summary>
/// Handles the <c>duende plugin list</c> command.
/// Lists all known plugins and their resolved versions from project context.
/// </summary>
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.");
}
}

View file

@ -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;
/// <summary>
/// Resolves and downloads CLI plugin packages from nuget.org using the NuGet global packages folder as cache.
/// </summary>
internal static class NuGetPluginResolver
{
private static readonly string GlobalPackagesFolder =
SettingsUtility.GetGlobalPackagesFolder(Settings.LoadDefaultSettings(null));
/// <summary>
/// Ensures the plugin package is available in the NuGet global packages folder,
/// downloading it from nuget.org if not already cached.
/// </summary>
/// <param name="resolution">The resolved plugin package ID and version range (e.g. "7.2.*").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The path to the plugin's primary assembly.</returns>
internal static async Task<string> 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<FindPackageByIdResource>(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<NuGetVersion>();
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";
}
}

View file

@ -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;
/// <summary>
/// Isolated <see cref="AssemblyLoadContext"/> for a single plugin.
/// Loads plugin assemblies and their dependencies in isolation, while sharing
/// <c>Duende.Cli.PluginAbstractions</c> and <c>System.CommandLine</c> from the default context.
/// </summary>
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<string> 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;
}
}

View file

@ -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;
/// <summary>
/// Loads a plugin assembly from a file path into an isolated <see cref="AssemblyLoadContext"/>
/// and returns the <see cref="ICliPlugin"/> implementation.
/// </summary>
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<CliPluginAttribute>()
.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;
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
internal static class PluginOrchestrator
{
internal static async Task<int> 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 <version> to specify the plugin version, " +
"or --plugin-path <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
}
}

View file

@ -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;
/// <summary>
/// Detects which plugin the user is invoking from raw args and eagerly loads it
/// before the command tree is built. This ensures <c>duende storage --help</c>
/// shows real subcommands from the plugin rather than the stub help.
/// </summary>
internal static class PluginPreloader
{
private const string PluginPathFlag = "--plugin-path";
private const string VersionFlag = "--plugin-version";
/// <summary>
/// Scans <paramref name="args"/> for a known plugin name as the first
/// non-option argument (e.g. "storage" in "duende storage --help").
/// Returns the plugin name or <c>null</c> if none detected.
/// </summary>
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;
}
/// <summary>
/// Extracts the value of <c>--plugin-path</c> directly from raw args.
/// </summary>
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;
}
/// <summary>
/// Extracts the value of <c>--plugin-version</c> directly from raw args.
/// </summary>
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;
}
/// <summary>
/// Eagerly loads the real <see cref="System.CommandLine.Command"/> for the
/// named plugin. Resolution chain: <c>--plugin-path</c> → project context
/// → deployed assembly → <c>--plugin-version</c> → <c>null</c>.
/// </summary>
internal static async Task<Command?> 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;
}
}
}

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Cli.Plugins;
/// <summary>
/// Information about a known CLI plugin.
/// </summary>
internal sealed record PluginInfo(string PackageId, string AnchorPackage, string Description);
/// <summary>
/// The resolved plugin package ID and version, ready for NuGet download.
/// </summary>
internal sealed record PluginResolution(string PackageId, string Version);
/// <summary>
/// Hardcoded registry of all known first-party Duende CLI plugins.
/// Maps the subcommand name to plugin NuGet package information.
/// </summary>
internal static class PluginRegistry
{
internal static readonly IReadOnlyDictionary<string, PluginInfo> KnownPlugins =
new Dictionary<string, PluginInfo>(StringComparer.OrdinalIgnoreCase)
{
["storage"] = new(
PackageId: "Duende.Storage.CliPlugin",
AnchorPackage: "Duende.Storage",
Description: "Duende Storage"),
};
}

View file

@ -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;
/// <summary>
/// Registers stub commands for known plugins onto the root command.
/// Each stub command lazily downloads and loads the real plugin on first invocation.
/// </summary>
internal static class PluginStubRegistrar
{
/// <summary>Registers stubs for all known plugins.</summary>
internal static void Register(
RootCommand rootCommand,
Option<string?> pluginPathOption,
Option<string?> pluginVersionOption,
string[] originalArgs) =>
Register(rootCommand, pluginPathOption, pluginVersionOption, originalArgs, onlyPlugin: null, excludePlugin: null);
/// <summary>
/// Registers stubs for known plugins, filtered by <paramref name="onlyPlugin"/>
/// or <paramref name="excludePlugin"/>. Pass <c>onlyPlugin</c> to register a
/// single stub; pass <c>excludePlugin</c> to register all except one.
/// </summary>
internal static void Register(
RootCommand rootCommand,
Option<string?> pluginPathOption,
Option<string?> 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);
}
}
}

View file

@ -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;
/// <summary>
/// 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 (<c>Directory.Packages.props</c>),
/// direct <c>.csproj</c> references, and deployed assembly scanning.
/// </summary>
internal static class ProjectContextScanner
{
private const string DirectoryPackagesProps = "Directory.Packages.props";
/// <summary>
/// Resolves the plugin version using the full fallback chain:
/// project context → deployed assembly scanning.
/// </summary>
/// <param name="pluginName">The plugin name (e.g. "storage").</param>
/// <param name="searchDirectory">The directory to start scanning from. Defaults to current directory.</param>
/// <returns>A <see cref="PluginResolution"/> if the anchor package is found; otherwise <c>null</c>.</returns>
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);
}
/// <summary>
/// Creates a <see cref="PluginResolution"/> from an explicit version string (e.g. from <c>--plugin-version</c>).
/// </summary>
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;
}
/// <summary>
/// Scans the directory for a deployed anchor assembly (e.g. <c>Duende.Storage.dll</c>)
/// and reads its version information to determine the product version.
/// Uses <see cref="System.Diagnostics.FileVersionInfo"/> to avoid loading the assembly.
/// </summary>
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;
}
}
/// <summary>
/// Converts a version string like "7.2.1" to "7.2.*" for NuGet floating version resolution.
/// </summary>
internal static string ToMajorMinorWildcard(string version)
{
var parts = version.Split('.');
return parts.Length >= 2 ? $"{parts[0]}.{parts[1]}.*" : $"{version}.*";
}
}

83
cli/src/Cli/Program.cs Normal file
View file

@ -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<AssemblyInformationalVersionAttribute>()
?.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<string?>("--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<string?>("--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();

View file

@ -0,0 +1,10 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..\'))" />
<Import Project="../../src.props" />
<PropertyGroup>
<MinVerMinimumMajorMinor>0.1</MinVerMinimumMajorMinor>
<MinVerTagPrefix>cli-</MinVerTagPrefix>
<Product>Duende CLI</Product>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,4 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..\'))" />
<Import Project="../../src.targets" />
</Project>

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\Cli\Cli.csproj" />
<ProjectReference Include="..\..\testing\Cli.TestPlugin\Cli.TestPlugin.csproj" />
</ItemGroup>
</Project>

View file

@ -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);
}
}

View file

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\Cli\Cli.csproj" />
</ItemGroup>
</Project>

View file

@ -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");
}
}

View file

@ -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);
}

View file

@ -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 = """
<Project>
<ItemGroup>
<PackageVersion Include="Duende.Storage" Version="7.2.1" />
</ItemGroup>
</Project>
""";
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 = """
<Project>
<ItemGroup>
<PackageVersion Include="SomeOtherPackage" Version="1.0.0" />
</ItemGroup>
</Project>
""";
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 = """
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Duende.Storage" Version="8.0.0" />
</ItemGroup>
</Project>
""";
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 = """
<Project>
<ItemGroup>
<PackageVersion Include="Duende.Storage" Version="7.2.1" />
</ItemGroup>
</Project>
""";
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();
}
}

View file

@ -0,0 +1,9 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..\'))" />
<Import Project="../../test.props" />
<PropertyGroup>
<!--Naming rule violation: Missing suffix: 'Async' (we don't append async on test method names) -->
<NoWarn>$(NoWarn);IDE1006</NoWarn>
</PropertyGroup>
</Project>

View file

@ -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))]

View file

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\Cli.PluginAbstractions\Cli.PluginAbstractions.csproj" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// A minimal CLI plugin used for integration testing the plugin loading infrastructure.
/// </summary>
public sealed class TestCliPlugin : ICliPlugin
{
/// <inheritdoc />
public string Name => "test";
/// <inheritdoc />
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;
}
}

View file

@ -27,6 +27,9 @@
<Project Path="cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj" />
<Project Path="cli/test/Cli.Tests/Cli.Tests.csproj" />
</Folder>
<Folder Name="/cli/testing/">
<Project Path="cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj" />
</Folder>
<Folder Name="/bff/" />
<Folder Name="/bff/hosts/">
<Project Path="bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj" />