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