diff --git a/src/PPDS.Cli/CHANGELOG.md b/src/PPDS.Cli/CHANGELOG.md index 7cb2fab81..c9fc755a4 100644 --- a/src/PPDS.Cli/CHANGELOG.md +++ b/src/PPDS.Cli/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-beta.2] - 2025-12-30 + +### Added + +- `ppds plugins` command group for plugin registration management: + - `ppds plugins extract` - Extract `[PluginStep]`/`[PluginImage]` attributes from assembly (.dll) or NuGet package (.nupkg) to registrations.json + - `ppds plugins deploy` - Deploy plugin registrations to Dataverse environment + - `ppds plugins diff` - Compare configuration against environment state, detect drift + - `ppds plugins list` - List registered plugins in environment + - `ppds plugins clean` - Remove orphaned registrations not in configuration +- Plugin deployment options: + - `--solution` to add components to a solution + - `--clean` to remove orphaned steps during deployment + - `--what-if` to preview changes without applying +- Full step registration field support: + - `deployment` - ServerOnly (default), Offline, or Both + - `runAsUser` - CallingUser (default) or systemuser GUID + - `description` - Step documentation + - `asyncAutoDelete` - Auto-delete async jobs on success +- Extract command enhancements: + - `--solution` option to set solution on initial extract + - `--force` option to skip merge and overwrite + - Merge behavior: re-running extract preserves deployment settings from existing file + - `[JsonExtensionData]` on all config models for forward compatibility +- List command enhancements: + - `--package` filter to list specific packages + - Full package hierarchy output: Package → Assembly → Type → Step → Image + - Summary includes types and images with proper pluralization + - Shows non-default step options (deployment, runAsUser, asyncAutoDelete) +- Uses `MetadataLoadContext` for safe, read-only assembly reflection +- JSON output for all plugin commands (`--json` flag) +- Supports both classic assemblies and NuGet plugin packages +- Connection pooling for plugin commands (improved performance) + ## [1.0.0-beta.1] - 2025-12-29 ### Added @@ -30,5 +64,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Packaged as .NET global tool (`ppds`) - Targets: `net10.0` -[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.1...HEAD +[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.2...HEAD +[1.0.0-beta.2]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.1...Cli-v1.0.0-beta.2 [1.0.0-beta.1]: https://github.com/joshsmithxrm/ppds-sdk/releases/tag/Cli-v1.0.0-beta.1 diff --git a/src/PPDS.Cli/Commands/Plugins/CleanCommand.cs b/src/PPDS.Cli/Commands/Plugins/CleanCommand.cs new file mode 100644 index 000000000..9cd344bfe --- /dev/null +++ b/src/PPDS.Cli/Commands/Plugins/CleanCommand.cs @@ -0,0 +1,319 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Plugins.Models; +using PPDS.Cli.Plugins.Registration; +using PPDS.Dataverse.Pooling; + +namespace PPDS.Cli.Commands.Plugins; + +/// +/// Remove orphaned plugin registrations not in configuration. +/// +public static class CleanCommand +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static Command Create() + { + var configOption = new Option("--config", "-c") + { + Description = "Path to registrations.json", + Required = true + }.AcceptExistingOnly(); + + var whatIfOption = new Option("--what-if") + { + Description = "Preview deletions without applying", + DefaultValueFactory = _ => false + }; + + var command = new Command("clean", "Remove orphaned registrations not in configuration") + { + configOption, + PluginsCommandGroup.ProfileOption, + PluginsCommandGroup.EnvironmentOption, + whatIfOption, + PluginsCommandGroup.JsonOption + }; + + command.SetAction(async (parseResult, cancellationToken) => + { + var config = parseResult.GetValue(configOption)!; + var profile = parseResult.GetValue(PluginsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(PluginsCommandGroup.EnvironmentOption); + var whatIf = parseResult.GetValue(whatIfOption); + var json = parseResult.GetValue(PluginsCommandGroup.JsonOption); + + return await ExecuteAsync(config, profile, environment, whatIf, json, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + FileInfo configFile, + string? profile, + string? environment, + bool whatIf, + bool json, + CancellationToken cancellationToken) + { + try + { + // Load configuration + var configJson = await File.ReadAllTextAsync(configFile.FullName, cancellationToken); + var config = JsonSerializer.Deserialize(configJson, JsonReadOptions); + + if (config?.Assemblies == null || config.Assemblies.Count == 0) + { + Console.Error.WriteLine("No assemblies found in configuration file."); + return ExitCodes.Failure; + } + + // Connect to Dataverse + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + verbose: false, + debug: false, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var pool = serviceProvider.GetRequiredService(); + await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken); + var registrationService = new PluginRegistrationService(client); + + if (!json) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.WriteLine(); + + if (whatIf) + { + Console.WriteLine("[What-If Mode] No changes will be applied."); + Console.WriteLine(); + } + } + + var results = new List(); + + foreach (var assemblyConfig in config.Assemblies) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await CleanAssemblyAsync( + registrationService, + assemblyConfig, + whatIf, + json, + cancellationToken); + + results.Add(result); + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(results, JsonWriteOptions)); + } + else + { + Console.WriteLine(); + var totalOrphans = results.Sum(r => r.OrphanedSteps.Count); + var totalDeleted = results.Sum(r => r.StepsDeleted); + var totalTypesDeleted = results.Sum(r => r.TypesDeleted); + + if (totalOrphans == 0) + { + Console.WriteLine("No orphaned registrations found."); + } + else if (whatIf) + { + Console.WriteLine($"Would delete: {totalOrphans} step(s), {totalTypesDeleted} orphaned type(s)"); + } + else + { + Console.WriteLine($"Deleted: {totalDeleted} step(s), {totalTypesDeleted} orphaned type(s)"); + } + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error cleaning plugins: {ex.Message}"); + return ExitCodes.Failure; + } + } + + private static async Task CleanAssemblyAsync( + PluginRegistrationService service, + PluginAssemblyConfig assemblyConfig, + bool whatIf, + bool json, + CancellationToken cancellationToken) + { + var result = new CleanResult + { + AssemblyName = assemblyConfig.Name + }; + + // Check if assembly exists + var assembly = await service.GetAssemblyByNameAsync(assemblyConfig.Name); + if (assembly == null) + { + if (!json) + Console.WriteLine($"Assembly not found: {assemblyConfig.Name}"); + return result; + } + + if (!json) + Console.WriteLine($"Checking assembly: {assemblyConfig.Name}"); + + // Build set of configured step names + var configuredStepNames = new HashSet(); + foreach (var typeConfig in assemblyConfig.Types) + { + foreach (var stepConfig in typeConfig.Steps) + { + var stepName = stepConfig.Name ?? $"{typeConfig.TypeName}: {stepConfig.Message} of {stepConfig.Entity}"; + configuredStepNames.Add(stepName); + } + } + + // Get existing types and steps + var existingTypes = await service.ListTypesForAssemblyAsync(assembly.Id); + + foreach (var existingType in existingTypes) + { + cancellationToken.ThrowIfCancellationRequested(); + + var steps = await service.ListStepsForTypeAsync(existingType.Id); + + // Find orphaned steps and track count for type cleanup + var orphanedStepsInType = steps.Where(s => !configuredStepNames.Contains(s.Name)).ToList(); + + foreach (var step in orphanedStepsInType) + { + result.OrphanedSteps.Add(new OrphanedStep + { + TypeName = existingType.TypeName, + StepName = step.Name, + StepId = step.Id + }); + + if (whatIf) + { + if (!json) + Console.WriteLine($" [What-If] Would delete step: {step.Name}"); + } + else + { + await service.DeleteStepAsync(step.Id); + result.StepsDeleted++; + if (!json) + Console.WriteLine($" Deleted step: {step.Name}"); + } + } + + // Check if type is orphaned (no configured steps and not in allTypeNames) + var typeHasConfiguredSteps = assemblyConfig.Types + .Any(t => t.TypeName == existingType.TypeName && t.Steps.Count > 0); + + var typeInAllTypeNames = assemblyConfig.AllTypeNames.Contains(existingType.TypeName); + + if (!typeHasConfiguredSteps && !typeInAllTypeNames) + { + // Calculate remaining steps in memory instead of re-querying + var remainingStepsCount = steps.Count - orphanedStepsInType.Count; + + if (remainingStepsCount == 0) + { + result.OrphanedTypes.Add(new OrphanedType + { + TypeName = existingType.TypeName, + TypeId = existingType.Id + }); + + if (whatIf) + { + if (!json) + Console.WriteLine($" [What-If] Would delete orphaned type: {existingType.TypeName}"); + } + else + { + try + { + await service.DeletePluginTypeAsync(existingType.Id); + result.TypesDeleted++; + if (!json) + Console.WriteLine($" Deleted orphaned type: {existingType.TypeName}"); + } + catch (Exception ex) + { + if (!json) + Console.WriteLine($" Warning: Could not delete type {existingType.TypeName}: {ex.Message}"); + } + } + } + } + } + + return result; + } + + #region Result Models + + private sealed class CleanResult + { + [JsonPropertyName("assemblyName")] + public string AssemblyName { get; set; } = string.Empty; + + [JsonPropertyName("orphanedSteps")] + public List OrphanedSteps { get; set; } = []; + + [JsonPropertyName("orphanedTypes")] + public List OrphanedTypes { get; set; } = []; + + [JsonPropertyName("stepsDeleted")] + public int StepsDeleted { get; set; } + + [JsonPropertyName("typesDeleted")] + public int TypesDeleted { get; set; } + } + + private sealed class OrphanedStep + { + [JsonPropertyName("typeName")] + public string TypeName { get; set; } = string.Empty; + + [JsonPropertyName("stepName")] + public string StepName { get; set; } = string.Empty; + + [JsonPropertyName("stepId")] + public Guid StepId { get; set; } + } + + private sealed class OrphanedType + { + [JsonPropertyName("typeName")] + public string TypeName { get; set; } = string.Empty; + + [JsonPropertyName("typeId")] + public Guid TypeId { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Plugins/DeployCommand.cs b/src/PPDS.Cli/Commands/Plugins/DeployCommand.cs new file mode 100644 index 000000000..01057989f --- /dev/null +++ b/src/PPDS.Cli/Commands/Plugins/DeployCommand.cs @@ -0,0 +1,464 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Plugins.Models; +using PPDS.Cli.Plugins.Registration; +using PPDS.Dataverse.Pooling; + +namespace PPDS.Cli.Commands.Plugins; + +/// +/// Deploy plugin registrations to a Dataverse environment. +/// +public static class DeployCommand +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static Command Create() + { + var configOption = new Option("--config", "-c") + { + Description = "Path to registrations.json", + Required = true + }.AcceptExistingOnly(); + + var cleanOption = new Option("--clean") + { + Description = "Also remove orphaned registrations not in config", + DefaultValueFactory = _ => false + }; + + var whatIfOption = new Option("--what-if") + { + Description = "Preview changes without applying", + DefaultValueFactory = _ => false + }; + + var command = new Command("deploy", "Deploy plugin registrations to environment") + { + configOption, + PluginsCommandGroup.ProfileOption, + PluginsCommandGroup.EnvironmentOption, + PluginsCommandGroup.SolutionOption, + cleanOption, + whatIfOption, + PluginsCommandGroup.JsonOption + }; + + command.SetAction(async (parseResult, cancellationToken) => + { + var config = parseResult.GetValue(configOption)!; + var profile = parseResult.GetValue(PluginsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(PluginsCommandGroup.EnvironmentOption); + var solution = parseResult.GetValue(PluginsCommandGroup.SolutionOption); + var clean = parseResult.GetValue(cleanOption); + var whatIf = parseResult.GetValue(whatIfOption); + var json = parseResult.GetValue(PluginsCommandGroup.JsonOption); + + return await ExecuteAsync(config, profile, environment, solution, clean, whatIf, json, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + FileInfo configFile, + string? profile, + string? environment, + string? solutionOverride, + bool clean, + bool whatIf, + bool json, + CancellationToken cancellationToken) + { + try + { + // Load configuration + var configJson = await File.ReadAllTextAsync(configFile.FullName, cancellationToken); + var config = JsonSerializer.Deserialize(configJson, JsonReadOptions); + + if (config?.Assemblies == null || config.Assemblies.Count == 0) + { + Console.Error.WriteLine("No assemblies found in configuration file."); + return ExitCodes.Failure; + } + + // Connect to Dataverse + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + verbose: false, + debug: false, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var pool = serviceProvider.GetRequiredService(); + await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken); + var registrationService = new PluginRegistrationService(client); + + if (!json) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.WriteLine(); + + if (whatIf) + { + Console.WriteLine("[What-If Mode] No changes will be applied."); + Console.WriteLine(); + } + } + + var configDir = configFile.DirectoryName ?? "."; + var results = new List(); + + foreach (var assemblyConfig in config.Assemblies) + { + var result = await DeployAssemblyAsync( + registrationService, + assemblyConfig, + configDir, + solutionOverride, + clean, + whatIf, + json, + cancellationToken); + + results.Add(result); + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(results, JsonWriteOptions)); + } + else + { + Console.WriteLine(); + var totalCreated = results.Sum(r => r.StepsCreated + r.ImagesCreated); + var totalUpdated = results.Sum(r => r.StepsUpdated + r.ImagesUpdated); + var totalDeleted = results.Sum(r => r.StepsDeleted + r.ImagesDeleted); + + Console.WriteLine($"Deployment complete: {totalCreated} created, {totalUpdated} updated, {totalDeleted} deleted"); + } + + return results.Any(r => !r.Success) ? ExitCodes.Failure : ExitCodes.Success; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error deploying plugins: {ex.Message}"); + return ExitCodes.Failure; + } + } + + private static async Task DeployAssemblyAsync( + PluginRegistrationService service, + PluginAssemblyConfig assemblyConfig, + string configDir, + string? solutionOverride, + bool clean, + bool whatIf, + bool json, + CancellationToken cancellationToken) + { + var result = new DeploymentResult + { + AssemblyName = assemblyConfig.Name, + Success = true + }; + + var solution = solutionOverride ?? assemblyConfig.Solution; + + try + { + if (!json) + Console.WriteLine($"Deploying assembly: {assemblyConfig.Name}"); + + // Resolve assembly path + var assemblyPath = ResolveAssemblyPath(assemblyConfig, configDir); + if (assemblyPath == null || !File.Exists(assemblyPath)) + { + throw new FileNotFoundException($"Assembly file not found: {assemblyConfig.Path ?? assemblyConfig.PackagePath}"); + } + + // Read assembly bytes + byte[] assemblyBytes; + if (assemblyConfig.Type == "Nuget") + { + // For NuGet packages, we need to extract the DLL + assemblyBytes = ExtractDllFromNupkg(assemblyPath, assemblyConfig.Name); + } + else + { + assemblyBytes = await File.ReadAllBytesAsync(assemblyPath, cancellationToken); + } + + // Upsert assembly + Guid assemblyId; + if (whatIf) + { + var existing = await service.GetAssemblyByNameAsync(assemblyConfig.Name); + assemblyId = existing?.Id ?? Guid.NewGuid(); + if (!json) + Console.WriteLine($" [What-If] Would {(existing == null ? "create" : "update")} assembly"); + } + else + { + assemblyId = await service.UpsertAssemblyAsync(assemblyConfig.Name, assemblyBytes, solution); + if (!json) + Console.WriteLine($" Assembly registered: {assemblyId}"); + } + + // Track existing steps for orphan detection - use dictionary for O(1) lookup during cleanup + var existingStepsMap = new Dictionary(); + var configuredStepNames = new HashSet(); + + // Get existing types and steps + var existingTypes = await service.ListTypesForAssemblyAsync(assemblyId); + var existingTypeMap = existingTypes.ToDictionary(t => t.TypeName, t => t); + + foreach (var existingType in existingTypes) + { + var steps = await service.ListStepsForTypeAsync(existingType.Id); + foreach (var step in steps) + { + existingStepsMap[step.Name] = step; + } + } + + // Deploy each type + foreach (var typeConfig in assemblyConfig.Types) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Upsert plugin type + Guid typeId; + if (whatIf) + { + typeId = existingTypeMap.TryGetValue(typeConfig.TypeName, out var existing) + ? existing.Id + : Guid.NewGuid(); + if (!json) + Console.WriteLine($" [What-If] Would register type: {typeConfig.TypeName}"); + } + else + { + typeId = await service.UpsertPluginTypeAsync(assemblyId, typeConfig.TypeName, solution); + if (!json) + Console.WriteLine($" Type registered: {typeConfig.TypeName}"); + } + + // Deploy each step + foreach (var stepConfig in typeConfig.Steps) + { + var stepName = stepConfig.Name ?? $"{typeConfig.TypeName}: {stepConfig.Message} of {stepConfig.Entity}"; + configuredStepNames.Add(stepName); + + // Lookup message and filter + var messageId = await service.GetSdkMessageIdAsync(stepConfig.Message); + if (messageId == null) + { + if (!json) + Console.WriteLine($" [Skip] Unknown message: {stepConfig.Message}"); + continue; + } + + var filterId = await service.GetSdkMessageFilterIdAsync( + messageId.Value, + stepConfig.Entity, + stepConfig.SecondaryEntity); + + var isNew = !existingStepsMap.ContainsKey(stepName); + + Guid stepId; + if (whatIf) + { + stepId = Guid.NewGuid(); + if (!json) + Console.WriteLine($" [What-If] Would {(isNew ? "create" : "update")} step: {stepName}"); + + if (isNew) result.StepsCreated++; + else result.StepsUpdated++; + } + else + { + stepId = await service.UpsertStepAsync(typeId, stepConfig, messageId.Value, filterId, solution); + if (!json) + Console.WriteLine($" Step {(isNew ? "created" : "updated")}: {stepName}"); + + if (isNew) result.StepsCreated++; + else result.StepsUpdated++; + } + + // Deploy images (skip query in what-if mode or for new steps since stepId doesn't exist) + var existingImages = whatIf || isNew ? [] : await service.ListImagesForStepAsync(stepId); + var existingImageNames = existingImages.Select(i => i.Name).ToHashSet(); + + foreach (var imageConfig in stepConfig.Images) + { + var imageIsNew = !existingImageNames.Contains(imageConfig.Name); + + if (whatIf) + { + if (!json) + Console.WriteLine($" [What-If] Would {(imageIsNew ? "create" : "update")} image: {imageConfig.Name}"); + + if (imageIsNew) result.ImagesCreated++; + else result.ImagesUpdated++; + } + else + { + await service.UpsertImageAsync(stepId, imageConfig); + if (!json) + Console.WriteLine($" Image {(imageIsNew ? "created" : "updated")}: {imageConfig.Name}"); + + if (imageIsNew) result.ImagesCreated++; + else result.ImagesUpdated++; + } + } + } + } + + // Handle orphan cleanup if requested + if (clean) + { + var orphanedStepNames = existingStepsMap.Keys.Except(configuredStepNames).ToList(); + + if (orphanedStepNames.Count > 0) + { + if (!json) + Console.WriteLine($" Cleaning {orphanedStepNames.Count} orphaned step(s)..."); + + foreach (var orphanName in orphanedStepNames) + { + // Use dictionary lookup instead of re-querying + if (existingStepsMap.TryGetValue(orphanName, out var orphanStep)) + { + if (whatIf) + { + if (!json) + Console.WriteLine($" [What-If] Would delete step: {orphanName}"); + result.StepsDeleted++; + } + else + { + await service.DeleteStepAsync(orphanStep.Id); + if (!json) + Console.WriteLine($" Deleted step: {orphanName}"); + result.StepsDeleted++; + } + } + } + } + } + } + catch (Exception ex) + { + result.Success = false; + result.Error = ex.Message; + + if (!json) + Console.Error.WriteLine($" Error: {ex.Message}"); + } + + return result; + } + + private static string? ResolveAssemblyPath(PluginAssemblyConfig config, string configDir) + { + if (config.Type == "Nuget" && !string.IsNullOrEmpty(config.PackagePath)) + { + return Path.GetFullPath(Path.Combine(configDir, config.PackagePath)); + } + + if (!string.IsNullOrEmpty(config.Path)) + { + return Path.GetFullPath(Path.Combine(configDir, config.Path)); + } + + return null; + } + + private static byte[] ExtractDllFromNupkg(string nupkgPath, string assemblyName) + { + using var archive = System.IO.Compression.ZipFile.OpenRead(nupkgPath); + + // Look for the DLL in lib/net462 (preferred) or any lib folder + var possiblePaths = new[] + { + $"lib/net462/{assemblyName}.dll", + $"lib/net48/{assemblyName}.dll", + $"lib/netstandard2.0/{assemblyName}.dll" + }; + + foreach (var path in possiblePaths) + { + var entry = archive.GetEntry(path) ?? archive.GetEntry(path.Replace('/', '\\')); + if (entry != null) + { + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + } + + // Fallback: find any DLL matching the assembly name + var matchingEntry = archive.Entries + .FirstOrDefault(e => e.FullName.EndsWith($"{assemblyName}.dll", StringComparison.OrdinalIgnoreCase)); + + if (matchingEntry != null) + { + using var stream = matchingEntry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + + throw new InvalidOperationException($"Could not find {assemblyName}.dll in NuGet package"); + } + + #region Result Models + + private sealed class DeploymentResult + { + [JsonPropertyName("assemblyName")] + public string AssemblyName { get; set; } = string.Empty; + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("stepsCreated")] + public int StepsCreated { get; set; } + + [JsonPropertyName("stepsUpdated")] + public int StepsUpdated { get; set; } + + [JsonPropertyName("stepsDeleted")] + public int StepsDeleted { get; set; } + + [JsonPropertyName("imagesCreated")] + public int ImagesCreated { get; set; } + + [JsonPropertyName("imagesUpdated")] + public int ImagesUpdated { get; set; } + + [JsonPropertyName("imagesDeleted")] + public int ImagesDeleted { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Plugins/DiffCommand.cs b/src/PPDS.Cli/Commands/Plugins/DiffCommand.cs new file mode 100644 index 000000000..f00500ea0 --- /dev/null +++ b/src/PPDS.Cli/Commands/Plugins/DiffCommand.cs @@ -0,0 +1,486 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Plugins.Models; +using PPDS.Cli.Plugins.Registration; +using PPDS.Dataverse.Pooling; + +namespace PPDS.Cli.Commands.Plugins; + +/// +/// Compare plugin configuration against Dataverse environment state. +/// +public static class DiffCommand +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static Command Create() + { + var configOption = new Option("--config", "-c") + { + Description = "Path to registrations.json", + Required = true + }.AcceptExistingOnly(); + + var command = new Command("diff", "Compare configuration against environment state") + { + configOption, + PluginsCommandGroup.ProfileOption, + PluginsCommandGroup.EnvironmentOption, + PluginsCommandGroup.JsonOption + }; + + command.SetAction(async (parseResult, cancellationToken) => + { + var config = parseResult.GetValue(configOption)!; + var profile = parseResult.GetValue(PluginsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(PluginsCommandGroup.EnvironmentOption); + var json = parseResult.GetValue(PluginsCommandGroup.JsonOption); + + return await ExecuteAsync(config, profile, environment, json, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + FileInfo configFile, + string? profile, + string? environment, + bool json, + CancellationToken cancellationToken) + { + try + { + // Load configuration + var configJson = await File.ReadAllTextAsync(configFile.FullName, cancellationToken); + var config = JsonSerializer.Deserialize(configJson, JsonReadOptions); + + if (config?.Assemblies == null || config.Assemblies.Count == 0) + { + Console.Error.WriteLine("No assemblies found in configuration file."); + return ExitCodes.Failure; + } + + // Connect to Dataverse + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + verbose: false, + debug: false, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var pool = serviceProvider.GetRequiredService(); + await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken); + var registrationService = new PluginRegistrationService(client); + + if (!json) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.WriteLine(); + } + + var allDrifts = new List(); + var hasDrift = false; + + foreach (var assemblyConfig in config.Assemblies) + { + var drift = await ComputeDriftAsync(registrationService, assemblyConfig); + allDrifts.Add(drift); + + if (drift.HasDrift) + hasDrift = true; + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(allDrifts, JsonWriteOptions)); + } + else + { + if (!hasDrift) + { + Console.WriteLine("No drift detected. Environment matches configuration."); + return ExitCodes.Success; + } + + foreach (var drift in allDrifts) + { + if (!drift.HasDrift) + continue; + + Console.WriteLine($"Assembly: {drift.AssemblyName}"); + + if (drift.AssemblyMissing) + { + Console.WriteLine(" [MISSING] Assembly not registered in environment"); + continue; + } + + foreach (var missing in drift.MissingSteps) + { + Console.WriteLine($" [+] Missing step: {missing.StepName}"); + Console.WriteLine($" {missing.Message} on {missing.Entity} ({missing.Stage}, {missing.Mode})"); + } + + foreach (var orphan in drift.OrphanedSteps) + { + Console.WriteLine($" [-] Orphaned step: {orphan.StepName}"); + Console.WriteLine($" {orphan.Message} on {orphan.Entity} ({orphan.Stage}, {orphan.Mode})"); + } + + foreach (var modified in drift.ModifiedSteps) + { + Console.WriteLine($" [~] Modified step: {modified.StepName}"); + foreach (var change in modified.Changes) + { + Console.WriteLine($" {change.Property}: {change.Expected} -> {change.Actual}"); + } + } + + foreach (var missingImage in drift.MissingImages) + { + Console.WriteLine($" [+] Missing image: {missingImage.ImageName} on step {missingImage.StepName}"); + } + + foreach (var orphanImage in drift.OrphanedImages) + { + Console.WriteLine($" [-] Orphaned image: {orphanImage.ImageName} on step {orphanImage.StepName}"); + } + + foreach (var modifiedImage in drift.ModifiedImages) + { + Console.WriteLine($" [~] Modified image: {modifiedImage.ImageName} on step {modifiedImage.StepName}"); + foreach (var change in modifiedImage.Changes) + { + Console.WriteLine($" {change.Property}: {change.Expected} -> {change.Actual}"); + } + } + + Console.WriteLine(); + } + } + + return hasDrift ? 1 : ExitCodes.Success; // Return 1 if drift detected (useful for CI) + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error comparing plugins: {ex.Message}"); + return ExitCodes.Failure; + } + } + + private static async Task ComputeDriftAsync( + PluginRegistrationService service, + PluginAssemblyConfig config) + { + var drift = new AssemblyDrift + { + AssemblyName = config.Name + }; + + // Check if assembly exists + var assembly = await service.GetAssemblyByNameAsync(config.Name); + if (assembly == null) + { + drift.AssemblyMissing = true; + return drift; + } + + // Get all types for this assembly + var existingTypes = await service.ListTypesForAssemblyAsync(assembly.Id); + + // Build a map of all configured steps + var configuredSteps = new Dictionary(); + foreach (var typeConfig in config.Types) + { + foreach (var stepConfig in typeConfig.Steps) + { + var key = stepConfig.Name ?? $"{typeConfig.TypeName}: {stepConfig.Message} of {stepConfig.Entity}"; + configuredSteps[key] = (typeConfig, stepConfig); + } + } + + // Get all existing steps + var existingSteps = new Dictionary(); + foreach (var existingType in existingTypes) + { + var steps = await service.ListStepsForTypeAsync(existingType.Id); + foreach (var step in steps) + { + existingSteps[step.Name] = (existingType, step); + } + } + + // Find missing steps (in config, not in environment) + foreach (var (stepName, (typeConfig, stepConfig)) in configuredSteps) + { + if (!existingSteps.ContainsKey(stepName)) + { + drift.MissingSteps.Add(new StepDrift + { + TypeName = typeConfig.TypeName, + StepName = stepName, + Message = stepConfig.Message, + Entity = stepConfig.Entity, + Stage = stepConfig.Stage, + Mode = stepConfig.Mode + }); + } + } + + // Find orphaned steps (in environment, not in config) + foreach (var (stepName, (typeInfo, stepInfo)) in existingSteps) + { + if (!configuredSteps.ContainsKey(stepName)) + { + drift.OrphanedSteps.Add(new StepDrift + { + TypeName = typeInfo.TypeName, + StepName = stepName, + Message = stepInfo.Message, + Entity = stepInfo.PrimaryEntity, + Stage = stepInfo.Stage, + Mode = stepInfo.Mode + }); + } + } + + // Find modified steps and check images + foreach (var (stepName, (typeConfig, stepConfig)) in configuredSteps) + { + if (!existingSteps.TryGetValue(stepName, out var existing)) + continue; + + var (_, stepInfo) = existing; + + // Compare step properties + var changes = new List(); + + if (!string.Equals(stepConfig.Stage, stepInfo.Stage, StringComparison.OrdinalIgnoreCase)) + changes.Add(new PropertyChange("stage", stepConfig.Stage, stepInfo.Stage)); + + if (!string.Equals(stepConfig.Mode, stepInfo.Mode, StringComparison.OrdinalIgnoreCase)) + changes.Add(new PropertyChange("mode", stepConfig.Mode, stepInfo.Mode)); + + if (stepConfig.ExecutionOrder != stepInfo.ExecutionOrder) + changes.Add(new PropertyChange("executionOrder", stepConfig.ExecutionOrder.ToString(), stepInfo.ExecutionOrder.ToString())); + + var configFiltering = NormalizeAttributes(stepConfig.FilteringAttributes); + var envFiltering = NormalizeAttributes(stepInfo.FilteringAttributes); + if (!string.Equals(configFiltering, envFiltering, StringComparison.OrdinalIgnoreCase)) + changes.Add(new PropertyChange("filteringAttributes", configFiltering ?? "(none)", envFiltering ?? "(none)")); + + if (changes.Count > 0) + { + drift.ModifiedSteps.Add(new ModifiedStepDrift + { + TypeName = typeConfig.TypeName, + StepName = stepName, + Changes = changes + }); + } + + // Compare images + var existingImages = await service.ListImagesForStepAsync(stepInfo.Id); + var existingImageMap = existingImages.ToDictionary(i => i.Name, i => i); + var configImageMap = stepConfig.Images.ToDictionary(i => i.Name, i => i); + + // Missing images + foreach (var imageConfig in stepConfig.Images) + { + if (!existingImageMap.ContainsKey(imageConfig.Name)) + { + drift.MissingImages.Add(new ImageDrift + { + StepName = stepName, + ImageName = imageConfig.Name + }); + } + } + + // Orphaned images + foreach (var imageInfo in existingImages) + { + if (!configImageMap.ContainsKey(imageInfo.Name)) + { + drift.OrphanedImages.Add(new ImageDrift + { + StepName = stepName, + ImageName = imageInfo.Name + }); + } + } + + // Modified images + foreach (var imageConfig in stepConfig.Images) + { + if (!existingImageMap.TryGetValue(imageConfig.Name, out var imageInfo)) + continue; + + var imageChanges = new List(); + + if (!string.Equals(imageConfig.ImageType, imageInfo.ImageType, StringComparison.OrdinalIgnoreCase)) + imageChanges.Add(new PropertyChange("imageType", imageConfig.ImageType, imageInfo.ImageType)); + + var configAttrs = NormalizeAttributes(imageConfig.Attributes); + var envAttrs = NormalizeAttributes(imageInfo.Attributes); + if (!string.Equals(configAttrs, envAttrs, StringComparison.OrdinalIgnoreCase)) + imageChanges.Add(new PropertyChange("attributes", configAttrs ?? "(all)", envAttrs ?? "(all)")); + + if (imageChanges.Count > 0) + { + drift.ModifiedImages.Add(new ModifiedImageDrift + { + StepName = stepName, + ImageName = imageConfig.Name, + Changes = imageChanges + }); + } + } + } + + return drift; + } + + private static string? NormalizeAttributes(string? attributes) + { + if (string.IsNullOrWhiteSpace(attributes)) + return null; + + // Sort attributes for consistent comparison + var sorted = attributes.Split(',') + .Select(a => a.Trim().ToLowerInvariant()) + .Where(a => !string.IsNullOrEmpty(a)) + .OrderBy(a => a) + .ToArray(); + + return sorted.Length > 0 ? string.Join(",", sorted) : null; + } + + #region Drift Models + + private sealed class AssemblyDrift + { + [JsonPropertyName("assemblyName")] + public string AssemblyName { get; set; } = string.Empty; + + [JsonPropertyName("assemblyMissing")] + public bool AssemblyMissing { get; set; } + + [JsonPropertyName("missingSteps")] + public List MissingSteps { get; set; } = []; + + [JsonPropertyName("orphanedSteps")] + public List OrphanedSteps { get; set; } = []; + + [JsonPropertyName("modifiedSteps")] + public List ModifiedSteps { get; set; } = []; + + [JsonPropertyName("missingImages")] + public List MissingImages { get; set; } = []; + + [JsonPropertyName("orphanedImages")] + public List OrphanedImages { get; set; } = []; + + [JsonPropertyName("modifiedImages")] + public List ModifiedImages { get; set; } = []; + + [JsonIgnore] + public bool HasDrift => AssemblyMissing || + MissingSteps.Count > 0 || + OrphanedSteps.Count > 0 || + ModifiedSteps.Count > 0 || + MissingImages.Count > 0 || + OrphanedImages.Count > 0 || + ModifiedImages.Count > 0; + } + + private sealed class StepDrift + { + [JsonPropertyName("typeName")] + public string TypeName { get; set; } = string.Empty; + + [JsonPropertyName("stepName")] + public string StepName { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("entity")] + public string Entity { get; set; } = string.Empty; + + [JsonPropertyName("stage")] + public string Stage { get; set; } = string.Empty; + + [JsonPropertyName("mode")] + public string Mode { get; set; } = string.Empty; + } + + private sealed class ModifiedStepDrift + { + [JsonPropertyName("typeName")] + public string TypeName { get; set; } = string.Empty; + + [JsonPropertyName("stepName")] + public string StepName { get; set; } = string.Empty; + + [JsonPropertyName("changes")] + public List Changes { get; set; } = []; + } + + private sealed class ImageDrift + { + [JsonPropertyName("stepName")] + public string StepName { get; set; } = string.Empty; + + [JsonPropertyName("imageName")] + public string ImageName { get; set; } = string.Empty; + } + + private sealed class ModifiedImageDrift + { + [JsonPropertyName("stepName")] + public string StepName { get; set; } = string.Empty; + + [JsonPropertyName("imageName")] + public string ImageName { get; set; } = string.Empty; + + [JsonPropertyName("changes")] + public List Changes { get; set; } = []; + } + + private sealed class PropertyChange + { + [JsonPropertyName("property")] + public string Property { get; set; } + + [JsonPropertyName("expected")] + public string Expected { get; set; } + + [JsonPropertyName("actual")] + public string Actual { get; set; } + + public PropertyChange(string property, string expected, string actual) + { + Property = property; + Expected = expected; + Actual = actual; + } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs b/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs new file mode 100644 index 000000000..13f5e3bb1 --- /dev/null +++ b/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs @@ -0,0 +1,253 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using PPDS.Cli.Plugins.Extraction; +using PPDS.Cli.Plugins.Models; + +namespace PPDS.Cli.Commands.Plugins; + +/// +/// Extract plugin registrations from assembly or NuGet package to JSON configuration. +/// +public static class ExtractCommand +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static Command Create() + { + var inputOption = new Option("--input", "-i") + { + Description = "Path to assembly (.dll) or plugin package (.nupkg)", + Required = true + }.AcceptExistingOnly(); + + var outputOption = new Option("--output", "-o") + { + Description = "Output file path (default: registrations.json in input directory)" + }; + + var solutionOption = new Option("--solution", "-s") + { + Description = "Solution unique name to add components to" + }; + + var forceOption = new Option("--force", "-f") + { + Description = "Overwrite existing file without merging" + }; + + var command = new Command("extract", "Extract plugin step/image attributes from assembly to JSON configuration") + { + inputOption, + outputOption, + solutionOption, + forceOption, + PluginsCommandGroup.JsonOption + }; + + command.SetAction(async (parseResult, cancellationToken) => + { + var input = parseResult.GetValue(inputOption)!; + var output = parseResult.GetValue(outputOption); + var solution = parseResult.GetValue(solutionOption); + var force = parseResult.GetValue(forceOption); + var json = parseResult.GetValue(PluginsCommandGroup.JsonOption); + + return await ExecuteAsync(input, output, solution, force, json, cancellationToken); + }); + + return command; + } + + private static Task ExecuteAsync( + FileInfo input, + FileInfo? output, + string? solution, + bool force, + bool json, + CancellationToken cancellationToken) + { + try + { + var extension = input.Extension.ToLowerInvariant(); + PluginAssemblyConfig assemblyConfig; + + if (extension == ".nupkg") + { + if (!json) + Console.WriteLine($"Extracting from NuGet package: {input.Name}"); + + assemblyConfig = NupkgExtractor.Extract(input.FullName); + } + else if (extension == ".dll") + { + if (!json) + Console.WriteLine($"Extracting from assembly: {input.Name}"); + + using var extractor = AssemblyExtractor.Create(input.FullName); + assemblyConfig = extractor.Extract(); + } + else + { + Console.Error.WriteLine($"Unsupported file type: {extension}. Expected .dll or .nupkg"); + return Task.FromResult(ExitCodes.Failure); + } + + // Make path relative to output location + var inputDir = input.DirectoryName ?? "."; + var outputPath = output?.FullName ?? Path.Combine(inputDir, "registrations.json"); + var outputDir = Path.GetDirectoryName(outputPath) ?? "."; + + // Calculate relative path from output to input + var relativePath = Path.GetRelativePath(outputDir, input.FullName); + if (assemblyConfig.Type == "Assembly") + { + assemblyConfig.Path = relativePath; + } + else + { + assemblyConfig.PackagePath = relativePath; + // For nupkg, path should point to the extracted DLL location (relative) + assemblyConfig.Path = null; + } + + // Apply solution from CLI if provided + if (!string.IsNullOrEmpty(solution)) + { + assemblyConfig.Solution = solution; + } + + // Check for existing file and merge if not forced + PluginRegistrationConfig config; + var existingFile = new FileInfo(outputPath); + + if (existingFile.Exists && !force && !json) + { + Console.WriteLine($"Merging with existing configuration..."); + + var existingContent = File.ReadAllText(outputPath); + var existingConfig = JsonSerializer.Deserialize(existingContent, JsonOptions); + + if (existingConfig != null) + { + // Merge: preserve deployment settings from existing, update code-derived values from fresh + MergeAssemblyConfig(assemblyConfig, existingConfig); + } + + config = new PluginRegistrationConfig + { + Schema = "https://raw.githubusercontent.com/joshsmithxrm/ppds-sdk/main/schemas/plugin-registration.schema.json", + Version = existingConfig?.Version ?? "1.0", + GeneratedAt = DateTimeOffset.UtcNow, + Assemblies = [assemblyConfig], + ExtensionData = existingConfig?.ExtensionData + }; + } + else + { + config = new PluginRegistrationConfig + { + Schema = "https://raw.githubusercontent.com/joshsmithxrm/ppds-sdk/main/schemas/plugin-registration.schema.json", + Version = "1.0", + GeneratedAt = DateTimeOffset.UtcNow, + Assemblies = [assemblyConfig] + }; + } + + var jsonContent = JsonSerializer.Serialize(config, JsonOptions); + + if (json) + { + // Output to stdout for tool integration + Console.WriteLine(jsonContent); + } + else + { + // Write to file + File.WriteAllText(outputPath, jsonContent); + Console.WriteLine(); + Console.WriteLine($"Found {assemblyConfig.Types.Count} plugin type(s) with {assemblyConfig.Types.Sum(t => t.Steps.Count)} step(s)"); + Console.WriteLine($"Output: {outputPath}"); + } + + return Task.FromResult(ExitCodes.Success); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error extracting plugin registrations: {ex.Message}"); + return Task.FromResult(ExitCodes.Failure); + } + } + + /// + /// Merges deployment settings from existing config into fresh extracted config. + /// Preserves: solution, runAsUser, description, deployment, asyncAutoDelete, and any unknown fields. + /// + private static void MergeAssemblyConfig(PluginAssemblyConfig fresh, PluginRegistrationConfig existing) + { + var existingAssembly = existing.Assemblies.FirstOrDefault(a => + a.Name.Equals(fresh.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingAssembly == null) + return; + + // Preserve assembly-level deployment settings (only if not set via CLI) + if (string.IsNullOrEmpty(fresh.Solution)) + fresh.Solution = existingAssembly.Solution; + + // Preserve unknown fields at assembly level + fresh.ExtensionData = existingAssembly.ExtensionData; + + // Merge types + foreach (var freshType in fresh.Types) + { + var existingType = existingAssembly.Types.FirstOrDefault(t => + t.TypeName.Equals(freshType.TypeName, StringComparison.OrdinalIgnoreCase)); + + if (existingType == null) + continue; + + // Preserve unknown fields at type level + freshType.ExtensionData = existingType.ExtensionData; + + // Merge steps - match by message + entity + stage (functional identity) + foreach (var freshStep in freshType.Steps) + { + var existingStep = existingType.Steps.FirstOrDefault(s => + s.Message.Equals(freshStep.Message, StringComparison.OrdinalIgnoreCase) && + s.Entity.Equals(freshStep.Entity, StringComparison.OrdinalIgnoreCase) && + s.Stage.Equals(freshStep.Stage, StringComparison.OrdinalIgnoreCase)); + + if (existingStep == null) + continue; + + // Preserve deployment settings from existing step + freshStep.Deployment ??= existingStep.Deployment; + freshStep.RunAsUser ??= existingStep.RunAsUser; + freshStep.Description ??= existingStep.Description; + freshStep.AsyncAutoDelete ??= existingStep.AsyncAutoDelete; + + // Preserve unknown fields at step level + freshStep.ExtensionData = existingStep.ExtensionData; + + // Merge images - match by name + foreach (var freshImage in freshStep.Images) + { + var existingImage = existingStep.Images.FirstOrDefault(i => + i.Name.Equals(freshImage.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingImage == null) + continue; + + // Preserve unknown fields at image level + freshImage.ExtensionData = existingImage.ExtensionData; + } + } + } + } +} diff --git a/src/PPDS.Cli/Commands/Plugins/ListCommand.cs b/src/PPDS.Cli/Commands/Plugins/ListCommand.cs new file mode 100644 index 000000000..c5617ca9b --- /dev/null +++ b/src/PPDS.Cli/Commands/Plugins/ListCommand.cs @@ -0,0 +1,442 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Plugins.Registration; +using PPDS.Dataverse.Pooling; + +namespace PPDS.Cli.Commands.Plugins; + +/// +/// List registered plugins in a Dataverse environment. +/// +public static class ListCommand +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static Command Create() + { + var assemblyOption = new Option("--assembly", "-a") + { + Description = "Filter by assembly name (classic DLL plugins)" + }; + + var packageOption = new Option("--package", "-pkg") + { + Description = "Filter by package name or unique name (NuGet plugin packages)" + }; + + var command = new Command("list", "List registered plugins in the environment") + { + PluginsCommandGroup.ProfileOption, + PluginsCommandGroup.EnvironmentOption, + assemblyOption, + packageOption, + PluginsCommandGroup.JsonOption + }; + + command.SetAction(async (parseResult, cancellationToken) => + { + var profile = parseResult.GetValue(PluginsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(PluginsCommandGroup.EnvironmentOption); + var assembly = parseResult.GetValue(assemblyOption); + var package = parseResult.GetValue(packageOption); + var json = parseResult.GetValue(PluginsCommandGroup.JsonOption); + + return await ExecuteAsync(profile, environment, assembly, package, json, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string? profile, + string? environment, + string? assemblyFilter, + string? packageFilter, + bool json, + CancellationToken cancellationToken) + { + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + verbose: false, + debug: false, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var pool = serviceProvider.GetRequiredService(); + await using var client = await pool.GetClientAsync(cancellationToken: cancellationToken); + var registrationService = new PluginRegistrationService(client); + + if (!json) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.WriteLine(); + } + + var output = new ListOutput(); + + // Get assemblies (unless package filter is specified, which means we only want packages) + if (string.IsNullOrEmpty(packageFilter)) + { + var assemblies = await registrationService.ListAssembliesAsync(assemblyFilter); + + foreach (var assembly in assemblies) + { + var assemblyOutput = new AssemblyOutput + { + Name = assembly.Name, + Version = assembly.Version, + PublicKeyToken = assembly.PublicKeyToken, + Types = [] + }; + + var types = await registrationService.ListTypesForAssemblyAsync(assembly.Id); + await PopulateTypesAsync(registrationService, types, assemblyOutput.Types); + + output.Assemblies.Add(assemblyOutput); + } + } + + // Get packages (unless assembly filter is specified, which means we only want assemblies) + if (string.IsNullOrEmpty(assemblyFilter)) + { + var packages = await registrationService.ListPackagesAsync(packageFilter); + + foreach (var package in packages) + { + var packageOutput = new PackageOutput + { + Name = package.Name, + UniqueName = package.UniqueName, + Version = package.Version, + Assemblies = [] + }; + + var assemblies = await registrationService.ListAssembliesForPackageAsync(package.Id); + foreach (var assembly in assemblies) + { + var assemblyOutput = new AssemblyOutput + { + Name = assembly.Name, + Version = assembly.Version, + PublicKeyToken = assembly.PublicKeyToken, + Types = [] + }; + + var types = await registrationService.ListTypesForAssemblyAsync(assembly.Id); + await PopulateTypesAsync(registrationService, types, assemblyOutput.Types); + + packageOutput.Assemblies.Add(assemblyOutput); + } + + output.Packages.Add(packageOutput); + } + } + + var totalAssemblies = output.Assemblies.Count; + var totalPackages = output.Packages.Count; + + if (totalAssemblies == 0 && totalPackages == 0) + { + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions)); + } + else + { + Console.WriteLine("No plugin assemblies or packages found."); + } + return ExitCodes.Success; + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions)); + } + else + { + // Print assemblies + foreach (var assembly in output.Assemblies) + { + Console.WriteLine($"Assembly: {assembly.Name} (v{assembly.Version})"); + PrintTypes(assembly.Types); + Console.WriteLine(); + } + + // Print packages + foreach (var package in output.Packages) + { + var uniqueName = package.UniqueName != package.Name ? $" [{package.UniqueName}]" : ""; + Console.WriteLine($"Package: {package.Name}{uniqueName} (v{package.Version})"); + PrintPackageAssemblies(package.Assemblies); + Console.WriteLine(); + } + + var totalPackageAssemblies = output.Packages.Sum(p => p.Assemblies.Count); + var totalTypes = output.Assemblies.Sum(a => a.Types.Count) + + output.Packages.Sum(p => p.Assemblies.Sum(a => a.Types.Count)); + var totalSteps = output.Assemblies.Sum(a => a.Types.Sum(t => t.Steps.Count)) + + output.Packages.Sum(p => p.Assemblies.Sum(a => a.Types.Sum(t => t.Steps.Count))); + var totalImages = output.Assemblies.Sum(a => a.Types.Sum(t => t.Steps.Sum(s => s.Images.Count))) + + output.Packages.Sum(p => p.Assemblies.Sum(a => a.Types.Sum(t => t.Steps.Sum(s => s.Images.Count)))); + + // Build summary parts based on what's present + var summaryParts = new List(); + if (totalAssemblies > 0) + { + summaryParts.Add(Pluralize(totalAssemblies, "assembly", "assemblies")); + } + if (totalPackages > 0) + { + summaryParts.Add($"{Pluralize(totalPackages, "package", "packages")} ({Pluralize(totalPackageAssemblies, "assembly", "assemblies")})"); + } + summaryParts.Add(Pluralize(totalTypes, "type", "types")); + summaryParts.Add(Pluralize(totalSteps, "step", "steps")); + if (totalImages > 0) + { + summaryParts.Add(Pluralize(totalImages, "image", "images")); + } + + Console.WriteLine($"Total: {string.Join(", ", summaryParts)}"); + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error listing plugins: {ex.Message}"); + return ExitCodes.Failure; + } + } + + private static async Task PopulateTypesAsync( + PluginRegistrationService registrationService, + List types, + List typeOutputs) + { + foreach (var type in types) + { + var typeOutput = new TypeOutput + { + TypeName = type.TypeName, + Steps = [] + }; + + var steps = await registrationService.ListStepsForTypeAsync(type.Id); + + foreach (var step in steps) + { + var stepOutput = new StepOutput + { + Name = step.Name, + Message = step.Message, + Entity = step.PrimaryEntity, + Stage = step.Stage, + Mode = step.Mode, + ExecutionOrder = step.ExecutionOrder, + FilteringAttributes = step.FilteringAttributes, + IsEnabled = step.IsEnabled, + Description = step.Description, + Deployment = step.Deployment, + RunAsUser = step.ImpersonatingUserName, + AsyncAutoDelete = step.AsyncAutoDelete, + Images = [] + }; + + var images = await registrationService.ListImagesForStepAsync(step.Id); + + foreach (var image in images) + { + stepOutput.Images.Add(new ImageOutput + { + Name = image.Name, + ImageType = image.ImageType, + Attributes = image.Attributes + }); + } + + typeOutput.Steps.Add(stepOutput); + } + + typeOutputs.Add(typeOutput); + } + } + + private static void PrintTypes(List types, string indent = " ") + { + foreach (var type in types) + { + Console.WriteLine($"{indent}Type: {type.TypeName}"); + + foreach (var step in type.Steps) + { + var status = step.IsEnabled ? "" : " [DISABLED]"; + Console.WriteLine($"{indent} Step: {step.Name}{status}"); + Console.WriteLine($"{indent} {step.Message} on {step.Entity} ({step.Stage}, {step.Mode})"); + + // Show non-default deployment/user/async settings on one line if any are set + var stepOptions = new List(); + if (step.Deployment != "ServerOnly") + { + stepOptions.Add($"Deployment: {step.Deployment}"); + } + if (!string.IsNullOrEmpty(step.RunAsUser)) + { + stepOptions.Add($"Run as: {step.RunAsUser}"); + } + if (step.AsyncAutoDelete && step.Mode == "Asynchronous") + { + stepOptions.Add("Auto-delete: Yes"); + } + if (stepOptions.Count > 0) + { + Console.WriteLine($"{indent} {string.Join(" | ", stepOptions)}"); + } + + if (!string.IsNullOrEmpty(step.FilteringAttributes)) + { + Console.WriteLine($"{indent} Filtering: {step.FilteringAttributes}"); + } + + if (!string.IsNullOrEmpty(step.Description)) + { + Console.WriteLine($"{indent} Description: {step.Description}"); + } + + foreach (var image in step.Images) + { + var attrs = string.IsNullOrEmpty(image.Attributes) ? "all" : image.Attributes; + Console.WriteLine($"{indent} Image: {image.Name} ({image.ImageType}) - {attrs}"); + } + } + } + } + + private static void PrintPackageAssemblies(List assemblies) + { + foreach (var assembly in assemblies) + { + Console.WriteLine($" Assembly: {assembly.Name} (v{assembly.Version})"); + PrintTypes(assembly.Types, " "); + } + } + + private static string Pluralize(int count, string singular, string plural) => + count == 1 ? $"{count} {singular}" : $"{count} {plural}"; + + #region Output Models + + private sealed class ListOutput + { + [JsonPropertyName("assemblies")] + public List Assemblies { get; set; } = []; + + [JsonPropertyName("packages")] + public List Packages { get; set; } = []; + } + + private sealed class PackageOutput + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("uniqueName")] + public string? UniqueName { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("assemblies")] + public List Assemblies { get; set; } = []; + } + + private sealed class AssemblyOutput + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("publicKeyToken")] + public string? PublicKeyToken { get; set; } + + [JsonPropertyName("types")] + public List Types { get; set; } = []; + } + + private sealed class TypeOutput + { + [JsonPropertyName("typeName")] + public string TypeName { get; set; } = string.Empty; + + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; + } + + private sealed class StepOutput + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("entity")] + public string Entity { get; set; } = string.Empty; + + [JsonPropertyName("stage")] + public string Stage { get; set; } = string.Empty; + + [JsonPropertyName("mode")] + public string Mode { get; set; } = string.Empty; + + [JsonPropertyName("executionOrder")] + public int ExecutionOrder { get; set; } + + [JsonPropertyName("filteringAttributes")] + public string? FilteringAttributes { get; set; } + + [JsonPropertyName("isEnabled")] + public bool IsEnabled { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("deployment")] + public string Deployment { get; set; } = "ServerOnly"; + + [JsonPropertyName("runAsUser")] + public string? RunAsUser { get; set; } + + [JsonPropertyName("asyncAutoDelete")] + public bool AsyncAutoDelete { get; set; } + + [JsonPropertyName("images")] + public List Images { get; set; } = []; + } + + private sealed class ImageOutput + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("imageType")] + public string ImageType { get; set; } = string.Empty; + + [JsonPropertyName("attributes")] + public string? Attributes { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Plugins/PluginsCommandGroup.cs b/src/PPDS.Cli/Commands/Plugins/PluginsCommandGroup.cs new file mode 100644 index 000000000..02af4e9af --- /dev/null +++ b/src/PPDS.Cli/Commands/Plugins/PluginsCommandGroup.cs @@ -0,0 +1,58 @@ +using System.CommandLine; + +namespace PPDS.Cli.Commands.Plugins; + +/// +/// Plugin command group for managing plugin registrations. +/// +public static class PluginsCommandGroup +{ + /// + /// Profile option for specifying which authentication profile to use. + /// + public static readonly Option ProfileOption = new("--profile", "-p") + { + Description = "Authentication profile name" + }; + + /// + /// Environment option for overriding the profile's bound environment. + /// + public static readonly Option EnvironmentOption = new("--environment", "-env") + { + Description = "Override the environment URL. Takes precedence over profile's bound environment." + }; + + /// + /// Solution option for specifying which solution to add components to. + /// + public static readonly Option SolutionOption = new("--solution", "-s") + { + Description = "Solution unique name. Overrides value in configuration file." + }; + + /// + /// JSON output option for tool integration. + /// + public static readonly Option JsonOption = new("--json", "-j") + { + Description = "Output as JSON (for tool integration)", + DefaultValueFactory = _ => false + }; + + /// + /// Creates the 'plugins' command group with all subcommands. + /// + public static Command Create() + { + var command = new Command("plugins", "Plugin registration management: extract, deploy, diff, list, clean"); + + command.Subcommands.Add(ExtractCommand.Create()); + command.Subcommands.Add(DeployCommand.Create()); + command.Subcommands.Add(DiffCommand.Create()); + command.Subcommands.Add(ListCommand.Create()); + command.Subcommands.Add(CleanCommand.Create()); + + return command; + } +} diff --git a/src/PPDS.Cli/PPDS.Cli.csproj b/src/PPDS.Cli/PPDS.Cli.csproj index 4661bfcc2..f49f2ea84 100644 --- a/src/PPDS.Cli/PPDS.Cli.csproj +++ b/src/PPDS.Cli/PPDS.Cli.csproj @@ -39,9 +39,11 @@ + + diff --git a/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs b/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs new file mode 100644 index 000000000..d6828943c --- /dev/null +++ b/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs @@ -0,0 +1,330 @@ +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.InteropServices; +using PPDS.Cli.Plugins.Models; +using PPDS.Plugins; + +namespace PPDS.Cli.Plugins.Extraction; + +/// +/// Extracts plugin registration information from assemblies using MetadataLoadContext. +/// +public sealed class AssemblyExtractor : IDisposable +{ + private readonly MetadataLoadContext _metadataLoadContext; + private readonly string _assemblyPath; + private bool _disposed; + + private AssemblyExtractor(MetadataLoadContext metadataLoadContext, string assemblyPath) + { + _metadataLoadContext = metadataLoadContext; + _assemblyPath = assemblyPath; + } + + /// + /// Creates an extractor for the specified assembly. + /// + /// Path to the assembly DLL. + /// An extractor instance that must be disposed. + public static AssemblyExtractor Create(string assemblyPath) + { + var directory = Path.GetDirectoryName(assemblyPath) ?? "."; + + // Collect assemblies for the resolver: + // 1. All DLLs in the same directory as the target assembly + // 2. .NET runtime assemblies for core types + var assemblyPaths = new List(); + + // Add assemblies from target directory + assemblyPaths.AddRange(Directory.GetFiles(directory, "*.dll")); + + // Add .NET runtime assemblies + var runtimeDir = RuntimeEnvironment.GetRuntimeDirectory(); + assemblyPaths.AddRange(Directory.GetFiles(runtimeDir, "*.dll")); + + var resolver = new PathAssemblyResolver(assemblyPaths); + var mlc = new MetadataLoadContext(resolver); + + return new AssemblyExtractor(mlc, assemblyPath); + } + + /// + /// Extracts plugin registration configuration from the assembly. + /// + /// Assembly configuration with all plugin types and steps. + public PluginAssemblyConfig Extract() + { + var assembly = _metadataLoadContext.LoadFromAssemblyPath(_assemblyPath); + var assemblyName = assembly.GetName(); + + var config = new PluginAssemblyConfig + { + Name = assemblyName.Name ?? Path.GetFileNameWithoutExtension(_assemblyPath), + Type = "Assembly", + Path = Path.GetFileName(_assemblyPath), + AllTypeNames = [], + Types = [] + }; + + // Get all exported types (public, non-abstract, non-interface) + foreach (var type in assembly.GetExportedTypes()) + { + if (type.IsAbstract || type.IsInterface) + continue; + + // Check if type implements IPlugin or has plugin attributes + var stepAttributes = GetPluginStepAttributes(type); + if (stepAttributes.Count == 0) + continue; + + // Track all plugin type names for orphan detection + config.AllTypeNames.Add(type.FullName ?? type.Name); + + var pluginType = new PluginTypeConfig + { + TypeName = type.FullName ?? type.Name, + Steps = [] + }; + + var imageAttributes = GetPluginImageAttributes(type); + + foreach (var stepAttr in stepAttributes) + { + var step = MapStepAttribute(stepAttr, type); + + // Find images for this step + var stepImages = imageAttributes + .Where(img => MatchesStep(img, stepAttr)) + .Select(MapImageAttribute) + .ToList(); + + step.Images = stepImages; + pluginType.Steps.Add(step); + } + + config.Types.Add(pluginType); + } + + return config; + } + + private List GetPluginStepAttributes(Type type) + { + return type.CustomAttributes + .Where(a => a.AttributeType.FullName == typeof(PluginStepAttribute).FullName) + .ToList(); + } + + private List GetPluginImageAttributes(Type type) + { + return type.CustomAttributes + .Where(a => a.AttributeType.FullName == typeof(PluginImageAttribute).FullName) + .ToList(); + } + + private static PluginStepConfig MapStepAttribute(CustomAttributeData attr, Type pluginType) + { + var step = new PluginStepConfig(); + + // Handle constructor arguments + var ctorParams = attr.Constructor.GetParameters(); + for (var i = 0; i < attr.ConstructorArguments.Count; i++) + { + var paramName = ctorParams[i].Name; + var value = attr.ConstructorArguments[i].Value; + + switch (paramName) + { + case "message": + step.Message = value?.ToString() ?? string.Empty; + break; + case "entityLogicalName": + step.Entity = value?.ToString() ?? string.Empty; + break; + case "stage": + step.Stage = MapStageValue(value); + break; + } + } + + // Handle named arguments + foreach (var namedArg in attr.NamedArguments) + { + var value = namedArg.TypedValue.Value; + switch (namedArg.MemberName) + { + case "Message": + step.Message = value?.ToString() ?? string.Empty; + break; + case "EntityLogicalName": + step.Entity = value?.ToString() ?? string.Empty; + break; + case "SecondaryEntityLogicalName": + step.SecondaryEntity = value?.ToString(); + break; + case "Stage": + step.Stage = MapStageValue(value); + break; + case "Mode": + step.Mode = MapModeValue(value); + break; + case "FilteringAttributes": + step.FilteringAttributes = value?.ToString(); + break; + case "ExecutionOrder": + step.ExecutionOrder = value is int order ? order : 1; + break; + case "Name": + step.Name = value?.ToString(); + break; + case "UnsecureConfiguration": + step.Configuration = value?.ToString(); + break; + case "Description": + step.Description = value?.ToString(); + break; + case "AsyncAutoDelete": + step.AsyncAutoDelete = value is true; + break; + case "StepId": + step.StepId = value?.ToString(); + break; + } + } + + // Auto-generate name if not specified + if (string.IsNullOrEmpty(step.Name)) + { + var typeName = pluginType.Name; + step.Name = $"{typeName}: {step.Message} of {step.Entity}"; + } + + return step; + } + + private static PluginImageConfig MapImageAttribute(CustomAttributeData attr) + { + var image = new PluginImageConfig(); + + // Handle constructor arguments + var ctorParams = attr.Constructor.GetParameters(); + for (var i = 0; i < attr.ConstructorArguments.Count; i++) + { + var paramName = ctorParams[i].Name; + var value = attr.ConstructorArguments[i].Value; + + switch (paramName) + { + case "imageType": + image.ImageType = MapImageTypeValue(value); + break; + case "name": + image.Name = value?.ToString() ?? string.Empty; + break; + case "attributes": + image.Attributes = value?.ToString(); + break; + } + } + + // Handle named arguments + foreach (var namedArg in attr.NamedArguments) + { + var value = namedArg.TypedValue.Value; + switch (namedArg.MemberName) + { + case "ImageType": + image.ImageType = MapImageTypeValue(value); + break; + case "Name": + image.Name = value?.ToString() ?? string.Empty; + break; + case "Attributes": + image.Attributes = value?.ToString(); + break; + case "EntityAlias": + image.EntityAlias = value?.ToString(); + break; + } + } + + return image; + } + + private static bool MatchesStep(CustomAttributeData imageAttr, CustomAttributeData stepAttr) + { + // Get StepId from both attributes using LINQ for clearer intent + var imageStepId = imageAttr.NamedArguments + .FirstOrDefault(na => na.MemberName == "StepId") + .TypedValue.Value?.ToString(); + + var stepStepId = stepAttr.NamedArguments + .FirstOrDefault(na => na.MemberName == "StepId") + .TypedValue.Value?.ToString(); + + // If image has no StepId, it applies to all steps (or the only step) + if (string.IsNullOrEmpty(imageStepId)) + return true; + + // If image has StepId, it must match the step's StepId + return string.Equals(imageStepId, stepStepId, StringComparison.Ordinal); + } + + private static string MapStageValue(object? value) + { + // Handle enum by underlying value + if (value is int intValue) + { + return intValue switch + { + 10 => "PreValidation", + 20 => "PreOperation", + 40 => "PostOperation", + _ => intValue.ToString() + }; + } + + return value?.ToString() ?? "PostOperation"; + } + + private static string MapModeValue(object? value) + { + if (value is int intValue) + { + return intValue switch + { + 0 => "Synchronous", + 1 => "Asynchronous", + _ => intValue.ToString() + }; + } + + return value?.ToString() ?? "Synchronous"; + } + + private static string MapImageTypeValue(object? value) + { + if (value is int intValue) + { + return intValue switch + { + 0 => "PreImage", + 1 => "PostImage", + 2 => "Both", + _ => intValue.ToString() + }; + } + + return value?.ToString() ?? "PreImage"; + } + + public void Dispose() + { + if (_disposed) + return; + + _metadataLoadContext.Dispose(); + _disposed = true; + } +} diff --git a/src/PPDS.Cli/Plugins/Extraction/NupkgExtractor.cs b/src/PPDS.Cli/Plugins/Extraction/NupkgExtractor.cs new file mode 100644 index 000000000..4c9e8b4d3 --- /dev/null +++ b/src/PPDS.Cli/Plugins/Extraction/NupkgExtractor.cs @@ -0,0 +1,114 @@ +using System.IO.Compression; +using PPDS.Cli.Plugins.Models; + +namespace PPDS.Cli.Plugins.Extraction; + +/// +/// Extracts plugin registration information from NuGet packages. +/// +public static class NupkgExtractor +{ + /// + /// Extracts plugin configuration from a NuGet package. + /// + /// Path to the .nupkg file. + /// Assembly configuration from the package. + public static PluginAssemblyConfig Extract(string nupkgPath) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"ppds-extract-{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(tempDir); + + // Extract the nupkg (it's a zip file) + ZipFile.ExtractToDirectory(nupkgPath, tempDir); + + // Find plugin DLLs in the lib folder + // Plugin packages target net462 typically + var libDir = Path.Combine(tempDir, "lib"); + if (!Directory.Exists(libDir)) + { + throw new InvalidOperationException( + $"NuGet package does not contain a 'lib' folder: {nupkgPath}"); + } + + // Look for the most specific framework folder + var frameworkDirs = Directory.GetDirectories(libDir) + .OrderByDescending(d => Path.GetFileName(d)) // Prefer higher versions + .ToList(); + + if (frameworkDirs.Count == 0) + { + throw new InvalidOperationException( + $"NuGet package 'lib' folder is empty: {nupkgPath}"); + } + + // Prefer net462 for plugins (Dataverse requirement), fallback to first available + var targetDir = frameworkDirs.FirstOrDefault(d => + Path.GetFileName(d).Equals("net462", StringComparison.OrdinalIgnoreCase)) + ?? frameworkDirs[0]; + + // Get all DLLs in the target framework folder + var dlls = Directory.GetFiles(targetDir, "*.dll"); + if (dlls.Length == 0) + { + throw new InvalidOperationException( + $"No DLL files found in package framework folder: {targetDir}"); + } + + // Scan all DLLs to find those with plugin registrations + var allTypes = new List(); + var allTypeNames = new List(); + string? primaryAssemblyName = null; + + foreach (var dllPath in dlls) + { + try + { + using var extractor = AssemblyExtractor.Create(dllPath); + var assemblyConfig = extractor.Extract(); + + if (assemblyConfig.Types.Count > 0) + { + // Found plugins in this assembly + primaryAssemblyName ??= assemblyConfig.Name; + allTypes.AddRange(assemblyConfig.Types); + allTypeNames.AddRange(assemblyConfig.AllTypeNames); + } + } + catch + { + // Skip DLLs that can't be loaded (dependencies, native, etc.) + } + } + + // Build the combined config + var config = new PluginAssemblyConfig + { + Name = primaryAssemblyName ?? Path.GetFileNameWithoutExtension(nupkgPath), + Type = "Nuget", + PackagePath = Path.GetFileName(nupkgPath), + AllTypeNames = allTypeNames, + Types = allTypes + }; + + return config; + } + finally + { + // Clean up temp directory + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + } + } +} diff --git a/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs b/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs new file mode 100644 index 000000000..9ac542ee0 --- /dev/null +++ b/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs @@ -0,0 +1,261 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PPDS.Cli.Plugins.Models; + +/// +/// Root configuration for plugin registrations. +/// Serialized to/from registrations.json. +/// +public sealed class PluginRegistrationConfig +{ + /// + /// JSON schema reference for validation. + /// + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + /// + /// Schema version for forward compatibility. + /// + [JsonPropertyName("version")] + public string Version { get; set; } = "1.0"; + + /// + /// Timestamp when the configuration was generated. + /// + [JsonPropertyName("generatedAt")] + public DateTimeOffset? GeneratedAt { get; set; } + + /// + /// List of plugin assemblies in this configuration. + /// + [JsonPropertyName("assemblies")] + public List Assemblies { get; set; } = []; + + /// + /// Preserves unknown JSON properties during round-trip serialization. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +/// +/// Configuration for a single plugin assembly. +/// +public sealed class PluginAssemblyConfig +{ + /// + /// Assembly name (without extension). + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Assembly type: "Assembly" for classic DLL, "Nuget" for NuGet package. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "Assembly"; + + /// + /// Relative path to the assembly DLL (from the config file location). + /// + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// + /// Relative path to the NuGet package (for Nuget type only). + /// + [JsonPropertyName("packagePath")] + public string? PackagePath { get; set; } + + /// + /// Solution unique name to add components to. + /// Required for Nuget type, optional for Assembly type. + /// + [JsonPropertyName("solution")] + public string? Solution { get; set; } + + /// + /// All plugin type names in this assembly (for orphan detection). + /// + [JsonPropertyName("allTypeNames")] + public List AllTypeNames { get; set; } = []; + + /// + /// Plugin types with their step registrations. + /// + [JsonPropertyName("types")] + public List Types { get; set; } = []; + + /// + /// Preserves unknown JSON properties during round-trip serialization. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +/// +/// Configuration for a single plugin type (class). +/// +public sealed class PluginTypeConfig +{ + /// + /// Fully qualified type name (namespace.classname). + /// + [JsonPropertyName("typeName")] + public string TypeName { get; set; } = string.Empty; + + /// + /// Step registrations for this plugin type. + /// + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; + + /// + /// Preserves unknown JSON properties during round-trip serialization. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +/// +/// Configuration for a single plugin step registration. +/// +public sealed class PluginStepConfig +{ + /// + /// Display name for the step. + /// Auto-generated if not specified: "{TypeName}: {Message} of {Entity}". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// SDK message name (Create, Update, Delete, Retrieve, etc.). + /// + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// + /// Primary entity logical name. Use "none" for global messages. + /// + [JsonPropertyName("entity")] + public string Entity { get; set; } = string.Empty; + + /// + /// Secondary entity logical name for relationship messages (Associate, etc.). + /// + [JsonPropertyName("secondaryEntity")] + public string? SecondaryEntity { get; set; } + + /// + /// Pipeline stage: PreValidation, PreOperation, or PostOperation. + /// + [JsonPropertyName("stage")] + public string Stage { get; set; } = string.Empty; + + /// + /// Execution mode: Synchronous or Asynchronous. + /// + [JsonPropertyName("mode")] + public string Mode { get; set; } = "Synchronous"; + + /// + /// Execution order when multiple plugins handle the same event. + /// + [JsonPropertyName("executionOrder")] + public int ExecutionOrder { get; set; } = 1; + + /// + /// Comma-separated list of attributes that trigger this step (Update message only). + /// + [JsonPropertyName("filteringAttributes")] + public string? FilteringAttributes { get; set; } + + /// + /// Unsecure configuration string passed to plugin constructor. + /// + [JsonPropertyName("configuration")] + public string? Configuration { get; set; } + + /// + /// Deployment target: ServerOnly (default), Offline, or Both. + /// + [JsonPropertyName("deployment")] + public string? Deployment { get; set; } + + /// + /// User context to run the plugin as. + /// Use "CallingUser" (default), "System", or a systemuser GUID. + /// + [JsonPropertyName("runAsUser")] + public string? RunAsUser { get; set; } + + /// + /// Description of what this step does. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// For async steps, whether to delete the async job on successful completion. + /// Default is false (keep async job records). + /// + [JsonPropertyName("asyncAutoDelete")] + public bool? AsyncAutoDelete { get; set; } + + /// + /// Step identifier for associating images with specific steps on multi-step plugins. + /// + [JsonPropertyName("stepId")] + public string? StepId { get; set; } + + /// + /// Entity images registered for this step. + /// + [JsonPropertyName("images")] + public List Images { get; set; } = []; + + /// + /// Preserves unknown JSON properties during round-trip serialization. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +/// +/// Configuration for a plugin step image (pre-image or post-image). +/// +public sealed class PluginImageConfig +{ + /// + /// Name used to access the image in plugin context. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Image type: PreImage, PostImage, or Both. + /// + [JsonPropertyName("imageType")] + public string ImageType { get; set; } = string.Empty; + + /// + /// Comma-separated list of attributes to include. Null means all attributes. + /// + [JsonPropertyName("attributes")] + public string? Attributes { get; set; } + + /// + /// Entity alias for the image. Defaults to Name if not specified. + /// + [JsonPropertyName("entityAlias")] + public string? EntityAlias { get; set; } + + /// + /// Preserves unknown JSON properties during round-trip serialization. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} diff --git a/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs b/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs new file mode 100644 index 000000000..3d7b709c7 --- /dev/null +++ b/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs @@ -0,0 +1,802 @@ +using Microsoft.Crm.Sdk.Messages; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Cli.Plugins.Models; + +namespace PPDS.Cli.Plugins.Registration; + +/// +/// Service for managing plugin registrations in Dataverse. +/// +public sealed class PluginRegistrationService +{ + private readonly IOrganizationService _service; + private readonly IOrganizationServiceAsync2? _asyncService; + + public PluginRegistrationService(IOrganizationService service) + { + _service = service; + // Use native async when available (ServiceClient implements IOrganizationServiceAsync2) + _asyncService = service as IOrganizationServiceAsync2; + } + + #region Query Operations + + /// + /// Lists all plugin assemblies in the environment. + /// + /// Optional filter by assembly name. + public async Task> ListAssembliesAsync(string? assemblyNameFilter = null) + { + var query = new QueryExpression("pluginassembly") + { + ColumnSet = new ColumnSet("name", "version", "publickeytoken", "culture", "isolationmode", "sourcetype"), + Criteria = new FilterExpression + { + Conditions = + { + // Exclude system assemblies + new ConditionExpression("ishidden", ConditionOperator.Equal, false) + } + }, + Orders = { new OrderExpression("name", OrderType.Ascending) } + }; + + if (!string.IsNullOrEmpty(assemblyNameFilter)) + { + query.Criteria.AddCondition("name", ConditionOperator.Equal, assemblyNameFilter); + } + + var results = await RetrieveMultipleAsync(query); + + return results.Entities.Select(e => new PluginAssemblyInfo + { + Id = e.Id, + Name = e.GetAttributeValue("name") ?? string.Empty, + Version = e.GetAttributeValue("version"), + PublicKeyToken = e.GetAttributeValue("publickeytoken"), + IsolationMode = e.GetAttributeValue("isolationmode")?.Value ?? 2 + }).ToList(); + } + + /// + /// Lists all plugin packages in the environment. + /// + /// Optional filter by package name or unique name. + public async Task> ListPackagesAsync(string? packageNameFilter = null) + { + var query = new QueryExpression("pluginpackage") + { + ColumnSet = new ColumnSet("name", "uniquename", "version"), + Orders = { new OrderExpression("name", OrderType.Ascending) } + }; + + if (!string.IsNullOrEmpty(packageNameFilter)) + { + // Filter by name or uniquename + query.Criteria = new FilterExpression(LogicalOperator.Or) + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.Equal, packageNameFilter), + new ConditionExpression("uniquename", ConditionOperator.Equal, packageNameFilter) + } + }; + } + + var results = await RetrieveMultipleAsync(query); + + return results.Entities.Select(e => new PluginPackageInfo + { + Id = e.Id, + Name = e.GetAttributeValue("name") ?? string.Empty, + UniqueName = e.GetAttributeValue("uniquename"), + Version = e.GetAttributeValue("version") + }).ToList(); + } + + /// + /// Lists all assemblies contained in a plugin package. + /// + public async Task> ListAssembliesForPackageAsync(Guid packageId) + { + var query = new QueryExpression("pluginassembly") + { + ColumnSet = new ColumnSet("name", "version", "publickeytoken", "culture", "isolationmode", "sourcetype"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("packageid", ConditionOperator.Equal, packageId) + } + }, + Orders = { new OrderExpression("name", OrderType.Ascending) } + }; + + var results = await RetrieveMultipleAsync(query); + + return results.Entities.Select(e => new PluginAssemblyInfo + { + Id = e.Id, + Name = e.GetAttributeValue("name") ?? string.Empty, + Version = e.GetAttributeValue("version"), + PublicKeyToken = e.GetAttributeValue("publickeytoken"), + IsolationMode = e.GetAttributeValue("isolationmode")?.Value ?? 2 + }).ToList(); + } + + /// + /// Lists all plugin types for a package by querying through the package's assemblies. + /// + public async Task> ListTypesForPackageAsync(Guid packageId) + { + var assemblies = await ListAssembliesForPackageAsync(packageId); + + if (assemblies.Count == 0) + return []; + + var allTypes = new List(); + foreach (var assembly in assemblies) + { + var types = await ListTypesForAssemblyAsync(assembly.Id); + allTypes.AddRange(types); + } + + return allTypes; + } + + /// + /// Lists all plugin types for an assembly. + /// + public async Task> ListTypesForAssemblyAsync(Guid assemblyId) + { + var query = new QueryExpression("plugintype") + { + ColumnSet = new ColumnSet("typename", "friendlyname", "name"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("pluginassemblyid", ConditionOperator.Equal, assemblyId) + } + }, + Orders = { new OrderExpression("typename", OrderType.Ascending) } + }; + + var results = await RetrieveMultipleAsync(query); + + return results.Entities.Select(e => new PluginTypeInfo + { + Id = e.Id, + TypeName = e.GetAttributeValue("typename") ?? string.Empty, + FriendlyName = e.GetAttributeValue("friendlyname") + }).ToList(); + } + + /// + /// Lists all processing steps for a plugin type. + /// + public async Task> ListStepsForTypeAsync(Guid pluginTypeId) + { + var query = new QueryExpression("sdkmessageprocessingstep") + { + ColumnSet = new ColumnSet( + "name", "stage", "mode", "rank", "filteringattributes", + "configuration", "statecode", "description", "supporteddeployment", + "impersonatinguserid", "asyncautodelete"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("plugintypeid", ConditionOperator.Equal, pluginTypeId) + } + }, + LinkEntities = + { + new LinkEntity("sdkmessageprocessingstep", "sdkmessage", "sdkmessageid", "sdkmessageid", JoinOperator.Inner) + { + Columns = new ColumnSet("name"), + EntityAlias = "message" + }, + new LinkEntity("sdkmessageprocessingstep", "sdkmessagefilter", "sdkmessagefilterid", "sdkmessagefilterid", JoinOperator.LeftOuter) + { + Columns = new ColumnSet("primaryobjecttypecode", "secondaryobjecttypecode"), + EntityAlias = "filter" + }, + new LinkEntity("sdkmessageprocessingstep", "systemuser", "impersonatinguserid", "systemuserid", JoinOperator.LeftOuter) + { + Columns = new ColumnSet("fullname", "domainname"), + EntityAlias = "impersonatinguser" + } + }, + Orders = { new OrderExpression("name", OrderType.Ascending) } + }; + + var results = await RetrieveMultipleAsync(query); + + return results.Entities.Select(e => + { + var impersonatingUserRef = e.GetAttributeValue("impersonatinguserid"); + var impersonatingUserName = e.GetAttributeValue("impersonatinguser.fullname")?.Value?.ToString() + ?? e.GetAttributeValue("impersonatinguser.domainname")?.Value?.ToString(); + + return new PluginStepInfo + { + Id = e.Id, + Name = e.GetAttributeValue("name") ?? string.Empty, + Message = e.GetAttributeValue("message.name")?.Value?.ToString() ?? string.Empty, + PrimaryEntity = e.GetAttributeValue("filter.primaryobjecttypecode")?.Value?.ToString() ?? "none", + SecondaryEntity = e.GetAttributeValue("filter.secondaryobjecttypecode")?.Value?.ToString(), + Stage = MapStageFromValue(e.GetAttributeValue("stage")?.Value ?? 40), + Mode = MapModeFromValue(e.GetAttributeValue("mode")?.Value ?? 0), + ExecutionOrder = e.GetAttributeValue("rank"), + FilteringAttributes = e.GetAttributeValue("filteringattributes"), + Configuration = e.GetAttributeValue("configuration"), + IsEnabled = e.GetAttributeValue("statecode")?.Value == 0, + Description = e.GetAttributeValue("description"), + Deployment = MapDeploymentFromValue(e.GetAttributeValue("supporteddeployment")?.Value ?? 0), + ImpersonatingUserId = impersonatingUserRef?.Id, + ImpersonatingUserName = impersonatingUserName, + AsyncAutoDelete = e.GetAttributeValue("asyncautodelete") ?? false + }; + }).ToList(); + } + + /// + /// Lists all images for a processing step. + /// + public async Task> ListImagesForStepAsync(Guid stepId) + { + var query = new QueryExpression("sdkmessageprocessingstepimage") + { + ColumnSet = new ColumnSet("name", "entityalias", "imagetype", "attributes"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("sdkmessageprocessingstepid", ConditionOperator.Equal, stepId) + } + }, + Orders = { new OrderExpression("name", OrderType.Ascending) } + }; + + var results = await RetrieveMultipleAsync(query); + + return results.Entities.Select(e => new PluginImageInfo + { + Id = e.Id, + Name = e.GetAttributeValue("name") ?? string.Empty, + EntityAlias = e.GetAttributeValue("entityalias"), + ImageType = MapImageTypeFromValue(e.GetAttributeValue("imagetype")?.Value ?? 0), + Attributes = e.GetAttributeValue("attributes") + }).ToList(); + } + + #endregion + + #region Lookup Operations + + /// + /// Gets an assembly by name. + /// + public async Task GetAssemblyByNameAsync(string name) + { + var assemblies = await ListAssembliesAsync(name); + return assemblies.FirstOrDefault(); + } + + /// + /// Gets the SDK message ID for a message name. + /// + public async Task GetSdkMessageIdAsync(string messageName) + { + var query = new QueryExpression("sdkmessage") + { + ColumnSet = new ColumnSet("sdkmessageid"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.Equal, messageName) + } + } + }; + + var results = await RetrieveMultipleAsync(query); + return results.Entities.FirstOrDefault()?.Id; + } + + /// + /// Gets the SDK message filter ID for a message and entity combination. + /// + public async Task GetSdkMessageFilterIdAsync(Guid messageId, string primaryEntity, string? secondaryEntity = null) + { + var query = new QueryExpression("sdkmessagefilter") + { + ColumnSet = new ColumnSet("sdkmessagefilterid"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("sdkmessageid", ConditionOperator.Equal, messageId), + new ConditionExpression("primaryobjecttypecode", ConditionOperator.Equal, primaryEntity) + } + } + }; + + if (!string.IsNullOrEmpty(secondaryEntity)) + { + query.Criteria.AddCondition("secondaryobjecttypecode", ConditionOperator.Equal, secondaryEntity); + } + + var results = await RetrieveMultipleAsync(query); + return results.Entities.FirstOrDefault()?.Id; + } + + #endregion + + #region Create Operations + + /// + /// Creates or updates a plugin assembly. + /// + public async Task UpsertAssemblyAsync(string name, byte[] content, string? solutionName = null) + { + var existing = await GetAssemblyByNameAsync(name); + + var entity = new Entity("pluginassembly") + { + ["name"] = name, + ["content"] = Convert.ToBase64String(content), + ["isolationmode"] = new OptionSetValue(2), // Sandbox + ["sourcetype"] = new OptionSetValue(0) // Database + }; + + if (existing != null) + { + entity.Id = existing.Id; + await UpdateAsync(entity); + return existing.Id; + } + else + { + return await CreateWithSolutionAsync(entity, solutionName); + } + } + + /// + /// Creates or updates a plugin type. + /// + public async Task UpsertPluginTypeAsync(Guid assemblyId, string typeName, string? solutionName = null) + { + // Check if type exists + var query = new QueryExpression("plugintype") + { + ColumnSet = new ColumnSet("plugintypeid"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("pluginassemblyid", ConditionOperator.Equal, assemblyId), + new ConditionExpression("typename", ConditionOperator.Equal, typeName) + } + } + }; + + var results = await RetrieveMultipleAsync(query); + var existing = results.Entities.FirstOrDefault(); + + if (existing != null) + { + return existing.Id; + } + + var entity = new Entity("plugintype") + { + ["pluginassemblyid"] = new EntityReference("pluginassembly", assemblyId), + ["typename"] = typeName, + ["friendlyname"] = typeName, + ["name"] = typeName + }; + + return await CreateWithSolutionAsync(entity, solutionName); + } + + /// + /// Creates or updates a processing step. + /// + public async Task UpsertStepAsync( + Guid pluginTypeId, + PluginStepConfig stepConfig, + Guid messageId, + Guid? filterId, + string? solutionName = null) + { + // Check if step exists by name + var query = new QueryExpression("sdkmessageprocessingstep") + { + ColumnSet = new ColumnSet("sdkmessageprocessingstepid"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("plugintypeid", ConditionOperator.Equal, pluginTypeId), + new ConditionExpression("name", ConditionOperator.Equal, stepConfig.Name) + } + } + }; + + var results = await RetrieveMultipleAsync(query); + var existing = results.Entities.FirstOrDefault(); + + var entity = new Entity("sdkmessageprocessingstep") + { + ["name"] = stepConfig.Name, + ["plugintypeid"] = new EntityReference("plugintype", pluginTypeId), + ["sdkmessageid"] = new EntityReference("sdkmessage", messageId), + ["stage"] = new OptionSetValue(MapStageToValue(stepConfig.Stage)), + ["mode"] = new OptionSetValue(MapModeToValue(stepConfig.Mode)), + ["rank"] = stepConfig.ExecutionOrder, + ["supporteddeployment"] = new OptionSetValue(MapDeploymentToValue(stepConfig.Deployment)), + ["invocationsource"] = new OptionSetValue(0) // Internal (legacy, but required) + }; + + if (filterId.HasValue) + { + entity["sdkmessagefilterid"] = new EntityReference("sdkmessagefilter", filterId.Value); + } + + if (!string.IsNullOrEmpty(stepConfig.FilteringAttributes)) + { + entity["filteringattributes"] = stepConfig.FilteringAttributes; + } + + if (!string.IsNullOrEmpty(stepConfig.Configuration)) + { + entity["configuration"] = stepConfig.Configuration; + } + + if (!string.IsNullOrEmpty(stepConfig.Description)) + { + entity["description"] = stepConfig.Description; + } + + // Handle impersonating user (Run in User's Context) + if (!string.IsNullOrEmpty(stepConfig.RunAsUser) && + !stepConfig.RunAsUser.Equals("CallingUser", StringComparison.OrdinalIgnoreCase)) + { + if (Guid.TryParse(stepConfig.RunAsUser, out var userId)) + { + entity["impersonatinguserid"] = new EntityReference("systemuser", userId); + } + } + + // Async auto-delete (only applies to async steps) + if (stepConfig.AsyncAutoDelete == true && stepConfig.Mode == "Asynchronous") + { + entity["asyncautodelete"] = true; + } + + if (existing != null) + { + entity.Id = existing.Id; + await UpdateAsync(entity); + return existing.Id; + } + else + { + return await CreateWithSolutionAsync(entity, solutionName); + } + } + + /// + /// Creates or updates a step image. + /// + public async Task UpsertImageAsync(Guid stepId, PluginImageConfig imageConfig) + { + // Check if image exists + var query = new QueryExpression("sdkmessageprocessingstepimage") + { + ColumnSet = new ColumnSet("sdkmessageprocessingstepimageid"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("sdkmessageprocessingstepid", ConditionOperator.Equal, stepId), + new ConditionExpression("name", ConditionOperator.Equal, imageConfig.Name) + } + } + }; + + var results = await RetrieveMultipleAsync(query); + var existing = results.Entities.FirstOrDefault(); + + var entity = new Entity("sdkmessageprocessingstepimage") + { + ["sdkmessageprocessingstepid"] = new EntityReference("sdkmessageprocessingstep", stepId), + ["name"] = imageConfig.Name, + ["entityalias"] = imageConfig.EntityAlias ?? imageConfig.Name, + ["imagetype"] = new OptionSetValue(MapImageTypeToValue(imageConfig.ImageType)), + ["messagepropertyname"] = "Target" + }; + + if (!string.IsNullOrEmpty(imageConfig.Attributes)) + { + entity["attributes"] = imageConfig.Attributes; + } + + if (existing != null) + { + entity.Id = existing.Id; + await UpdateAsync(entity); + return existing.Id; + } + else + { + return await CreateAsync(entity); + } + } + + #endregion + + #region Delete Operations + + /// + /// Deletes a step image. + /// + public async Task DeleteImageAsync(Guid imageId) + { + await DeleteAsync("sdkmessageprocessingstepimage", imageId); + } + + /// + /// Deletes a processing step (also deletes child images). + /// + public async Task DeleteStepAsync(Guid stepId) + { + // Delete images first + var images = await ListImagesForStepAsync(stepId); + foreach (var image in images) + { + await DeleteImageAsync(image.Id); + } + + await DeleteAsync("sdkmessageprocessingstep", stepId); + } + + /// + /// Deletes a plugin type (only if it has no steps). + /// + public async Task DeletePluginTypeAsync(Guid pluginTypeId) + { + await DeleteAsync("plugintype", pluginTypeId); + } + + #endregion + + #region Solution Operations + + /// + /// Adds a component to a solution. + /// + public async Task AddToSolutionAsync(Guid componentId, int componentType, string solutionName) + { + var request = new AddSolutionComponentRequest + { + ComponentId = componentId, + ComponentType = componentType, + SolutionUniqueName = solutionName, + AddRequiredComponents = false + }; + + try + { + await ExecuteAsync(request); + } + catch (Exception ex) when (ex.Message.Contains("already exists")) + { + // Component already in solution, ignore + } + } + + #endregion + + #region Private Helpers + + private async Task CreateWithSolutionAsync(Entity entity, string? solutionName) + { + var id = await CreateAsync(entity); + + if (!string.IsNullOrEmpty(solutionName)) + { + var componentType = entity.LogicalName switch + { + "pluginassembly" => 91, + "sdkmessageprocessingstep" => 92, + _ => 0 + }; + + if (componentType > 0) + { + await AddToSolutionAsync(id, componentType, solutionName); + } + } + + return id; + } + + private static string MapStageFromValue(int value) => value switch + { + 10 => "PreValidation", + 20 => "PreOperation", + 40 => "PostOperation", + _ => value.ToString() + }; + + private static string MapModeFromValue(int value) => value switch + { + 0 => "Synchronous", + 1 => "Asynchronous", + _ => value.ToString() + }; + + private static string MapImageTypeFromValue(int value) => value switch + { + 0 => "PreImage", + 1 => "PostImage", + 2 => "Both", + _ => value.ToString() + }; + + private static int MapStageToValue(string stage) => stage switch + { + "PreValidation" => 10, + "PreOperation" => 20, + "PostOperation" => 40, + _ => int.TryParse(stage, out var v) ? v : 40 + }; + + private static int MapModeToValue(string mode) => mode switch + { + "Synchronous" => 0, + "Asynchronous" => 1, + _ => int.TryParse(mode, out var v) ? v : 0 + }; + + private static int MapImageTypeToValue(string imageType) => imageType switch + { + "PreImage" => 0, + "PostImage" => 1, + "Both" => 2, + _ => int.TryParse(imageType, out var v) ? v : 0 + }; + + private static string MapDeploymentFromValue(int value) => value switch + { + 0 => "ServerOnly", + 1 => "Offline", + 2 => "Both", + _ => value.ToString() + }; + + private static int MapDeploymentToValue(string? deployment) => deployment switch + { + "ServerOnly" or null => 0, + "Offline" => 1, + "Both" => 2, + _ => int.TryParse(deployment, out var v) ? v : 0 + }; + + // Native async helpers - use async SDK when available, otherwise fall back to Task.Run + private async Task RetrieveMultipleAsync(QueryExpression query) + { + if (_asyncService != null) + return await _asyncService.RetrieveMultipleAsync(query); + return await Task.Run(() => _service.RetrieveMultiple(query)); + } + + private async Task CreateAsync(Entity entity) + { + if (_asyncService != null) + return await _asyncService.CreateAsync(entity); + return await Task.Run(() => _service.Create(entity)); + } + + private async Task UpdateAsync(Entity entity) + { + if (_asyncService != null) + await _asyncService.UpdateAsync(entity); + else + await Task.Run(() => _service.Update(entity)); + } + + private async Task DeleteAsync(string entityName, Guid id) + { + if (_asyncService != null) + await _asyncService.DeleteAsync(entityName, id); + else + await Task.Run(() => _service.Delete(entityName, id)); + } + + private async Task ExecuteAsync(OrganizationRequest request) + { + if (_asyncService != null) + return await _asyncService.ExecuteAsync(request); + return await Task.Run(() => _service.Execute(request)); + } + + #endregion +} + +#region Info Models + +/// +/// Information about a plugin assembly in Dataverse. +/// +public sealed class PluginAssemblyInfo +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Version { get; set; } + public string? PublicKeyToken { get; set; } + public int IsolationMode { get; set; } +} + +/// +/// Information about a plugin package (NuGet) in Dataverse. +/// +public sealed class PluginPackageInfo +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? UniqueName { get; set; } + public string? Version { get; set; } +} + +/// +/// Information about a plugin type in Dataverse. +/// +public sealed class PluginTypeInfo +{ + public Guid Id { get; set; } + public string TypeName { get; set; } = string.Empty; + public string? FriendlyName { get; set; } +} + +/// +/// Information about a processing step in Dataverse. +/// +public sealed class PluginStepInfo +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string PrimaryEntity { get; set; } = string.Empty; + public string? SecondaryEntity { get; set; } + public string Stage { get; set; } = string.Empty; + public string Mode { get; set; } = string.Empty; + public int ExecutionOrder { get; set; } + public string? FilteringAttributes { get; set; } + public string? Configuration { get; set; } + public bool IsEnabled { get; set; } + public string? Description { get; set; } + public string Deployment { get; set; } = "ServerOnly"; + public Guid? ImpersonatingUserId { get; set; } + public string? ImpersonatingUserName { get; set; } + public bool AsyncAutoDelete { get; set; } +} + +/// +/// Information about a step image in Dataverse. +/// +public sealed class PluginImageInfo +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? EntityAlias { get; set; } + public string ImageType { get; set; } = string.Empty; + public string? Attributes { get; set; } +} + +#endregion diff --git a/src/PPDS.Cli/Program.cs b/src/PPDS.Cli/Program.cs index 23a6f7a7f..8280868e7 100644 --- a/src/PPDS.Cli/Program.cs +++ b/src/PPDS.Cli/Program.cs @@ -3,6 +3,7 @@ using PPDS.Cli.Commands.Auth; using PPDS.Cli.Commands.Data; using PPDS.Cli.Commands.Env; +using PPDS.Cli.Commands.Plugins; using PPDS.Cli.Infrastructure; namespace PPDS.Cli; @@ -21,6 +22,7 @@ public static async Task Main(string[] args) rootCommand.Subcommands.Add(EnvCommandGroup.Create()); rootCommand.Subcommands.Add(EnvCommandGroup.CreateOrgAlias()); // 'org' alias for 'env' rootCommand.Subcommands.Add(DataCommandGroup.Create()); + rootCommand.Subcommands.Add(PluginsCommandGroup.Create()); rootCommand.Subcommands.Add(SchemaCommand.Create()); rootCommand.Subcommands.Add(UsersCommand.Create()); diff --git a/src/PPDS.Plugins/Attributes/PluginStepAttribute.cs b/src/PPDS.Plugins/Attributes/PluginStepAttribute.cs index 94bc78643..cf0ed2b39 100644 --- a/src/PPDS.Plugins/Attributes/PluginStepAttribute.cs +++ b/src/PPDS.Plugins/Attributes/PluginStepAttribute.cs @@ -81,11 +81,17 @@ public sealed class PluginStepAttribute : Attribute public string? UnsecureConfiguration { get; set; } /// - /// Gets or sets the secure configuration string passed to the plugin constructor. - /// This configuration is encrypted and only accessible by the plugin at runtime. - /// Use for sensitive data like API keys or connection strings. + /// Gets or sets a description of what this plugin step does. + /// This is stored as metadata in Dataverse and helps document the step's purpose. /// - public string? SecureConfiguration { get; set; } + public string? Description { get; set; } + + /// + /// Gets or sets whether to automatically delete the async job on successful completion. + /// Only applies when Mode is Asynchronous. Default is false (keep async job records). + /// Set to true to clean up async job records after successful execution. + /// + public bool AsyncAutoDelete { get; set; } /// /// Gets or sets a unique identifier for this step when a plugin has multiple steps. diff --git a/src/PPDS.Plugins/CHANGELOG.md b/src/PPDS.Plugins/CHANGELOG.md index 93302cf80..64339e798 100644 --- a/src/PPDS.Plugins/CHANGELOG.md +++ b/src/PPDS.Plugins/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] - 2025-12-31 + +### Added + +- `Description` property to `PluginStepAttribute` for documenting step purpose +- `AsyncAutoDelete` property to `PluginStepAttribute` for auto-deleting async job records on success + +### Removed + +- **BREAKING:** `SecureConfiguration` property removed from `PluginStepAttribute` + - Secure configuration contains secrets that should never be committed to source control + - Use environment variables, Azure Key Vault, or Dataverse secure configuration via Plugin Registration Tool instead + ## [1.1.1] - 2025-12-29 ### Changed @@ -46,7 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full XML documentation - Comprehensive unit test suite -[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Plugins-v1.1.1...HEAD +[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Plugins-v2.0.0...HEAD +[2.0.0]: https://github.com/joshsmithxrm/ppds-sdk/compare/Plugins-v1.1.1...Plugins-v2.0.0 [1.1.1]: https://github.com/joshsmithxrm/ppds-sdk/compare/Plugins-v1.1.0...Plugins-v1.1.1 [1.1.0]: https://github.com/joshsmithxrm/ppds-sdk/compare/Plugins-v1.0.0...Plugins-v1.1.0 [1.0.0]: https://github.com/joshsmithxrm/ppds-sdk/releases/tag/Plugins-v1.0.0 diff --git a/tests/PPDS.Cli.Tests/Commands/Plugins/CleanCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Plugins/CleanCommandTests.cs new file mode 100644 index 000000000..0f6956e10 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Plugins/CleanCommandTests.cs @@ -0,0 +1,155 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using PPDS.Cli.Commands.Plugins; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Plugins; + +public class CleanCommandTests : IDisposable +{ + private readonly Command _command; + private readonly string _tempConfigFile; + private readonly string _originalDir; + + public CleanCommandTests() + { + _command = CleanCommand.Create(); + + // Create temp config file for parsing tests + _tempConfigFile = Path.Combine(Path.GetTempPath(), $"registrations-{Guid.NewGuid()}.json"); + File.WriteAllText(_tempConfigFile, "{}"); + + // Change to temp directory for relative path tests + _originalDir = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(Path.GetTempPath()); + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalDir); + if (File.Exists(_tempConfigFile)) + File.Delete(_tempConfigFile); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("clean", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("Remove", _command.Description); + } + + [Fact] + public void Create_HasRequiredConfigOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--config"); + Assert.NotNull(option); + Assert.True(option.Required); + Assert.Contains("-c", option.Aliases); + } + + [Fact] + public void Create_HasOptionalProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalWhatIfOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--what-if"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalJsonOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--json"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + #endregion + + #region Argument Parsing Tests + + [Fact] + public void Parse_WithRequiredConfig_Succeeds() + { + var result = _command.Parse($"--config \"{_tempConfigFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithShortAliases_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_MissingConfig_HasError() + { + var result = _command.Parse(""); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalProfile_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --profile dev"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalEnvironment_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --environment https://org.crm.dynamics.com"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalWhatIf_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --what-if"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalJson_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --json"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithAllOptions_Succeeds() + { + var result = _command.Parse( + $"-c \"{_tempConfigFile}\" " + + "--profile dev " + + "--environment https://org.crm.dynamics.com " + + "--what-if " + + "--json"); + Assert.Empty(result.Errors); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/Plugins/DeployCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Plugins/DeployCommandTests.cs new file mode 100644 index 000000000..9c65a8f54 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Plugins/DeployCommandTests.cs @@ -0,0 +1,187 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using PPDS.Cli.Commands.Plugins; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Plugins; + +public class DeployCommandTests : IDisposable +{ + private readonly Command _command; + private readonly string _tempConfigFile; + private readonly string _originalDir; + + public DeployCommandTests() + { + _command = DeployCommand.Create(); + + // Create temp config file for parsing tests + _tempConfigFile = Path.Combine(Path.GetTempPath(), $"registrations-{Guid.NewGuid()}.json"); + File.WriteAllText(_tempConfigFile, "{}"); + + // Change to temp directory for relative path tests + _originalDir = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(Path.GetTempPath()); + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalDir); + if (File.Exists(_tempConfigFile)) + File.Delete(_tempConfigFile); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("deploy", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("Deploy", _command.Description); + } + + [Fact] + public void Create_HasRequiredConfigOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--config"); + Assert.NotNull(option); + Assert.True(option.Required); + Assert.Contains("-c", option.Aliases); + } + + [Fact] + public void Create_HasOptionalProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalSolutionOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalCleanOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--clean"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalWhatIfOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--what-if"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalJsonOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--json"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + #endregion + + #region Argument Parsing Tests + + [Fact] + public void Parse_WithRequiredConfig_Succeeds() + { + var result = _command.Parse($"--config \"{_tempConfigFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithShortAliases_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_MissingConfig_HasError() + { + var result = _command.Parse(""); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalProfile_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --profile dev"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalEnvironment_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --environment https://org.crm.dynamics.com"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalSolution_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --solution my_solution"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalClean_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --clean"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalWhatIf_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --what-if"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalJson_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --json"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithAllOptions_Succeeds() + { + var result = _command.Parse( + $"-c \"{_tempConfigFile}\" " + + "--profile dev " + + "--environment https://org.crm.dynamics.com " + + "--solution my_solution " + + "--clean " + + "--what-if " + + "--json"); + Assert.Empty(result.Errors); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/Plugins/DiffCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Plugins/DiffCommandTests.cs new file mode 100644 index 000000000..324ed0682 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Plugins/DiffCommandTests.cs @@ -0,0 +1,139 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using PPDS.Cli.Commands.Plugins; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Plugins; + +public class DiffCommandTests : IDisposable +{ + private readonly Command _command; + private readonly string _tempConfigFile; + private readonly string _originalDir; + + public DiffCommandTests() + { + _command = DiffCommand.Create(); + + // Create temp config file for parsing tests + _tempConfigFile = Path.Combine(Path.GetTempPath(), $"registrations-{Guid.NewGuid()}.json"); + File.WriteAllText(_tempConfigFile, "{}"); + + // Change to temp directory for relative path tests + _originalDir = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(Path.GetTempPath()); + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalDir); + if (File.Exists(_tempConfigFile)) + File.Delete(_tempConfigFile); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("diff", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("Compare", _command.Description); + } + + [Fact] + public void Create_HasRequiredConfigOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--config"); + Assert.NotNull(option); + Assert.True(option.Required); + Assert.Contains("-c", option.Aliases); + } + + [Fact] + public void Create_HasOptionalProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalJsonOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--json"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + #endregion + + #region Argument Parsing Tests + + [Fact] + public void Parse_WithRequiredConfig_Succeeds() + { + var result = _command.Parse($"--config \"{_tempConfigFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithShortAliases_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_MissingConfig_HasError() + { + var result = _command.Parse(""); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalProfile_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --profile dev"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalEnvironment_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --environment https://org.crm.dynamics.com"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalJson_Succeeds() + { + var result = _command.Parse($"-c \"{_tempConfigFile}\" --json"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithAllOptions_Succeeds() + { + var result = _command.Parse( + $"-c \"{_tempConfigFile}\" " + + "--profile dev " + + "--environment https://org.crm.dynamics.com " + + "--json"); + Assert.Empty(result.Errors); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/Plugins/ExtractCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Plugins/ExtractCommandTests.cs new file mode 100644 index 000000000..c48c2fa28 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Plugins/ExtractCommandTests.cs @@ -0,0 +1,137 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using PPDS.Cli.Commands.Plugins; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Plugins; + +public class ExtractCommandTests : IDisposable +{ + private readonly Command _command; + private readonly string _tempDllFile; + private readonly string _tempNupkgFile; + private readonly string _originalDir; + + public ExtractCommandTests() + { + _command = ExtractCommand.Create(); + + // Create temp files for parsing tests + _tempDllFile = Path.Combine(Path.GetTempPath(), $"test-plugin-{Guid.NewGuid()}.dll"); + _tempNupkgFile = Path.Combine(Path.GetTempPath(), $"test-plugin-{Guid.NewGuid()}.nupkg"); + + // Create empty placeholder files for AcceptExistingOnly validation + File.WriteAllBytes(_tempDllFile, []); + File.WriteAllBytes(_tempNupkgFile, []); + + // Change to temp directory for relative path tests + _originalDir = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(Path.GetTempPath()); + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalDir); + if (File.Exists(_tempDllFile)) + File.Delete(_tempDllFile); + if (File.Exists(_tempNupkgFile)) + File.Delete(_tempNupkgFile); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("extract", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("Extract", _command.Description); + } + + [Fact] + public void Create_HasRequiredInputOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--input"); + Assert.NotNull(option); + Assert.True(option.Required); + Assert.Contains("-i", option.Aliases); + } + + [Fact] + public void Create_HasOptionalOutputOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--output"); + Assert.NotNull(option); + Assert.False(option.Required); + Assert.Contains("-o", option.Aliases); + } + + [Fact] + public void Create_HasOptionalJsonOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--json"); + Assert.NotNull(option); + Assert.False(option.Required); + Assert.Contains("-j", option.Aliases); + } + + #endregion + + #region Argument Parsing Tests + + [Fact] + public void Parse_WithDllInput_Succeeds() + { + var result = _command.Parse($"--input \"{_tempDllFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithNupkgInput_Succeeds() + { + var result = _command.Parse($"--input \"{_tempNupkgFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithShortAliases_Succeeds() + { + var result = _command.Parse($"-i \"{_tempDllFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_MissingInput_HasError() + { + var result = _command.Parse(""); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalOutput_Succeeds() + { + var outputFile = $"test-output-{Guid.NewGuid()}.json"; + var result = _command.Parse($"-i \"{_tempDllFile}\" --output \"{outputFile}\""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalJson_Succeeds() + { + var result = _command.Parse($"-i \"{_tempDllFile}\" --json"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_NonExistentFile_HasError() + { + var result = _command.Parse("--input \"nonexistent.dll\""); + Assert.NotEmpty(result.Errors); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/Plugins/ListCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Plugins/ListCommandTests.cs new file mode 100644 index 000000000..d3a30ab7f --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Plugins/ListCommandTests.cs @@ -0,0 +1,137 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using PPDS.Cli.Commands.Plugins; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Plugins; + +public class ListCommandTests +{ + private readonly Command _command; + + public ListCommandTests() + { + _command = ListCommand.Create(); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("list", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("List", _command.Description); + } + + [Fact] + public void Create_HasOptionalProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalAssemblyOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--assembly"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + [Fact] + public void Create_HasOptionalPackageOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--package"); + Assert.NotNull(option); + Assert.False(option.Required); + Assert.Contains("-pkg", option.Aliases); + } + + [Fact] + public void Create_HasOptionalJsonOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--json"); + Assert.NotNull(option); + Assert.False(option.Required); + } + + #endregion + + #region Argument Parsing Tests + + [Fact] + public void Parse_WithNoOptions_Succeeds() + { + var result = _command.Parse(""); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalProfile_Succeeds() + { + var result = _command.Parse("--profile dev"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalEnvironment_Succeeds() + { + var result = _command.Parse("--environment https://org.crm.dynamics.com"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalAssembly_Succeeds() + { + var result = _command.Parse("--assembly MyPlugins"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalPackage_Succeeds() + { + var result = _command.Parse("--package MyPackage"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithPackageShortAlias_Succeeds() + { + var result = _command.Parse("-pkg MyPackage"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithOptionalJson_Succeeds() + { + var result = _command.Parse("--json"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Parse_WithAllOptions_Succeeds() + { + var result = _command.Parse( + "--profile dev " + + "--environment https://org.crm.dynamics.com " + + "--assembly MyPlugins " + + "--json"); + Assert.Empty(result.Errors); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/Plugins/PluginsCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/Plugins/PluginsCommandGroupTests.cs new file mode 100644 index 000000000..9a1316a07 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Plugins/PluginsCommandGroupTests.cs @@ -0,0 +1,124 @@ +using System.CommandLine; +using PPDS.Cli.Commands.Plugins; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Plugins; + +public class PluginsCommandGroupTests +{ + private readonly Command _command; + + public PluginsCommandGroupTests() + { + _command = PluginsCommandGroup.Create(); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("plugins", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("Plugin", _command.Description); + } + + [Fact] + public void Create_HasExtractSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "extract"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasDeploySubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "deploy"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasDiffSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "diff"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasListSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "list"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasCleanSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "clean"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasFiveSubcommands() + { + Assert.Equal(5, _command.Subcommands.Count); + } + + #endregion + + #region Shared Options Tests + + [Fact] + public void ProfileOption_HasCorrectName() + { + Assert.Equal("--profile", PluginsCommandGroup.ProfileOption.Name); + } + + [Fact] + public void ProfileOption_HasShortAlias() + { + Assert.Contains("-p", PluginsCommandGroup.ProfileOption.Aliases); + } + + [Fact] + public void EnvironmentOption_HasCorrectName() + { + Assert.Equal("--environment", PluginsCommandGroup.EnvironmentOption.Name); + } + + [Fact] + public void EnvironmentOption_HasShortAlias() + { + Assert.Contains("-env", PluginsCommandGroup.EnvironmentOption.Aliases); + } + + [Fact] + public void SolutionOption_HasCorrectName() + { + Assert.Equal("--solution", PluginsCommandGroup.SolutionOption.Name); + } + + [Fact] + public void SolutionOption_HasShortAlias() + { + Assert.Contains("-s", PluginsCommandGroup.SolutionOption.Aliases); + } + + [Fact] + public void JsonOption_HasCorrectName() + { + Assert.Equal("--json", PluginsCommandGroup.JsonOption.Name); + } + + [Fact] + public void JsonOption_HasShortAlias() + { + Assert.Contains("-j", PluginsCommandGroup.JsonOption.Aliases); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs b/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs new file mode 100644 index 000000000..7fce6f1ce --- /dev/null +++ b/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs @@ -0,0 +1,301 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using PPDS.Cli.Plugins.Models; +using Xunit; + +namespace PPDS.Cli.Tests.Plugins.Models; + +public class PluginRegistrationConfigTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true + }; + + #region Serialization Tests + + [Fact] + public void Serialize_EmptyConfig_ProducesValidJson() + { + var config = new PluginRegistrationConfig(); + var json = JsonSerializer.Serialize(config, JsonOptions); + + Assert.Contains("\"version\"", json); + Assert.Contains("\"assemblies\"", json); + } + + [Fact] + public void Serialize_ConfigWithSchema_IncludesSchemaProperty() + { + var config = new PluginRegistrationConfig + { + Schema = "https://example.com/schema.json" + }; + var json = JsonSerializer.Serialize(config, JsonOptions); + + Assert.Contains("\"$schema\"", json); + Assert.Contains("https://example.com/schema.json", json); + } + + [Fact] + public void Serialize_ConfigWithGeneratedAt_IncludesTimestamp() + { + var timestamp = DateTimeOffset.UtcNow; + var config = new PluginRegistrationConfig + { + GeneratedAt = timestamp + }; + var json = JsonSerializer.Serialize(config, JsonOptions); + + Assert.Contains("\"generatedAt\"", json); + } + + [Fact] + public void Serialize_FullConfig_ProducesCorrectStructure() + { + var config = new PluginRegistrationConfig + { + Version = "1.0", + Assemblies = + [ + new PluginAssemblyConfig + { + Name = "MyPlugins", + Type = "Assembly", + Path = "bin/MyPlugins.dll", + Types = + [ + new PluginTypeConfig + { + TypeName = "MyPlugins.AccountPlugin", + Steps = + [ + new PluginStepConfig + { + Name = "AccountPlugin: Update of account", + Message = "Update", + Entity = "account", + Stage = "PostOperation", + Mode = "Asynchronous", + Images = + [ + new PluginImageConfig + { + Name = "PreImage", + ImageType = "PreImage", + Attributes = "name,telephone1" + } + ] + } + ] + } + ] + } + ] + }; + + var json = JsonSerializer.Serialize(config, JsonOptions); + + Assert.Contains("\"name\": \"MyPlugins\"", json); + Assert.Contains("\"typeName\": \"MyPlugins.AccountPlugin\"", json); + Assert.Contains("\"message\": \"Update\"", json); + Assert.Contains("\"entity\": \"account\"", json); + Assert.Contains("\"stage\": \"PostOperation\"", json); + Assert.Contains("\"mode\": \"Asynchronous\"", json); + Assert.Contains("\"imageType\": \"PreImage\"", json); + } + + #endregion + + #region Deserialization Tests + + [Fact] + public void Deserialize_MinimalJson_Succeeds() + { + var json = """ + { + "version": "1.0", + "assemblies": [] + } + """; + + var config = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(config); + Assert.Equal("1.0", config.Version); + Assert.Empty(config.Assemblies); + } + + [Fact] + public void Deserialize_FullJson_Succeeds() + { + var json = """ + { + "$schema": "https://example.com/schema.json", + "version": "1.0", + "generatedAt": "2024-01-15T10:30:00Z", + "assemblies": [ + { + "name": "MyPlugins", + "type": "Assembly", + "path": "bin/MyPlugins.dll", + "allTypeNames": ["MyPlugins.AccountPlugin"], + "types": [ + { + "typeName": "MyPlugins.AccountPlugin", + "steps": [ + { + "name": "AccountPlugin: Update of account", + "message": "Update", + "entity": "account", + "stage": "PostOperation", + "mode": "Asynchronous", + "executionOrder": 1, + "filteringAttributes": "name,telephone1", + "images": [ + { + "name": "PreImage", + "imageType": "PreImage", + "attributes": "name,telephone1" + } + ] + } + ] + } + ] + } + ] + } + """; + + var config = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(config); + Assert.Equal("1.0", config.Version); + Assert.Single(config.Assemblies); + + var assembly = config.Assemblies[0]; + Assert.Equal("MyPlugins", assembly.Name); + Assert.Equal("Assembly", assembly.Type); + Assert.Single(assembly.Types); + + var type = assembly.Types[0]; + Assert.Equal("MyPlugins.AccountPlugin", type.TypeName); + Assert.Single(type.Steps); + + var step = type.Steps[0]; + Assert.Equal("Update", step.Message); + Assert.Equal("account", step.Entity); + Assert.Equal("PostOperation", step.Stage); + Assert.Equal("Asynchronous", step.Mode); + Assert.Single(step.Images); + + var image = step.Images[0]; + Assert.Equal("PreImage", image.Name); + Assert.Equal("PreImage", image.ImageType); + } + + [Fact] + public void Deserialize_NugetPackage_Succeeds() + { + var json = """ + { + "version": "1.0", + "assemblies": [ + { + "name": "MyPlugins", + "type": "Nuget", + "packagePath": "bin/MyPlugins.1.0.0.nupkg", + "solution": "my_solution", + "types": [] + } + ] + } + """; + + var config = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(config); + var assembly = config.Assemblies[0]; + Assert.Equal("Nuget", assembly.Type); + Assert.Equal("bin/MyPlugins.1.0.0.nupkg", assembly.PackagePath); + Assert.Equal("my_solution", assembly.Solution); + } + + #endregion + + #region Round-Trip Tests + + [Fact] + public void RoundTrip_PreservesAllProperties() + { + var original = new PluginRegistrationConfig + { + Schema = "https://example.com/schema.json", + Version = "1.0", + GeneratedAt = DateTimeOffset.Parse("2024-01-15T10:30:00Z"), + Assemblies = + [ + new PluginAssemblyConfig + { + Name = "TestPlugins", + Type = "Assembly", + Path = "bin/TestPlugins.dll", + Solution = "test_solution", + AllTypeNames = ["TestPlugins.Plugin1", "TestPlugins.Plugin2"], + Types = + [ + new PluginTypeConfig + { + TypeName = "TestPlugins.Plugin1", + Steps = + [ + new PluginStepConfig + { + Name = "Plugin1: Create of contact", + Message = "Create", + Entity = "contact", + SecondaryEntity = null, + Stage = "PreOperation", + Mode = "Synchronous", + ExecutionOrder = 10, + FilteringAttributes = null, + Configuration = "some config", + StepId = "step1", + Images = [] + } + ] + } + ] + } + ] + }; + + var json = JsonSerializer.Serialize(original, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(deserialized); + Assert.Equal(original.Schema, deserialized.Schema); + Assert.Equal(original.Version, deserialized.Version); + Assert.Equal(original.Assemblies.Count, deserialized.Assemblies.Count); + + var origAssembly = original.Assemblies[0]; + var deserAssembly = deserialized.Assemblies[0]; + Assert.Equal(origAssembly.Name, deserAssembly.Name); + Assert.Equal(origAssembly.Type, deserAssembly.Type); + Assert.Equal(origAssembly.Solution, deserAssembly.Solution); + Assert.Equal(origAssembly.AllTypeNames.Count, deserAssembly.AllTypeNames.Count); + + var origStep = origAssembly.Types[0].Steps[0]; + var deserStep = deserAssembly.Types[0].Steps[0]; + Assert.Equal(origStep.Name, deserStep.Name); + Assert.Equal(origStep.Message, deserStep.Message); + Assert.Equal(origStep.ExecutionOrder, deserStep.ExecutionOrder); + Assert.Equal(origStep.Configuration, deserStep.Configuration); + Assert.Equal(origStep.StepId, deserStep.StepId); + } + + #endregion +} diff --git a/tests/PPDS.Plugins.Tests/PluginStepAttributeTests.cs b/tests/PPDS.Plugins.Tests/PluginStepAttributeTests.cs index 63564acc4..88a75e100 100644 --- a/tests/PPDS.Plugins.Tests/PluginStepAttributeTests.cs +++ b/tests/PPDS.Plugins.Tests/PluginStepAttributeTests.cs @@ -19,7 +19,8 @@ public void DefaultConstructor_SetsDefaultValues() Assert.Null(attribute.FilteringAttributes); Assert.Null(attribute.Name); Assert.Null(attribute.UnsecureConfiguration); - Assert.Null(attribute.SecureConfiguration); + Assert.Null(attribute.Description); + Assert.False(attribute.AsyncAutoDelete); Assert.Null(attribute.StepId); Assert.Null(attribute.SecondaryEntityLogicalName); } @@ -144,11 +145,25 @@ public void UnsecureConfiguration_CanBeSet() } [Fact] - public void SecureConfiguration_CanBeSet() + public void Description_CanBeSet() { - const string config = "{\"apiKey\": \"secret123\"}"; - var attribute = new PluginStepAttribute { SecureConfiguration = config }; - Assert.Equal(config, attribute.SecureConfiguration); + const string description = "Logs account changes to audit table"; + var attribute = new PluginStepAttribute { Description = description }; + Assert.Equal(description, attribute.Description); + } + + [Fact] + public void AsyncAutoDelete_CanBeSet() + { + var attribute = new PluginStepAttribute { AsyncAutoDelete = true }; + Assert.True(attribute.AsyncAutoDelete); + } + + [Fact] + public void AsyncAutoDelete_DefaultsToFalse() + { + var attribute = new PluginStepAttribute(); + Assert.False(attribute.AsyncAutoDelete); } [Fact] @@ -232,16 +247,31 @@ public void TypicalUpdatePluginWithFilteringAttributes() } [Fact] - public void PluginWithBothConfigurations() + public void PluginWithConfigurationAndDescription() { var attribute = new PluginStepAttribute("Create", "email", PluginStage.PostOperation) { UnsecureConfiguration = "{\"retryCount\": 3}", - SecureConfiguration = "{\"apiKey\": \"secret\"}" + Description = "Sends email notifications on record creation" }; Assert.NotNull(attribute.UnsecureConfiguration); - Assert.NotNull(attribute.SecureConfiguration); + Assert.NotNull(attribute.Description); + } + + [Fact] + public void AsyncPluginWithAutoDelete() + { + var attribute = new PluginStepAttribute("Update", "account", PluginStage.PostOperation) + { + Mode = PluginMode.Asynchronous, + AsyncAutoDelete = true, + Description = "Async audit logging with auto-cleanup" + }; + + Assert.Equal(PluginMode.Asynchronous, attribute.Mode); + Assert.True(attribute.AsyncAutoDelete); + Assert.NotNull(attribute.Description); } #endregion