mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
Publish - 2026-05-21 19:55:48 UTC
This commit is contained in:
parent
d1f96fb8cf
commit
adae547d62
33 changed files with 1480 additions and 0 deletions
9
cli/Directory.Build.props
Normal file
9
cli/Directory.Build.props
Normal 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>
|
||||
3
cli/Directory.Build.targets
Normal file
3
cli/Directory.Build.targets
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..\'))" />
|
||||
</Project>
|
||||
8
cli/cli-abstractions.slnf
Normal file
8
cli/cli-abstractions.slnf
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"solution": {
|
||||
"path": "..\\products.slnx",
|
||||
"projects": [
|
||||
"cli\\src\\Cli.PluginAbstractions\\Cli.PluginAbstractions.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
cli/cli.slnf
Normal file
12
cli/cli.slnf
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj
Normal file
13
cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj
Normal 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>
|
||||
45
cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs
Normal file
45
cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
23
cli/src/Cli.PluginAbstractions/ICliPlugin.cs
Normal file
23
cli/src/Cli.PluginAbstractions/ICliPlugin.cs
Normal 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();
|
||||
}
|
||||
27
cli/src/Cli.PluginAbstractions/IPluginContext.cs
Normal file
27
cli/src/Cli.PluginAbstractions/IPluginContext.cs
Normal 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
27
cli/src/Cli/Cli.csproj
Normal 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>
|
||||
51
cli/src/Cli/Commands/PluginCacheHandler.cs
Normal file
51
cli/src/Cli/Commands/PluginCacheHandler.cs
Normal 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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
33
cli/src/Cli/Commands/PluginListHandler.cs
Normal file
33
cli/src/Cli/Commands/PluginListHandler.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
162
cli/src/Cli/Plugins/NuGetPluginResolver.cs
Normal file
162
cli/src/Cli/Plugins/NuGetPluginResolver.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
39
cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs
Normal file
39
cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
54
cli/src/Cli/Plugins/PluginLoader.cs
Normal file
54
cli/src/Cli/Plugins/PluginLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
65
cli/src/Cli/Plugins/PluginOrchestrator.cs
Normal file
65
cli/src/Cli/Plugins/PluginOrchestrator.cs
Normal 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
|
||||
}
|
||||
}
|
||||
153
cli/src/Cli/Plugins/PluginPreloader.cs
Normal file
153
cli/src/Cli/Plugins/PluginPreloader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
cli/src/Cli/Plugins/PluginRegistry.cs
Normal file
30
cli/src/Cli/Plugins/PluginRegistry.cs
Normal 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"),
|
||||
};
|
||||
}
|
||||
60
cli/src/Cli/Plugins/PluginStubRegistrar.cs
Normal file
60
cli/src/Cli/Plugins/PluginStubRegistrar.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
186
cli/src/Cli/Plugins/ProjectContextScanner.cs
Normal file
186
cli/src/Cli/Plugins/ProjectContextScanner.cs
Normal 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
83
cli/src/Cli/Program.cs
Normal 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();
|
||||
10
cli/src/Directory.Build.props
Normal file
10
cli/src/Directory.Build.props
Normal 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>
|
||||
4
cli/src/Directory.Build.targets
Normal file
4
cli/src/Directory.Build.targets
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..\'))" />
|
||||
<Import Project="../../src.targets" />
|
||||
</Project>
|
||||
|
|
@ -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>
|
||||
51
cli/test/Cli.IntegrationTests/PluginLoaderTests.cs
Normal file
51
cli/test/Cli.IntegrationTests/PluginLoaderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
cli/test/Cli.Tests/Cli.Tests.csproj
Normal file
7
cli/test/Cli.Tests/Cli.Tests.csproj
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Cli\Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
90
cli/test/Cli.Tests/PluginPreloaderTests.cs
Normal file
90
cli/test/Cli.Tests/PluginPreloaderTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
28
cli/test/Cli.Tests/PluginRegistryTests.cs
Normal file
28
cli/test/Cli.Tests/PluginRegistryTests.cs
Normal 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);
|
||||
}
|
||||
149
cli/test/Cli.Tests/ProjectContextScannerTests.cs
Normal file
149
cli/test/Cli.Tests/ProjectContextScannerTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
9
cli/test/Directory.Build.props
Normal file
9
cli/test/Directory.Build.props
Normal 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>
|
||||
7
cli/testing/Cli.TestPlugin/AssemblyInfo.cs
Normal file
7
cli/testing/Cli.TestPlugin/AssemblyInfo.cs
Normal 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))]
|
||||
7
cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj
Normal file
7
cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Cli.PluginAbstractions\Cli.PluginAbstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
24
cli/testing/Cli.TestPlugin/TestCliPlugin.cs
Normal file
24
cli/testing/Cli.TestPlugin/TestCliPlugin.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue