using System; using System.Collections.Generic; using System.CommandLine; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Cli.Configuration; using StellaOps.Plugin.Hosting; namespace StellaOps.Cli.Plugins; internal sealed class CliCommandModuleLoader { private readonly IServiceProvider _services; private readonly StellaOpsCliOptions _options; private readonly ILogger _logger; private readonly RestartOnlyCliPluginGuard _guard = new(); private IReadOnlyList _modules = Array.Empty(); private bool _loaded; public CliCommandModuleLoader( IServiceProvider services, StellaOpsCliOptions options, ILogger logger) { _services = services ?? throw new ArgumentNullException(nameof(services)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public IReadOnlyList LoadModules() { if (_loaded) { return _modules; } var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions(); var baseDirectory = ResolveBaseDirectory(pluginOptions); var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory); var searchPatterns = ResolveSearchPatterns(pluginOptions); var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern) ? "*.manifest.json" : pluginOptions.ManifestSearchPattern; _logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory); var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern); IReadOnlyList manifests; try { manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult(); } catch (Exception ex) { _logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory); manifests = Array.Empty(); } if (manifests.Count == 0) { _logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory); _loaded = true; _guard.Seal(); _modules = Array.Empty(); return _modules; } var hostOptions = new PluginHostOptions { BaseDirectory = baseDirectory, PluginsDirectory = pluginsDirectory, EnsureDirectoryExists = false, RecursiveSearch = true, PrimaryPrefix = "StellaOps.Cli" }; foreach (var pattern in searchPatterns) { hostOptions.SearchPatterns.Add(pattern); } foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty()) { if (!string.IsNullOrWhiteSpace(ordered)) { hostOptions.PluginOrder.Add(ordered); } } var loadResult = PluginHost.LoadPlugins(hostOptions, _logger); var assemblies = loadResult.Plugins.ToDictionary( descriptor => Normalize(descriptor.AssemblyPath), descriptor => descriptor.Assembly, StringComparer.OrdinalIgnoreCase); var modules = new List(manifests.Count); foreach (var manifest in manifests) { try { var assemblyPath = ResolveAssemblyPath(manifest); _guard.EnsureRegistrationAllowed(assemblyPath); if (!assemblies.TryGetValue(assemblyPath, out var assembly)) { if (!File.Exists(assemblyPath)) { throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found."); } assembly = Assembly.LoadFrom(assemblyPath); assemblies[assemblyPath] = assembly; } var module = CreateModule(assembly, manifest); if (module is null) { continue; } modules.Add(module); _logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath); } catch (Exception ex) { _logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id); } } _modules = modules; _loaded = true; _guard.Seal(); return _modules; } public void RegisterModules(RootCommand root, Option verboseOption, CancellationToken cancellationToken) { if (root is null) { throw new ArgumentNullException(nameof(root)); } if (verboseOption is null) { throw new ArgumentNullException(nameof(verboseOption)); } var modules = LoadModules(); if (modules.Count == 0) { return; } foreach (var module in modules) { if (!module.IsAvailable(_services)) { _logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name); continue; } try { module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken); _logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name); } catch (Exception ex) { _logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name); } } } private static string ResolveAssemblyPath(CliPluginManifest manifest) { if (manifest.EntryPoint is null) { throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point."); } var assemblyPath = manifest.EntryPoint.Assembly; if (string.IsNullOrWhiteSpace(assemblyPath)) { throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path."); } if (!Path.IsPathRooted(assemblyPath)) { if (string.IsNullOrWhiteSpace(manifest.SourceDirectory)) { throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata."); } assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath); } return Normalize(assemblyPath); } private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest) { if (manifest.EntryPoint is null) { return null; } var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true); if (type is null) { throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'."); } var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule; if (module is null) { throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}."); } return module; } private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options) { var baseDirectory = options.BaseDirectory; if (string.IsNullOrWhiteSpace(baseDirectory)) { baseDirectory = AppContext.BaseDirectory; } return Path.GetFullPath(baseDirectory); } private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory) { var directory = options.Directory; if (string.IsNullOrWhiteSpace(directory)) { directory = Path.Combine("plugins", "cli"); } directory = directory.Trim(); if (!Path.IsPathRooted(directory)) { directory = Path.Combine(baseDirectory, directory); } return Path.GetFullPath(directory); } private static IReadOnlyList ResolveSearchPatterns(StellaOpsCliPluginOptions options) { if (options.SearchPatterns is null || options.SearchPatterns.Count == 0) { return new[] { "StellaOps.Cli.Plugin.*.dll" }; } return options.SearchPatterns .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) .Select(pattern => pattern.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } private static string Normalize(string path) { var full = Path.GetFullPath(path); return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } }