From 9a5188204b9e569e183a98026e69b79b4af8d594 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 25 May 2026 12:06:11 +0200 Subject: [PATCH 1/3] feat: env plugin assembly/type/step list commands --- .../Dataverse/IPluginInventoryService.cs | 76 +++++++ .../EnvironmentCliCommand.cs | 2 +- .../Assemblies/PluginAssemblyCliCommand.cs | 17 ++ .../PluginAssemblyListCliCommand.cs | 50 +++++ .../Plugin/PluginCliCommand.cs | 22 ++ .../Plugin/Steps/PluginStepCliCommand.cs | 17 ++ .../Plugin/Steps/PluginStepListCliCommand.cs | 59 ++++++ .../Plugin/Types/PluginTypeCliCommand.cs | 17 ++ .../Plugin/Types/PluginTypeListCliCommand.cs | 71 +++++++ ...eApplicationServiceCollectionExtensions.cs | 1 + .../Sdk/PluginInventoryManager.cs | 189 ++++++++++++++++++ .../DataversePluginInventoryService.cs | 29 +++ 12 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyListCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeListCliCommand.cs create mode 100644 src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs create mode 100644 src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs new file mode 100644 index 00000000..dadcd68c --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs @@ -0,0 +1,76 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +public enum PluginKind { Plugin = 0, WorkflowActivity = 1 } + +public enum PluginStage +{ + PreValidation = 10, + PreOperation = 20, + PostOperation = 40, + PostOperationDeprecated = 50, +} + +public enum PluginExecutionMode { Synchronous = 0, Asynchronous = 1 } + +public enum PluginIsolationMode { None = 1, Sandbox = 2, External = 3 } + +public enum PluginAssemblySourceType { Database = 0, Disk = 1, Gac = 2, Package = 4 } + +public sealed record PluginAssemblyRecord( + Guid Id, + string Name, + string? Version, + string? Culture, + string? PublicKeyToken, + PluginIsolationMode IsolationMode, + PluginAssemblySourceType SourceType, + string? Description, + DateTime? ModifiedOn); + +public sealed record PluginTypeRecord( + Guid Id, + string TypeName, + string? FriendlyName, + PluginKind Kind, + string? WorkflowActivityGroupName, + string? Description, + Guid AssemblyId, + string AssemblyName, + string? AssemblyVersion); + +public sealed record PluginStepRecord( + Guid Id, + string Name, + string? Description, + string Message, + string? PrimaryEntity, + PluginStage Stage, + PluginExecutionMode Mode, + int Rank, + bool Enabled, + string? FilteringAttributes, + string? Configuration, + Guid PluginTypeId, + string PluginTypeName, + Guid AssemblyId, + string AssemblyName, + string? AssemblyVersion); + +public interface IPluginInventoryService +{ + Task> ListAssembliesAsync( + string? profileName, + string? nameContains, + CancellationToken ct); + + Task> ListTypesAsync( + string? profileName, + string? assemblyContains, + PluginKind? kind, + CancellationToken ct); + + Task> ListStepsAsync( + string? profileName, + string? assemblyContains, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 7b3fd0ba..341fa0e3 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand), typeof(Plugin.PluginCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs new file mode 100644 index 00000000..a71a2dc6 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs @@ -0,0 +1,17 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Plugin.Assemblies; + +[CliCommand( + Name = "assembly", + Description = "List plugin assemblies registered in the connected environment.", + Children = new[] { typeof(PluginAssemblyListCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class PluginAssemblyCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyListCliCommand.cs new file mode 100644 index 00000000..b41533fb --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyListCliCommand.cs @@ -0,0 +1,50 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Assemblies; + +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List plugin assemblies registered in the connected environment. Useful for verifying which version of an assembly is currently deployed." +)] +public class PluginAssemblyListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginAssemblyListCliCommand)); + + [CliOption(Name = "--name", Description = "Filter to assemblies whose name contains this substring.", Required = false)] + public string? Name { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var rows = await service.ListAssembliesAsync(Profile, Name, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(rows, PrintTable); + return ExitSuccess; + } + +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList rows) + { + if (rows.Count == 0) { OutputWriter.WriteLine("No plugin assemblies found."); return; } + + int nameWidth = Math.Clamp(rows.Max(r => r.Name.Length), 20, 60); + int versionWidth = Math.Clamp(rows.Max(r => (r.Version ?? "").Length), 7, 18); + string header = $"{"Name".PadRight(nameWidth)} | {"Version".PadRight(versionWidth)} | {"Isolation".PadRight(9)} | {"Source".PadRight(8)} | Modified"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var r in rows) + { + string name = r.Name.Length > nameWidth ? r.Name[..(nameWidth - 1)] + "." : r.Name; + string version = (r.Version ?? "").PadRight(versionWidth); + string modified = r.ModifiedOn?.ToString("yyyy-MM-dd HH:mm") ?? ""; + OutputWriter.WriteLine($"{name.PadRight(nameWidth)} | {version} | {r.IsolationMode.ToString().PadRight(9)} | {r.SourceType.ToString().PadRight(8)} | {modified}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs new file mode 100644 index 00000000..02f19424 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs @@ -0,0 +1,22 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Plugin; + +[CliCommand( + Name = "plugin", + Description = "Inspect plugin assemblies, plugin types, and processing steps registered in the connected environment.", + Children = new[] + { + typeof(Assemblies.PluginAssemblyCliCommand), + typeof(Types.PluginTypeCliCommand), + typeof(Steps.PluginStepCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class PluginCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs new file mode 100644 index 00000000..6ad89f85 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs @@ -0,0 +1,17 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +[CliCommand( + Name = "step", + Description = "List plugin processing steps registered in the connected environment.", + Children = new[] { typeof(PluginStepListCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class PluginStepCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs new file mode 100644 index 00000000..49fc4fca --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs @@ -0,0 +1,59 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List plugin processing steps (SdkMessageProcessingStep) registered in the connected environment. Shows which message + entity each step fires on, its stage, mode, rank, and enabled state." +)] +public class PluginStepListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginStepListCliCommand)); + + [CliOption(Name = "--assembly", Description = "Filter to steps whose owning assembly name contains this substring.", Required = false)] + public string? Assembly { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var rows = await service.ListStepsAsync(Profile, Assembly, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(rows, PrintTable); + return ExitSuccess; + } + +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList rows) + { + if (rows.Count == 0) { OutputWriter.WriteLine("No plugin steps found."); return; } + + var ordered = rows + .OrderBy(r => r.AssemblyName, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.PluginTypeName, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Rank) + .ToList(); + + int msgWidth = Math.Clamp(ordered.Max(r => r.Message.Length), 8, 18); + int entityWidth = Math.Clamp(ordered.Max(r => (r.PrimaryEntity ?? "").Length), 8, 22); + int typeWidth = Math.Clamp(ordered.Max(r => r.PluginTypeName.Length), 30, 60); + + string header = $"{"Message".PadRight(msgWidth)} | {"Entity".PadRight(entityWidth)} | {"Stage".PadRight(15)} | {"Mode".PadRight(5)} | {"Rank".PadRight(4)} | {"State".PadRight(8)} | {"Plugin Type".PadRight(typeWidth)} | Step Name"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var r in ordered) + { + string entity = (r.PrimaryEntity ?? "").PadRight(entityWidth); + string typeName = r.PluginTypeName.Length > typeWidth ? r.PluginTypeName[..(typeWidth - 1)] + "." : r.PluginTypeName; + string state = r.Enabled ? "Enabled" : "Disabled"; + string mode = r.Mode == PluginExecutionMode.Synchronous ? "Sync" : "Async"; + OutputWriter.WriteLine($"{r.Message.PadRight(msgWidth)} | {entity} | {r.Stage.ToString().PadRight(15)} | {mode.PadRight(5)} | {r.Rank.ToString().PadRight(4)} | {state.PadRight(8)} | {typeName.PadRight(typeWidth)} | {r.Name}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeCliCommand.cs new file mode 100644 index 00000000..c32027ff --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeCliCommand.cs @@ -0,0 +1,17 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Plugin.Types; + +[CliCommand( + Name = "type", + Description = "List plugin types (plugins and workflow activities) registered in the connected environment.", + Children = new[] { typeof(PluginTypeListCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class PluginTypeCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeListCliCommand.cs new file mode 100644 index 00000000..84fce5c8 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Types/PluginTypeListCliCommand.cs @@ -0,0 +1,71 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Types; + +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List plugin types (plugins and workflow activities) registered in the connected environment." +)] +public class PluginTypeListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginTypeListCliCommand)); + + [CliOption(Name = "--assembly", Description = "Filter to plugin types whose parent assembly name contains this substring.", Required = false)] + public string? Assembly { get; set; } + + [CliOption(Name = "--kind", Description = "Filter by kind: plugin, workflow, or all (default).", Required = false)] + public string? Kind { get; set; } + + protected override async Task ExecuteAsync() + { + PluginKind? kind = null; + if (!string.IsNullOrWhiteSpace(Kind)) + { + switch (Kind.Trim().ToLowerInvariant()) + { + case "plugin": kind = PluginKind.Plugin; break; + case "workflow": + case "workflowactivity": + case "wf": kind = PluginKind.WorkflowActivity; break; + case "all": kind = null; break; + default: + Logger.LogError("Invalid --kind value '{Kind}'. Expected: plugin, workflow, or all.", Kind); + return ExitValidationError; + } + } + + var service = TxcServices.Get(); + var rows = await service.ListTypesAsync(Profile, Assembly, kind, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(rows, PrintTable); + return ExitSuccess; + } + +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList rows) + { + if (rows.Count == 0) { OutputWriter.WriteLine("No plugin types found."); return; } + + int typeWidth = Math.Clamp(rows.Max(r => r.TypeName.Length), 30, 70); + int assemblyWidth = Math.Clamp(rows.Max(r => r.AssemblyName.Length), 15, 45); + int versionWidth = Math.Clamp(rows.Max(r => (r.AssemblyVersion ?? "").Length), 7, 15); + string header = $"{"Type Name".PadRight(typeWidth)} | {"Kind".PadRight(16)} | {"Assembly".PadRight(assemblyWidth)} | {"Version".PadRight(versionWidth)} | WF Group"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var r in rows) + { + string typeName = r.TypeName.Length > typeWidth ? r.TypeName[..(typeWidth - 1)] + "." : r.TypeName; + string asm = r.AssemblyName.Length > assemblyWidth ? r.AssemblyName[..(assemblyWidth - 1)] + "." : r.AssemblyName; + string ver = (r.AssemblyVersion ?? "").PadRight(versionWidth); + string group = r.WorkflowActivityGroupName ?? ""; + OutputWriter.WriteLine($"{typeName.PadRight(typeWidth)} | {r.Kind.ToString().PadRight(16)} | {asm.PadRight(assemblyWidth)} | {ver} | {group}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs index 5731793c..fcb0ee7a 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ public static IServiceCollection AddTxcDataverseApplication(this IServiceCollect services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs new file mode 100644 index 00000000..ad202986 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs @@ -0,0 +1,189 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Sdk; + +internal static class PluginInventoryManager +{ + public static async Task> ListAssembliesAsync( + IOrganizationServiceAsync2 service, + string? nameContains, + CancellationToken ct) + { + var query = new QueryExpression("pluginassembly") + { + ColumnSet = new ColumnSet( + "pluginassemblyid", "name", "version", "culture", "publickeytoken", + "isolationmode", "sourcetype", "description", "modifiedon"), + }; + if (!string.IsNullOrWhiteSpace(nameContains)) + query.Criteria.AddCondition("name", ConditionOperator.Like, $"%{nameContains}%"); + query.AddOrder("name", OrderType.Ascending); + + var rows = await RetrieveAllAsync(service, query, ct).ConfigureAwait(false); + return rows.Select(MapAssembly).ToList(); + } + + public static async Task> ListTypesAsync( + IOrganizationServiceAsync2 service, + string? assemblyContains, + PluginKind? kind, + CancellationToken ct) + { + var query = new QueryExpression("plugintype") + { + ColumnSet = new ColumnSet( + "plugintypeid", "typename", "friendlyname", "isworkflowactivity", + "workflowactivitygroupname", "description", "pluginassemblyid"), + }; + query.Criteria.AddCondition("typename", ConditionOperator.NotLike, "Compiled.Workflow%"); + + if (kind == PluginKind.Plugin) + query.Criteria.AddCondition("isworkflowactivity", ConditionOperator.Equal, false); + else if (kind == PluginKind.WorkflowActivity) + query.Criteria.AddCondition("isworkflowactivity", ConditionOperator.Equal, true); + + var assemblyLink = query.AddLink("pluginassembly", "pluginassemblyid", "pluginassemblyid", JoinOperator.Inner); + assemblyLink.EntityAlias = "a"; + assemblyLink.Columns = new ColumnSet("pluginassemblyid", "name", "version"); + if (!string.IsNullOrWhiteSpace(assemblyContains)) + assemblyLink.LinkCriteria.AddCondition("name", ConditionOperator.Like, $"%{assemblyContains}%"); + + query.AddOrder("typename", OrderType.Ascending); + + var rows = await RetrieveAllAsync(service, query, ct).ConfigureAwait(false); + return rows.Select(MapType).ToList(); + } + + public static async Task> ListStepsAsync( + IOrganizationServiceAsync2 service, + string? assemblyContains, + CancellationToken ct) + { + var query = new QueryExpression("sdkmessageprocessingstep") + { + ColumnSet = new ColumnSet( + "sdkmessageprocessingstepid", "name", "description", "mode", "stage", "rank", + "statecode", "filteringattributes", "configuration", + "plugintypeid", "sdkmessageid", "sdkmessagefilterid"), + }; + query.Criteria.AddCondition("stage", ConditionOperator.In, 10, 20, 40, 50); + + var typeLink = query.AddLink("plugintype", "plugintypeid", "plugintypeid", JoinOperator.Inner); + typeLink.EntityAlias = "pt"; + typeLink.Columns = new ColumnSet("plugintypeid", "typename", "pluginassemblyid"); + + var assemblyLink = typeLink.AddLink("pluginassembly", "pluginassemblyid", "pluginassemblyid", JoinOperator.Inner); + assemblyLink.EntityAlias = "a"; + assemblyLink.Columns = new ColumnSet("pluginassemblyid", "name", "version"); + if (!string.IsNullOrWhiteSpace(assemblyContains)) + assemblyLink.LinkCriteria.AddCondition("name", ConditionOperator.Like, $"%{assemblyContains}%"); + + var msgLink = query.AddLink("sdkmessage", "sdkmessageid", "sdkmessageid", JoinOperator.Inner); + msgLink.EntityAlias = "msg"; + msgLink.Columns = new ColumnSet("name"); + + var filterLink = query.AddLink("sdkmessagefilter", "sdkmessagefilterid", "sdkmessagefilterid", JoinOperator.LeftOuter); + filterLink.EntityAlias = "filter"; + filterLink.Columns = new ColumnSet("primaryobjecttypecode"); + + query.AddOrder("rank", OrderType.Ascending); + + var rows = await RetrieveAllAsync(service, query, ct).ConfigureAwait(false); + return rows.Select(MapStep).ToList(); + } + + private static async Task> RetrieveAllAsync( + IOrganizationServiceAsync2 service, QueryExpression query, CancellationToken ct) + { + query.PageInfo ??= new PagingInfo(); + query.PageInfo.PageNumber = 1; + query.PageInfo.PagingCookie = null; + query.PageInfo.Count = 5000; + + var all = new List(); + while (true) + { + ct.ThrowIfCancellationRequested(); + var page = await service.RetrieveMultipleAsync(query, ct).ConfigureAwait(false); + all.AddRange(page.Entities); + if (!page.MoreRecords) break; + query.PageInfo.PageNumber++; + query.PageInfo.PagingCookie = page.PagingCookie; + } + return all; + } + + private static PluginAssemblyRecord MapAssembly(Entity e) => new( + Id: e.Id, + Name: e.GetAttributeValue("name") ?? "(unknown)", + Version: e.GetAttributeValue("version"), + Culture: e.GetAttributeValue("culture"), + PublicKeyToken: e.GetAttributeValue("publickeytoken"), + IsolationMode: (PluginIsolationMode)(e.GetAttributeValue("isolationmode")?.Value ?? 1), + SourceType: (PluginAssemblySourceType)(e.GetAttributeValue("sourcetype")?.Value ?? 0), + Description: e.GetAttributeValue("description"), + ModifiedOn: e.GetAttributeValue("modifiedon")); + + private static PluginTypeRecord MapType(Entity e) + { + var assemblyRef = e.GetAttributeValue("pluginassemblyid"); + var assemblyId = assemblyRef?.Id ?? GetAliasedGuid(e, "a.pluginassemblyid"); + var isWorkflow = e.GetAttributeValue("isworkflowactivity"); + + return new PluginTypeRecord( + Id: e.Id, + TypeName: e.GetAttributeValue("typename") ?? "(unknown)", + FriendlyName: e.GetAttributeValue("friendlyname"), + Kind: isWorkflow ? PluginKind.WorkflowActivity : PluginKind.Plugin, + WorkflowActivityGroupName: e.GetAttributeValue("workflowactivitygroupname"), + Description: e.GetAttributeValue("description"), + AssemblyId: assemblyId, + AssemblyName: GetAliasedString(e, "a.name") ?? assemblyRef?.Name ?? "(unknown)", + AssemblyVersion: GetAliasedString(e, "a.version")); + } + + private static PluginStepRecord MapStep(Entity e) + { + var ptRef = e.GetAttributeValue("plugintypeid"); + var ptId = ptRef?.Id ?? GetAliasedGuid(e, "pt.plugintypeid"); + var ptName = GetAliasedString(e, "pt.typename") ?? ptRef?.Name ?? "(unknown)"; + var assemblyId = GetAliasedGuid(e, "a.pluginassemblyid"); + var assemblyName = GetAliasedString(e, "a.name") ?? "(unknown)"; + var assemblyVersion = GetAliasedString(e, "a.version"); + var message = GetAliasedString(e, "msg.name") ?? "(unknown)"; + var primaryEntity = GetAliasedString(e, "filter.primaryobjecttypecode"); + var stage = e.GetAttributeValue("stage")?.Value ?? 0; + var mode = e.GetAttributeValue("mode")?.Value ?? 0; + var statecode = e.GetAttributeValue("statecode")?.Value; + + return new PluginStepRecord( + Id: e.Id, + Name: e.GetAttributeValue("name") ?? "(unknown)", + Description: e.GetAttributeValue("description"), + Message: message, + PrimaryEntity: string.IsNullOrEmpty(primaryEntity) ? null : primaryEntity, + Stage: (PluginStage)stage, + Mode: (PluginExecutionMode)mode, + Rank: e.GetAttributeValue("rank"), + Enabled: statecode == 0, + FilteringAttributes: e.GetAttributeValue("filteringattributes"), + Configuration: e.GetAttributeValue("configuration"), + PluginTypeId: ptId, + PluginTypeName: ptName, + AssemblyId: assemblyId, + AssemblyName: assemblyName, + AssemblyVersion: assemblyVersion); + } + + private static string? GetAliasedString(Entity e, string alias) + => e.GetAttributeValue(alias)?.Value as string; + + private static Guid GetAliasedGuid(Entity e, string alias) + { + var av = e.GetAttributeValue(alias); + return av?.Value is Guid g ? g : Guid.Empty; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs new file mode 100644 index 00000000..5629e83b --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs @@ -0,0 +1,29 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Application.Sdk; +using TALXIS.CLI.Platform.Dataverse.Runtime; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Services; + +internal sealed class DataversePluginInventoryService : IPluginInventoryService +{ + public async Task> ListAssembliesAsync( + string? profileName, string? nameContains, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await PluginInventoryManager.ListAssembliesAsync(conn.Client, nameContains, ct).ConfigureAwait(false); + } + + public async Task> ListTypesAsync( + string? profileName, string? assemblyContains, PluginKind? kind, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await PluginInventoryManager.ListTypesAsync(conn.Client, assemblyContains, kind, ct).ConfigureAwait(false); + } + + public async Task> ListStepsAsync( + string? profileName, string? assemblyContains, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await PluginInventoryManager.ListStepsAsync(conn.Client, assemblyContains, ct).ConfigureAwait(false); + } +} From 6f0ecf557a4250a7b278461789e02f12d02aa0c3 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Thu, 4 Jun 2026 14:41:26 +0200 Subject: [PATCH 2/3] feat: env plugin step enable/disable/show, list filters, assembly show --- .../Dataverse/IPluginInventoryService.cs | 24 +++ .../Assemblies/PluginAssemblyCliCommand.cs | 8 +- .../Assemblies/PluginAssemblyResolver.cs | 49 ++++++ .../PluginAssemblyShowCliCommand.cs | 109 +++++++++++++ .../Plugin/Steps/PluginStepCliCommand.cs | 11 +- .../Steps/PluginStepDisableCliCommand.cs | 48 ++++++ .../Steps/PluginStepEnableAllCliCommand.cs | 51 ++++++ .../Steps/PluginStepEnableCliCommand.cs | 48 ++++++ .../Plugin/Steps/PluginStepListCliCommand.cs | 19 ++- .../Plugin/Steps/PluginStepQuery.cs | 86 +++++++++++ .../Plugin/Steps/PluginStepResolver.cs | 51 ++++++ .../Plugin/Steps/PluginStepShowCliCommand.cs | 82 ++++++++++ .../Sdk/PluginInventoryManager.cs | 68 ++++++++ .../DataversePluginInventoryService.cs | 21 +++ .../Plugin/PluginAssemblyResolverTests.cs | 70 +++++++++ .../PluginAssemblyShowCliCommandTests.cs | 79 ++++++++++ .../Plugin/PluginInventoryManagerTests.cs | 18 +++ .../PluginStepEnableAllCliCommandTests.cs | 54 +++++++ .../Plugin/PluginStepQueryTests.cs | 146 ++++++++++++++++++ .../Plugin/PluginStepResolverTests.cs | 129 ++++++++++++++++ .../Plugin/PluginStepShowCliCommandTests.cs | 77 +++++++++ 21 files changed, 1243 insertions(+), 5 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyResolver.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyShowCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepDisableCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableAllCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepQuery.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepResolver.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepShowCliCommand.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyResolverTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyShowCliCommandTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginInventoryManagerTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepEnableAllCliCommandTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepQueryTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepResolverTests.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepShowCliCommandTests.cs diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs index dadcd68c..2860f911 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs @@ -56,6 +56,13 @@ public sealed record PluginStepRecord( string AssemblyName, string? AssemblyVersion); +public sealed record PluginStepImageRecord( + Guid Id, + Guid StepId, + string ImageType, + string? EntityAlias, + string? Attributes); + public interface IPluginInventoryService { Task> ListAssembliesAsync( @@ -73,4 +80,21 @@ Task> ListStepsAsync( string? profileName, string? assemblyContains, CancellationToken ct); + + Task> ListStepImagesAsync( + string? profileName, + string? assemblyContains, + CancellationToken ct); + + Task SetStepStateAsync( + string? profileName, + Guid stepId, + bool enabled, + CancellationToken ct); + + Task SetStepsStateAsync( + string? profileName, + IReadOnlyCollection stepIds, + bool enabled, + CancellationToken ct); } diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs index a71a2dc6..e2269c8b 100644 --- a/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyCliCommand.cs @@ -4,8 +4,12 @@ namespace TALXIS.CLI.Features.Environment.Plugin.Assemblies; [CliCommand( Name = "assembly", - Description = "List plugin assemblies registered in the connected environment.", - Children = new[] { typeof(PluginAssemblyListCliCommand) }, + Description = "List and inspect plugin assemblies registered in the connected environment.", + Children = new[] + { + typeof(PluginAssemblyListCliCommand), + typeof(PluginAssemblyShowCliCommand), + }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class PluginAssemblyCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyResolver.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyResolver.cs new file mode 100644 index 00000000..f5f59bcb --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyResolver.cs @@ -0,0 +1,49 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Features.Environment.Plugin.Assemblies; + +/// +/// Resolves a single plugin assembly from a token that may be an assembly GUID +/// or a name (exact, then unique substring). Mirrors the step resolver so the +/// CLI behaves consistently when a user passes a name or id. +/// +public static class PluginAssemblyResolver +{ + public sealed record Resolution(PluginAssemblyRecord? Assembly, string? Error); + + public static Resolution Resolve(IReadOnlyList rows, string token) + { + if (string.IsNullOrWhiteSpace(token)) + return new Resolution(null, "No assembly identifier supplied. Pass an assembly GUID or name."); + + token = token.Trim(); + + if (Guid.TryParse(token, out var id)) + { + var byId = rows.FirstOrDefault(r => r.Id == id); + return byId is not null + ? new Resolution(byId, null) + : new Resolution(null, $"No plugin assembly found with id '{id}'."); + } + + var exact = rows.Where(r => string.Equals(r.Name, token, StringComparison.OrdinalIgnoreCase)).ToList(); + if (exact.Count == 1) + return new Resolution(exact[0], null); + if (exact.Count > 1) + return new Resolution(null, AmbiguousError(token, exact)); + + var partial = rows.Where(r => r.Name.Contains(token, StringComparison.OrdinalIgnoreCase)).ToList(); + if (partial.Count == 1) + return new Resolution(partial[0], null); + if (partial.Count > 1) + return new Resolution(null, AmbiguousError(token, partial)); + + return new Resolution(null, $"No plugin assembly found matching '{token}'."); + } + + private static string AmbiguousError(string token, IReadOnlyList matches) + { + var names = string.Join("\n ", matches.Take(10).Select(m => $"{m.Id} {m.Name}")); + return $"'{token}' is ambiguous; {matches.Count} assemblies match. Use the assembly id or a longer name:\n {names}"; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyShowCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyShowCliCommand.cs new file mode 100644 index 00000000..df96a6fe --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Assemblies/PluginAssemblyShowCliCommand.cs @@ -0,0 +1,109 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Assemblies; + +[CliReadOnly] +[CliCommand( + Name = "show", + Description = "Show the details of a single plugin assembly — its plugin types, processing steps (with enabled/disabled state), and step images. Accepts an assembly GUID or name (exact or unique substring)." +)] +public class PluginAssemblyShowCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginAssemblyShowCliCommand)); + + [CliArgument(Name = "assembly", Description = "Assembly id (GUID) or name (exact or unique substring).")] + public string Assembly { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + + var assemblies = await service.ListAssembliesAsync(Profile, Assembly, CancellationToken.None).ConfigureAwait(false); + var resolution = PluginAssemblyResolver.Resolve(assemblies, Assembly); + if (resolution.Assembly is null) + { + Logger.LogError("{Error}", resolution.Error); + return ExitValidationError; + } + + var asm = resolution.Assembly; + var types = await service.ListTypesAsync(Profile, asm.Name, kind: null, CancellationToken.None).ConfigureAwait(false); + var steps = await service.ListStepsAsync(Profile, asm.Name, CancellationToken.None).ConfigureAwait(false); + var images = await service.ListStepImagesAsync(Profile, asm.Name, CancellationToken.None).ConfigureAwait(false); + + // Keep only the rows that actually belong to the resolved assembly — the + // service filters are substring matches, so a narrower id match can still + // pull in siblings sharing a name fragment. + var ownTypes = types.Where(t => t.AssemblyId == asm.Id).ToList(); + var ownSteps = steps.Where(s => s.AssemblyId == asm.Id).ToList(); + var stepIds = ownSteps.Select(s => s.Id).ToHashSet(); + var ownImages = images.Where(i => stepIds.Contains(i.StepId)).ToList(); + + var detail = new { Assembly = asm, Types = ownTypes, Steps = ownSteps, Images = ownImages }; + OutputFormatter.WriteData(detail, _ => PrintDetail(asm, ownTypes, ownSteps, ownImages)); + return ExitSuccess; + } + + /// + /// Builds the human-readable detail block for an assembly and its components. + /// Pure and public so rendering is unit-testable without a live environment. + /// + public static IReadOnlyList BuildDetailLines( + PluginAssemblyRecord asm, + IReadOnlyList types, + IReadOnlyList steps, + IReadOnlyList images) + { + var imagesByStep = images + .GroupBy(i => i.StepId) + .ToDictionary(g => g.Key, g => g.Count()); + + var lines = new List + { + $"Assembly : {asm.Name}" + (asm.Version is { } v ? $" ({v})" : ""), + $"Id : {asm.Id}", + $"Isolation : {asm.IsolationMode}", + $"Source : {asm.SourceType}", + }; + if (asm.ModifiedOn is { } modified) + lines.Add($"Modified : {modified:yyyy-MM-dd HH:mm}"); + + lines.Add(""); + lines.Add($"Types ({types.Count}):"); + foreach (var t in types.OrderBy(t => t.TypeName, StringComparer.OrdinalIgnoreCase)) + lines.Add($" {t.TypeName} [{t.Kind}]"); + + lines.Add(""); + lines.Add($"Steps ({steps.Count}):"); + foreach (var s in steps + .OrderBy(s => s.PluginTypeName, StringComparer.OrdinalIgnoreCase) + .ThenBy(s => s.Rank)) + { + var state = s.Enabled ? "Enabled" : "Disabled"; + var entity = s.PrimaryEntity ?? "(none)"; + var imageCount = imagesByStep.TryGetValue(s.Id, out var c) ? c : 0; + var imagePart = imageCount > 0 ? $", {imageCount} image(s)" : ""; + lines.Add($" [{state}] {s.Message} of {entity} ({s.Stage}{imagePart}) — {s.Name}"); + } + + return lines; + } + + // Text-renderer callback invoked by OutputFormatter.WriteData — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintDetail( + PluginAssemblyRecord asm, + IReadOnlyList types, + IReadOnlyList steps, + IReadOnlyList images) + { + foreach (var line in BuildDetailLines(asm, types, steps, images)) + OutputWriter.WriteLine(line); + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs index 6ad89f85..945cf595 100644 --- a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepCliCommand.cs @@ -4,8 +4,15 @@ namespace TALXIS.CLI.Features.Environment.Plugin.Steps; [CliCommand( Name = "step", - Description = "List plugin processing steps registered in the connected environment.", - Children = new[] { typeof(PluginStepListCliCommand) }, + Description = "List, inspect, and toggle plugin processing steps registered in the connected environment.", + Children = new[] + { + typeof(PluginStepListCliCommand), + typeof(PluginStepShowCliCommand), + typeof(PluginStepEnableCliCommand), + typeof(PluginStepDisableCliCommand), + typeof(PluginStepEnableAllCliCommand), + }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class PluginStepCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepDisableCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepDisableCliCommand.cs new file mode 100644 index 00000000..f10dc085 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepDisableCliCommand.cs @@ -0,0 +1,48 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +[CliIdempotent] +[CliCommand( + Name = "disable", + Description = "Disable (deactivate) a plugin processing step so it stops firing. Accepts a step GUID or a step name (exact or unique substring)." +)] +public class PluginStepDisableCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginStepDisableCliCommand)); + + [CliArgument(Name = "step", Description = "Step id (GUID) or name (exact or unique substring).")] + public string Step { get; set; } = null!; + + [CliOption(Name = "--assembly", Description = "Narrow the search to steps whose owning assembly name contains this substring (helps disambiguate names).", Required = false)] + public string? Assembly { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var rows = await service.ListStepsAsync(Profile, Assembly, CancellationToken.None).ConfigureAwait(false); + + var resolution = PluginStepResolver.Resolve(rows, Step); + if (resolution.Step is null) + { + Logger.LogError("{Error}", resolution.Error); + return ExitValidationError; + } + + var step = resolution.Step; + if (!step.Enabled) + { + OutputFormatter.WriteResult("succeeded", $"Step '{step.Name}' is already disabled.", step.Id.ToString()); + return ExitSuccess; + } + + await service.SetStepStateAsync(Profile, step.Id, enabled: false, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteResult("succeeded", $"Disabled step '{step.Name}'.", step.Id.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableAllCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableAllCliCommand.cs new file mode 100644 index 00000000..3f6b6e34 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableAllCliCommand.cs @@ -0,0 +1,51 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +[CliIdempotent] +[CliCommand( + Name = "enable-all", + Description = "Enable every disabled plugin processing step for an assembly in one go. Useful right after importing a solution whose steps came in disabled. Already-enabled steps are left untouched." +)] +public class PluginStepEnableAllCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginStepEnableAllCliCommand)); + + [CliOption(Name = "--assembly", Description = "Assembly name (substring) whose steps should be enabled.", Required = true)] + public string Assembly { get; set; } = null!; + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var rows = await service.ListStepsAsync(Profile, Assembly, CancellationToken.None).ConfigureAwait(false); + + if (rows.Count == 0) + { + OutputFormatter.WriteResult("succeeded", $"No plugin steps found for assembly matching '{Assembly}'."); + return ExitSuccess; + } + + var toEnable = SelectDisabledStepIds(rows); + if (toEnable.Count == 0) + { + OutputFormatter.WriteResult("succeeded", $"All {rows.Count} step(s) for '{Assembly}' are already enabled."); + return ExitSuccess; + } + + var count = await service.SetStepsStateAsync(Profile, toEnable, enabled: true, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteResult("succeeded", $"Enabled {count} step(s) for assembly matching '{Assembly}'."); + return ExitSuccess; + } + + /// + /// Selects the ids of steps that are currently disabled. Kept pure and + /// public so the "don't touch already-enabled steps" rule is unit-testable. + /// + public static IReadOnlyList SelectDisabledStepIds(IReadOnlyList rows) + => rows.Where(r => !r.Enabled).Select(r => r.Id).ToList(); +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableCliCommand.cs new file mode 100644 index 00000000..9b6fe34f --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepEnableCliCommand.cs @@ -0,0 +1,48 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +[CliIdempotent] +[CliCommand( + Name = "enable", + Description = "Enable (activate) a plugin processing step so it fires again. Accepts a step GUID or a step name (exact or unique substring). Common after importing a solution whose steps came in disabled." +)] +public class PluginStepEnableCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginStepEnableCliCommand)); + + [CliArgument(Name = "step", Description = "Step id (GUID) or name (exact or unique substring).")] + public string Step { get; set; } = null!; + + [CliOption(Name = "--assembly", Description = "Narrow the search to steps whose owning assembly name contains this substring (helps disambiguate names).", Required = false)] + public string? Assembly { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var rows = await service.ListStepsAsync(Profile, Assembly, CancellationToken.None).ConfigureAwait(false); + + var resolution = PluginStepResolver.Resolve(rows, Step); + if (resolution.Step is null) + { + Logger.LogError("{Error}", resolution.Error); + return ExitValidationError; + } + + var step = resolution.Step; + if (step.Enabled) + { + OutputFormatter.WriteResult("succeeded", $"Step '{step.Name}' is already enabled.", step.Id.ToString()); + return ExitSuccess; + } + + await service.SetStepStateAsync(Profile, step.Id, enabled: true, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteResult("succeeded", $"Enabled step '{step.Name}'.", step.Id.ToString()); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs index 49fc4fca..b955d5f8 100644 --- a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepListCliCommand.cs @@ -19,12 +19,29 @@ public class PluginStepListCliCommand : ProfiledCliCommand [CliOption(Name = "--assembly", Description = "Filter to steps whose owning assembly name contains this substring.", Required = false)] public string? Assembly { get; set; } + [CliOption(Name = "--entity", Description = "Filter to steps whose primary entity name contains this substring.", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--stage", Description = "Filter by execution stage: pre, post, prevalidation, preoperation, or postoperation.", Required = false)] + public string? Stage { get; set; } + + [CliOption(Name = "--disabled", Description = "Show only disabled steps.", Required = false)] + public bool DisabledOnly { get; set; } + protected override async Task ExecuteAsync() { + if (!PluginStepQuery.TryParseStageFilter(Stage, out var stages, out var stageError)) + { + Logger.LogError("{Error}", stageError); + return ExitValidationError; + } + var service = TxcServices.Get(); var rows = await service.ListStepsAsync(Profile, Assembly, CancellationToken.None).ConfigureAwait(false); - OutputFormatter.WriteList(rows, PrintTable); + var filtered = PluginStepQuery.Filter(rows, Entity, stages, DisabledOnly); + + OutputFormatter.WriteList(filtered, PrintTable); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepQuery.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepQuery.cs new file mode 100644 index 00000000..2ef68c10 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepQuery.cs @@ -0,0 +1,86 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +/// +/// Pure, client-side filtering helpers for plugin processing steps. +/// The assembly filter is applied server-side by the query; entity, stage, +/// and disabled-only filters are applied here so they stay easy to unit test +/// and behave identically across the CLI and MCP surfaces. +/// +public static class PluginStepQuery +{ + /// + /// Parses a --stage value into the set of + /// codes it selects. An empty/whitespace value means "no stage filter" and + /// yields an empty set with a true result. + /// + public static bool TryParseStageFilter( + string? value, + out IReadOnlyCollection stages, + out string? error) + { + if (string.IsNullOrWhiteSpace(value)) + { + stages = Array.Empty(); + error = null; + return true; + } + + switch (value.Trim().ToLowerInvariant()) + { + case "pre": + stages = new[] { PluginStage.PreValidation, PluginStage.PreOperation }; + break; + case "post": + stages = new[] { PluginStage.PostOperation, PluginStage.PostOperationDeprecated }; + break; + case "prevalidation": + stages = new[] { PluginStage.PreValidation }; + break; + case "preoperation": + stages = new[] { PluginStage.PreOperation }; + break; + case "postoperation": + stages = new[] { PluginStage.PostOperation }; + break; + default: + stages = Array.Empty(); + error = $"Invalid --stage value '{value}'. Expected: pre, post, prevalidation, preoperation, or postoperation."; + return false; + } + + error = null; + return true; + } + + /// + /// Applies the entity, stage, and disabled-only filters to an already + /// fetched set of steps. Null/empty criteria are no-ops. + /// + public static IReadOnlyList Filter( + IReadOnlyList rows, + string? entityContains, + IReadOnlyCollection? stages, + bool disabledOnly) + { + IEnumerable result = rows; + + if (!string.IsNullOrWhiteSpace(entityContains)) + { + result = result.Where(r => r.PrimaryEntity is not null + && r.PrimaryEntity.Contains(entityContains, StringComparison.OrdinalIgnoreCase)); + } + + if (stages is { Count: > 0 }) + { + var set = new HashSet(stages); + result = result.Where(r => set.Contains(r.Stage)); + } + + if (disabledOnly) + result = result.Where(r => !r.Enabled); + + return result.ToList(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepResolver.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepResolver.cs new file mode 100644 index 00000000..89306185 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepResolver.cs @@ -0,0 +1,51 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +/// +/// Resolves a single plugin processing step from a user-supplied token that +/// may be either a step GUID or a step name. Name matching prefers an exact +/// (case-insensitive) hit and falls back to a unique substring match. A token +/// that matches nothing, or matches more than one step, yields an error +/// message instead of a step so callers can fail cleanly. +/// +public static class PluginStepResolver +{ + public sealed record Resolution(PluginStepRecord? Step, string? Error); + + public static Resolution Resolve(IReadOnlyList rows, string token) + { + if (string.IsNullOrWhiteSpace(token)) + return new Resolution(null, "No step identifier supplied. Pass a step GUID or name."); + + token = token.Trim(); + + if (Guid.TryParse(token, out var id)) + { + var byId = rows.FirstOrDefault(r => r.Id == id); + return byId is not null + ? new Resolution(byId, null) + : new Resolution(null, $"No plugin step found with id '{id}'."); + } + + var exact = rows.Where(r => string.Equals(r.Name, token, StringComparison.OrdinalIgnoreCase)).ToList(); + if (exact.Count == 1) + return new Resolution(exact[0], null); + if (exact.Count > 1) + return new Resolution(null, AmbiguousError(token, exact)); + + var partial = rows.Where(r => r.Name.Contains(token, StringComparison.OrdinalIgnoreCase)).ToList(); + if (partial.Count == 1) + return new Resolution(partial[0], null); + if (partial.Count > 1) + return new Resolution(null, AmbiguousError(token, partial)); + + return new Resolution(null, $"No plugin step found matching '{token}'."); + } + + private static string AmbiguousError(string token, IReadOnlyList matches) + { + var names = string.Join("\n ", matches.Take(10).Select(m => $"{m.Id} {m.Name}")); + return $"'{token}' is ambiguous; {matches.Count} steps match. Use the step id instead:\n {names}"; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepShowCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepShowCliCommand.cs new file mode 100644 index 00000000..4959e747 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Steps/PluginStepShowCliCommand.cs @@ -0,0 +1,82 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Steps; + +[CliReadOnly] +[CliCommand( + Name = "show", + Description = "Show the full configuration of a single plugin processing step — message, entity, stage, mode, rank, state, filtering attributes, and configuration. Accepts a step GUID or a step name (exact or unique substring)." +)] +public class PluginStepShowCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginStepShowCliCommand)); + + [CliArgument(Name = "step", Description = "Step id (GUID) or name (exact or unique substring).")] + public string Step { get; set; } = null!; + + [CliOption(Name = "--assembly", Description = "Narrow the search to steps whose owning assembly name contains this substring (helps disambiguate names).", Required = false)] + public string? Assembly { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var rows = await service.ListStepsAsync(Profile, Assembly, CancellationToken.None).ConfigureAwait(false); + + var resolution = PluginStepResolver.Resolve(rows, Step); + if (resolution.Step is null) + { + Logger.LogError("{Error}", resolution.Error); + return ExitValidationError; + } + + OutputFormatter.WriteData(resolution.Step, PrintDetail); + return ExitSuccess; + } + + /// + /// Builds the human-readable detail lines for a step. Kept pure and public + /// so the rendering is unit-testable without a live environment. + /// + public static IReadOnlyList BuildDetailLines(PluginStepRecord step) + { + var mode = step.Mode == PluginExecutionMode.Synchronous ? "Sync" : "Async"; + var state = step.Enabled ? "Enabled" : "Disabled"; + + var lines = new List + { + $"Name : {step.Name}", + $"Id : {step.Id}", + $"State : {state}", + $"Message : {step.Message}", + $"Entity : {step.PrimaryEntity ?? "(none)"}", + $"Stage : {step.Stage}", + $"Mode : {mode}", + $"Rank : {step.Rank}", + $"Type : {step.PluginTypeName}", + $"Assembly : {step.AssemblyName}" + (step.AssemblyVersion is { } v ? $" ({v})" : ""), + }; + + if (!string.IsNullOrWhiteSpace(step.Description)) + lines.Add($"Description : {step.Description}"); + if (!string.IsNullOrWhiteSpace(step.FilteringAttributes)) + lines.Add($"Filtering Attributes : {step.FilteringAttributes}"); + if (!string.IsNullOrWhiteSpace(step.Configuration)) + lines.Add($"Configuration : {step.Configuration}"); + + return lines; + } + + // Text-renderer callback invoked by OutputFormatter.WriteData — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintDetail(PluginStepRecord step) + { + foreach (var line in BuildDetailLines(step)) + OutputWriter.WriteLine(line); + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs index ad202986..6156c346 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginInventoryManager.cs @@ -95,6 +95,74 @@ public static async Task> ListStepsAsync( return rows.Select(MapStep).ToList(); } + public static async Task> ListStepImagesAsync( + IOrganizationServiceAsync2 service, + string? assemblyContains, + CancellationToken ct) + { + var query = new QueryExpression("sdkmessageprocessingstepimage") + { + ColumnSet = new ColumnSet( + "sdkmessageprocessingstepimageid", "sdkmessageprocessingstepid", + "imagetype", "entityalias", "attributes"), + }; + + var stepLink = query.AddLink("sdkmessageprocessingstep", "sdkmessageprocessingstepid", "sdkmessageprocessingstepid", JoinOperator.Inner); + stepLink.EntityAlias = "step"; + var typeLink = stepLink.AddLink("plugintype", "plugintypeid", "plugintypeid", JoinOperator.Inner); + var assemblyLink = typeLink.AddLink("pluginassembly", "pluginassemblyid", "pluginassemblyid", JoinOperator.Inner); + if (!string.IsNullOrWhiteSpace(assemblyContains)) + assemblyLink.LinkCriteria.AddCondition("name", ConditionOperator.Like, $"%{assemblyContains}%"); + + var rows = await RetrieveAllAsync(service, query, ct).ConfigureAwait(false); + return rows.Select(MapImage).ToList(); + } + + private static PluginStepImageRecord MapImage(Entity e) + { + var stepRef = e.GetAttributeValue("sdkmessageprocessingstepid"); + var imageType = e.GetAttributeValue("imagetype")?.Value ?? 0; + return new PluginStepImageRecord( + Id: e.Id, + StepId: stepRef?.Id ?? Guid.Empty, + ImageType: imageType switch { 0 => "PreImage", 1 => "PostImage", 2 => "Both", _ => imageType.ToString() }, + EntityAlias: e.GetAttributeValue("entityalias"), + Attributes: e.GetAttributeValue("attributes")); + } + + /// + /// Maps a desired enabled/disabled state to the Dataverse + /// statecode/statuscode pair for a SdkMessageProcessingStep. + /// Enabled = (0, 1); Disabled = (1, 2). + /// + public static (int StateCode, int StatusCode) StepStateCodes(bool enabled) + => enabled ? (0, 1) : (1, 2); + + public static async Task SetStepStateAsync( + IOrganizationServiceAsync2 service, Guid stepId, bool enabled, CancellationToken ct) + { + var (state, status) = StepStateCodes(enabled); + var entity = new Entity("sdkmessageprocessingstep", stepId) + { + ["statecode"] = new OptionSetValue(state), + ["statuscode"] = new OptionSetValue(status), + }; + await service.UpdateAsync(entity, ct).ConfigureAwait(false); + } + + public static async Task SetStepsStateAsync( + IOrganizationServiceAsync2 service, IReadOnlyCollection stepIds, bool enabled, CancellationToken ct) + { + var count = 0; + foreach (var id in stepIds) + { + ct.ThrowIfCancellationRequested(); + await SetStepStateAsync(service, id, enabled, ct).ConfigureAwait(false); + count++; + } + return count; + } + private static async Task> RetrieveAllAsync( IOrganizationServiceAsync2 service, QueryExpression query, CancellationToken ct) { diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs index 5629e83b..1818e4d7 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginInventoryService.cs @@ -26,4 +26,25 @@ public async Task> ListStepsAsync( using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); return await PluginInventoryManager.ListStepsAsync(conn.Client, assemblyContains, ct).ConfigureAwait(false); } + + public async Task> ListStepImagesAsync( + string? profileName, string? assemblyContains, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await PluginInventoryManager.ListStepImagesAsync(conn.Client, assemblyContains, ct).ConfigureAwait(false); + } + + public async Task SetStepStateAsync( + string? profileName, Guid stepId, bool enabled, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + await PluginInventoryManager.SetStepStateAsync(conn.Client, stepId, enabled, ct).ConfigureAwait(false); + } + + public async Task SetStepsStateAsync( + string? profileName, IReadOnlyCollection stepIds, bool enabled, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await PluginInventoryManager.SetStepsStateAsync(conn.Client, stepIds, enabled, ct).ConfigureAwait(false); + } } diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyResolverTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyResolverTests.cs new file mode 100644 index 00000000..0e11f161 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyResolverTests.cs @@ -0,0 +1,70 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Plugin.Assemblies; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginAssemblyResolverTests +{ + private static PluginAssemblyRecord Asm(Guid id, string name) + => new(id, name, "1.0.0.0", null, null, PluginIsolationMode.Sandbox, + PluginAssemblySourceType.Database, null, null); + + [Fact] + public void Resolve_ByGuid_Matches() + { + var id = Guid.NewGuid(); + var rows = new[] { Asm(id, "Alpha"), Asm(Guid.NewGuid(), "Beta") }; + + var result = PluginAssemblyResolver.Resolve(rows, id.ToString()); + + Assert.Null(result.Error); + Assert.Equal(id, result.Assembly!.Id); + } + + [Fact] + public void Resolve_ByExactName_IsCaseInsensitive() + { + var id = Guid.NewGuid(); + var rows = new[] { Asm(id, "PluginsWarehouse"), Asm(Guid.NewGuid(), "Other") }; + + var result = PluginAssemblyResolver.Resolve(rows, "pluginswarehouse"); + + Assert.Null(result.Error); + Assert.Equal(id, result.Assembly!.Id); + } + + [Fact] + public void Resolve_BySubstring_WhenUnique() + { + var id = Guid.NewGuid(); + var rows = new[] { Asm(id, "PluginsWarehouse"), Asm(Guid.NewGuid(), "Other") }; + + var result = PluginAssemblyResolver.Resolve(rows, "ware"); + + Assert.Null(result.Error); + Assert.Equal(id, result.Assembly!.Id); + } + + [Fact] + public void Resolve_Ambiguous_ReturnsError() + { + var rows = new[] { Asm(Guid.NewGuid(), "Plugins.A"), Asm(Guid.NewGuid(), "Plugins.B") }; + + var result = PluginAssemblyResolver.Resolve(rows, "Plugins"); + + Assert.Null(result.Assembly); + Assert.NotNull(result.Error); + } + + [Fact] + public void Resolve_NotFound_ReturnsError() + { + var rows = new[] { Asm(Guid.NewGuid(), "Alpha") }; + + var result = PluginAssemblyResolver.Resolve(rows, "zzz"); + + Assert.Null(result.Assembly); + Assert.NotNull(result.Error); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyShowCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyShowCliCommandTests.cs new file mode 100644 index 00000000..2aee2a7a --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginAssemblyShowCliCommandTests.cs @@ -0,0 +1,79 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Plugin.Assemblies; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginAssemblyShowCliCommandTests +{ + private static readonly Guid AsmId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + private static PluginAssemblyRecord Assembly() + => new( + Id: AsmId, + Name: "PluginsWarehouse", + Version: "1.2.3.4", + Culture: "neutral", + PublicKeyToken: null, + IsolationMode: PluginIsolationMode.Sandbox, + SourceType: PluginAssemblySourceType.Database, + Description: null, + ModifiedOn: null); + + private static PluginTypeRecord Type(string name) + => new(Guid.NewGuid(), name, null, PluginKind.Plugin, null, null, AsmId, "PluginsWarehouse", "1.2.3.4"); + + private static PluginStepRecord Step(Guid id, string name, bool enabled) + => new(id, name, null, "Create", "account", PluginStage.PostOperation, + PluginExecutionMode.Synchronous, 1, enabled, null, null, + Guid.NewGuid(), "Plugins.Foo", AsmId, "PluginsWarehouse", "1.2.3.4"); + + private static PluginStepImageRecord Image(Guid stepId) + => new(Guid.NewGuid(), stepId, "PostImage", "PostImage", "name"); + + [Fact] + public void BuildDetailLines_RendersAssemblyHeader() + { + var text = string.Join("\n", PluginAssemblyShowCliCommand.BuildDetailLines( + Assembly(), Array.Empty(), Array.Empty(), Array.Empty())); + + Assert.Contains("PluginsWarehouse", text); + Assert.Contains("1.2.3.4", text); + Assert.Contains(AsmId.ToString(), text); + Assert.Contains("Sandbox", text); + } + + [Fact] + public void BuildDetailLines_ListsTypesAndStepsWithCounts() + { + var types = new[] { Type("Plugins.Foo"), Type("Plugins.Bar") }; + var steps = new[] + { + Step(Guid.NewGuid(), "Foo: Create of account", enabled: true), + Step(Guid.NewGuid(), "Bar: Update of contact", enabled: false), + }; + + var text = string.Join("\n", PluginAssemblyShowCliCommand.BuildDetailLines( + Assembly(), types, steps, Array.Empty())); + + Assert.Contains("Plugins.Foo", text); + Assert.Contains("Plugins.Bar", text); + Assert.Contains("Foo: Create of account", text); + Assert.Contains("Bar: Update of contact", text); + // counts surfaced somewhere + Assert.Contains("2", text); + } + + [Fact] + public void BuildDetailLines_ShowsImageCountPerStep() + { + var stepId = Guid.NewGuid(); + var steps = new[] { Step(stepId, "Foo: Create of account", enabled: true) }; + var images = new[] { Image(stepId), Image(stepId) }; + + var text = string.Join("\n", PluginAssemblyShowCliCommand.BuildDetailLines( + Assembly(), Array.Empty(), steps, images)); + + Assert.Contains("image", text, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginInventoryManagerTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginInventoryManagerTests.cs new file mode 100644 index 00000000..8cc1fb55 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginInventoryManagerTests.cs @@ -0,0 +1,18 @@ +using TALXIS.CLI.Platform.Dataverse.Application.Sdk; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginInventoryManagerTests +{ + [Theory] + [InlineData(true, 0, 1)] // enabled -> statecode=0 (Enabled), statuscode=1 + [InlineData(false, 1, 2)] // disabled -> statecode=1 (Disabled), statuscode=2 + public void StepStateCodes_MapsEnabledToStateAndStatus(bool enabled, int expectedState, int expectedStatus) + { + var (state, status) = PluginInventoryManager.StepStateCodes(enabled); + + Assert.Equal(expectedState, state); + Assert.Equal(expectedStatus, status); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepEnableAllCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepEnableAllCliCommandTests.cs new file mode 100644 index 00000000..009bb3b6 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepEnableAllCliCommandTests.cs @@ -0,0 +1,54 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Plugin.Steps; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginStepEnableAllCliCommandTests +{ + private static PluginStepRecord Step(Guid id, bool enabled) + => new( + Id: id, + Name: "Step", + Description: null, + Message: "Create", + PrimaryEntity: "account", + Stage: PluginStage.PostOperation, + Mode: PluginExecutionMode.Synchronous, + Rank: 1, + Enabled: enabled, + FilteringAttributes: null, + Configuration: null, + PluginTypeId: Guid.NewGuid(), + PluginTypeName: "Some.Plugin", + AssemblyId: Guid.NewGuid(), + AssemblyName: "Asm", + AssemblyVersion: "1.0.0.0"); + + [Fact] + public void SelectDisabledStepIds_ReturnsOnlyDisabled() + { + var disabled1 = Guid.NewGuid(); + var disabled2 = Guid.NewGuid(); + var rows = new[] + { + Step(Guid.NewGuid(), enabled: true), + Step(disabled1, enabled: false), + Step(disabled2, enabled: false), + }; + + var ids = PluginStepEnableAllCliCommand.SelectDisabledStepIds(rows); + + Assert.Equal(new[] { disabled1, disabled2 }.OrderBy(x => x), ids.OrderBy(x => x)); + } + + [Fact] + public void SelectDisabledStepIds_AllEnabled_ReturnsEmpty() + { + var rows = new[] { Step(Guid.NewGuid(), enabled: true) }; + + var ids = PluginStepEnableAllCliCommand.SelectDisabledStepIds(rows); + + Assert.Empty(ids); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepQueryTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepQueryTests.cs new file mode 100644 index 00000000..fd58c9b7 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepQueryTests.cs @@ -0,0 +1,146 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Plugin.Steps; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginStepQueryTests +{ + private static PluginStepRecord Step( + string name = "Step", + string? entity = "account", + PluginStage stage = PluginStage.PostOperation, + bool enabled = true) + => new( + Id: Guid.NewGuid(), + Name: name, + Description: null, + Message: "Create", + PrimaryEntity: entity, + Stage: stage, + Mode: PluginExecutionMode.Synchronous, + Rank: 1, + Enabled: enabled, + FilteringAttributes: null, + Configuration: null, + PluginTypeId: Guid.NewGuid(), + PluginTypeName: "Some.Plugin", + AssemblyId: Guid.NewGuid(), + AssemblyName: "Asm", + AssemblyVersion: "1.0.0.0"); + + [Theory] + [InlineData("pre", new[] { PluginStage.PreValidation, PluginStage.PreOperation })] + [InlineData("PRE", new[] { PluginStage.PreValidation, PluginStage.PreOperation })] + [InlineData("post", new[] { PluginStage.PostOperation, PluginStage.PostOperationDeprecated })] + [InlineData("prevalidation", new[] { PluginStage.PreValidation })] + [InlineData("preoperation", new[] { PluginStage.PreOperation })] + [InlineData("postoperation", new[] { PluginStage.PostOperation })] + public void TryParseStageFilter_MapsKnownValues(string value, PluginStage[] expected) + { + var ok = PluginStepQuery.TryParseStageFilter(value, out var stages, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal(expected.OrderBy(s => s), stages.OrderBy(s => s)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryParseStageFilter_EmptyMeansNoFilter(string? value) + { + var ok = PluginStepQuery.TryParseStageFilter(value, out var stages, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Empty(stages); + } + + [Fact] + public void TryParseStageFilter_InvalidReturnsError() + { + var ok = PluginStepQuery.TryParseStageFilter("middle", out var stages, out var error); + + Assert.False(ok); + Assert.Empty(stages); + Assert.NotNull(error); + Assert.Contains("middle", error); + } + + [Fact] + public void Filter_NoCriteria_ReturnsAll() + { + var rows = new[] { Step("a"), Step("b") }; + + var result = PluginStepQuery.Filter(rows, entityContains: null, stages: null, disabledOnly: false); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void Filter_ByEntity_IsCaseInsensitiveSubstring() + { + var rows = new[] + { + Step("a", entity: "account"), + Step("b", entity: "contact"), + Step("c", entity: null), + }; + + var result = PluginStepQuery.Filter(rows, entityContains: "ACC", stages: null, disabledOnly: false); + + Assert.Single(result); + Assert.Equal("a", result[0].Name); + } + + [Fact] + public void Filter_ByStage_KeepsOnlyMatchingStages() + { + var rows = new[] + { + Step("pre", stage: PluginStage.PreOperation), + Step("post", stage: PluginStage.PostOperation), + }; + + var stages = new[] { PluginStage.PreValidation, PluginStage.PreOperation }; + var result = PluginStepQuery.Filter(rows, entityContains: null, stages: stages, disabledOnly: false); + + Assert.Single(result); + Assert.Equal("pre", result[0].Name); + } + + [Fact] + public void Filter_DisabledOnly_DropsEnabled() + { + var rows = new[] + { + Step("on", enabled: true), + Step("off", enabled: false), + }; + + var result = PluginStepQuery.Filter(rows, entityContains: null, stages: null, disabledOnly: true); + + Assert.Single(result); + Assert.Equal("off", result[0].Name); + } + + [Fact] + public void Filter_CombinesCriteria() + { + var rows = new[] + { + Step("keep", entity: "account", stage: PluginStage.PreOperation, enabled: false), + Step("wrongEntity", entity: "contact", stage: PluginStage.PreOperation, enabled: false), + Step("wrongStage", entity: "account", stage: PluginStage.PostOperation, enabled: false), + Step("enabled", entity: "account", stage: PluginStage.PreOperation, enabled: true), + }; + + var stages = new[] { PluginStage.PreValidation, PluginStage.PreOperation }; + var result = PluginStepQuery.Filter(rows, entityContains: "account", stages: stages, disabledOnly: true); + + Assert.Single(result); + Assert.Equal("keep", result[0].Name); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepResolverTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepResolverTests.cs new file mode 100644 index 00000000..0f2419bc --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepResolverTests.cs @@ -0,0 +1,129 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Plugin.Steps; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginStepResolverTests +{ + private static PluginStepRecord Step(Guid id, string name) + => new( + Id: id, + Name: name, + Description: null, + Message: "Create", + PrimaryEntity: "account", + Stage: PluginStage.PostOperation, + Mode: PluginExecutionMode.Synchronous, + Rank: 1, + Enabled: true, + FilteringAttributes: null, + Configuration: null, + PluginTypeId: Guid.NewGuid(), + PluginTypeName: "Some.Plugin", + AssemblyId: Guid.NewGuid(), + AssemblyName: "Asm", + AssemblyVersion: "1.0.0.0"); + + [Fact] + public void Resolve_ByGuid_MatchesById() + { + var id = Guid.NewGuid(); + var rows = new[] { Step(id, "Alpha"), Step(Guid.NewGuid(), "Beta") }; + + var result = PluginStepResolver.Resolve(rows, id.ToString()); + + Assert.Null(result.Error); + Assert.NotNull(result.Step); + Assert.Equal(id, result.Step!.Id); + } + + [Fact] + public void Resolve_ByGuid_NotFound_ReturnsError() + { + var rows = new[] { Step(Guid.NewGuid(), "Alpha") }; + + var result = PluginStepResolver.Resolve(rows, Guid.NewGuid().ToString()); + + Assert.Null(result.Step); + Assert.NotNull(result.Error); + } + + [Fact] + public void Resolve_ByExactName_IsCaseInsensitive() + { + var id = Guid.NewGuid(); + var rows = new[] { Step(id, "Plugins.Foo: Create of account"), Step(Guid.NewGuid(), "Other") }; + + var result = PluginStepResolver.Resolve(rows, "plugins.foo: create of account"); + + Assert.Null(result.Error); + Assert.Equal(id, result.Step!.Id); + } + + [Fact] + public void Resolve_BySubstring_WhenNoExactMatch() + { + var id = Guid.NewGuid(); + var rows = new[] { Step(id, "Plugins.Foo: Create of account"), Step(Guid.NewGuid(), "Bar: Update") }; + + var result = PluginStepResolver.Resolve(rows, "Foo"); + + Assert.Null(result.Error); + Assert.Equal(id, result.Step!.Id); + } + + [Fact] + public void Resolve_ExactMatch_WinsOverSubstring() + { + var exactId = Guid.NewGuid(); + var rows = new[] + { + Step(exactId, "Create"), + Step(Guid.NewGuid(), "Create of account"), + }; + + var result = PluginStepResolver.Resolve(rows, "Create"); + + Assert.Null(result.Error); + Assert.Equal(exactId, result.Step!.Id); + } + + [Fact] + public void Resolve_AmbiguousSubstring_ReturnsError() + { + var rows = new[] + { + Step(Guid.NewGuid(), "Foo: Create"), + Step(Guid.NewGuid(), "Foo: Update"), + }; + + var result = PluginStepResolver.Resolve(rows, "Foo"); + + Assert.Null(result.Step); + Assert.NotNull(result.Error); + Assert.Contains("ambiguous", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Resolve_NotFound_ReturnsError() + { + var rows = new[] { Step(Guid.NewGuid(), "Alpha") }; + + var result = PluginStepResolver.Resolve(rows, "nothing-here"); + + Assert.Null(result.Step); + Assert.NotNull(result.Error); + } + + [Fact] + public void Resolve_EmptyToken_ReturnsError() + { + var rows = new[] { Step(Guid.NewGuid(), "Alpha") }; + + var result = PluginStepResolver.Resolve(rows, " "); + + Assert.Null(result.Step); + Assert.NotNull(result.Error); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepShowCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepShowCliCommandTests.cs new file mode 100644 index 00000000..86db0f53 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginStepShowCliCommandTests.cs @@ -0,0 +1,77 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Plugin.Steps; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginStepShowCliCommandTests +{ + private static PluginStepRecord Step( + bool enabled = false, + string? filtering = "name,statecode", + string? configuration = "secure-config") + => new( + Id: Guid.Parse("11111111-1111-1111-1111-111111111111"), + Name: "Plugins.Foo: Create of account", + Description: "Demo step", + Message: "Create", + PrimaryEntity: "account", + Stage: PluginStage.PostOperation, + Mode: PluginExecutionMode.Synchronous, + Rank: 5, + Enabled: enabled, + FilteringAttributes: filtering, + Configuration: configuration, + PluginTypeId: Guid.NewGuid(), + PluginTypeName: "Plugins.Foo", + AssemblyId: Guid.NewGuid(), + AssemblyName: "PluginsWarehouse", + AssemblyVersion: "1.2.3.4"); + + [Fact] + public void BuildDetailLines_IncludesCoreFields() + { + var text = string.Join("\n", PluginStepShowCliCommand.BuildDetailLines(Step())); + + Assert.Contains("Plugins.Foo: Create of account", text); + Assert.Contains("11111111-1111-1111-1111-111111111111", text); + Assert.Contains("Create", text); + Assert.Contains("account", text); + Assert.Contains("PostOperation", text); + Assert.Contains("Sync", text); + Assert.Contains("5", text); + Assert.Contains("PluginsWarehouse", text); + Assert.Contains("Plugins.Foo", text); + } + + [Fact] + public void BuildDetailLines_ShowsDisabledState() + { + var text = string.Join("\n", PluginStepShowCliCommand.BuildDetailLines(Step(enabled: false))); + Assert.Contains("Disabled", text); + } + + [Fact] + public void BuildDetailLines_ShowsEnabledState() + { + var text = string.Join("\n", PluginStepShowCliCommand.BuildDetailLines(Step(enabled: true))); + Assert.Contains("Enabled", text); + } + + [Fact] + public void BuildDetailLines_IncludesFilteringAndConfigWhenPresent() + { + var text = string.Join("\n", PluginStepShowCliCommand.BuildDetailLines(Step())); + Assert.Contains("name,statecode", text); + Assert.Contains("secure-config", text); + } + + [Fact] + public void BuildDetailLines_OmitsFilteringAndConfigWhenAbsent() + { + var lines = PluginStepShowCliCommand.BuildDetailLines(Step(filtering: null, configuration: null)); + var text = string.Join("\n", lines); + Assert.DoesNotContain("Filtering Attributes", text); + Assert.DoesNotContain("Configuration", text); + } +} From 3beebfb1ee62d4bf0bc85fe0f2ba67c84a00d5c6 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Thu, 4 Jun 2026 15:24:34 +0200 Subject: [PATCH 3/3] feat: env plugin profile (plugin trace log level toggle) --- .../Dataverse/IPluginTraceService.cs | 25 +++++++++ .../Plugin/PluginCliCommand.cs | 1 + .../Plugin/Profile/PluginProfileCliCommand.cs | 22 ++++++++ .../Profile/PluginProfileDisableCliCommand.cs | 33 ++++++++++++ .../Profile/PluginProfileEnableCliCommand.cs | 42 +++++++++++++++ .../Profile/PluginProfileStatusCliCommand.cs | 36 +++++++++++++ .../Plugin/Profile/PluginTraceLevelParser.cs | 50 ++++++++++++++++++ ...eApplicationServiceCollectionExtensions.cs | 1 + .../Sdk/PluginTraceManager.cs | 51 +++++++++++++++++++ .../Services/DataversePluginTraceService.cs | 20 ++++++++ .../Plugin/PluginTraceLevelParserTests.cs | 47 +++++++++++++++++ 11 files changed, 328 insertions(+) create mode 100644 src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginTraceService.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileDisableCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileEnableCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileStatusCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginTraceLevelParser.cs create mode 100644 src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginTraceManager.cs create mode 100644 src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginTraceService.cs create mode 100644 tests/TALXIS.CLI.Tests/Environment/Plugin/PluginTraceLevelParserTests.cs diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginTraceService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginTraceService.cs new file mode 100644 index 00000000..9af4dac5 --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginTraceService.cs @@ -0,0 +1,25 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +/// +/// Organization-wide plugin trace log level (the plugintracelogsetting +/// attribute on the organization entity). Controls whether plugin +/// execution is written to the plugintracelog table. +/// +public enum PluginTraceLevel +{ + Off = 0, + Exception = 1, + All = 2, +} + +public sealed record PluginTraceSetting( + Guid OrganizationId, + string? OrganizationName, + PluginTraceLevel Level); + +public interface IPluginTraceService +{ + Task GetSettingAsync(string? profileName, CancellationToken ct); + + Task SetSettingAsync(string? profileName, PluginTraceLevel level, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs index 02f19424..532c7c06 100644 --- a/src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs @@ -10,6 +10,7 @@ namespace TALXIS.CLI.Features.Environment.Plugin; typeof(Assemblies.PluginAssemblyCliCommand), typeof(Types.PluginTypeCliCommand), typeof(Steps.PluginStepCliCommand), + typeof(Profile.PluginProfileCliCommand), }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileCliCommand.cs new file mode 100644 index 00000000..44d43110 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileCliCommand.cs @@ -0,0 +1,22 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Plugin.Profile; + +[CliCommand( + Name = "profile", + Description = "Control the organization-wide plugin trace log level (organization.plugintracelogsetting). When enabled, plugin execution is written to the plugintracelog table for later inspection.", + Children = new[] + { + typeof(PluginProfileStatusCliCommand), + typeof(PluginProfileEnableCliCommand), + typeof(PluginProfileDisableCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class PluginProfileCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileDisableCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileDisableCliCommand.cs new file mode 100644 index 00000000..7010a68b --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileDisableCliCommand.cs @@ -0,0 +1,33 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Profile; + +[CliIdempotent] +[CliCommand( + Name = "disable", + Description = "Disable plugin trace logging for the whole environment (sets the trace level to Off)." +)] +public class PluginProfileDisableCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginProfileDisableCliCommand)); + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var current = await service.GetSettingAsync(Profile, CancellationToken.None).ConfigureAwait(false); + if (current.Level == PluginTraceLevel.Off) + { + OutputFormatter.WriteResult("succeeded", "Plugin trace logging is already disabled."); + return ExitSuccess; + } + + await service.SetSettingAsync(Profile, PluginTraceLevel.Off, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteResult("succeeded", "Plugin trace logging disabled."); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileEnableCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileEnableCliCommand.cs new file mode 100644 index 00000000..c75dee27 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileEnableCliCommand.cs @@ -0,0 +1,42 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Profile; + +[CliIdempotent] +[CliCommand( + Name = "enable", + Description = "Enable plugin trace logging for the whole environment. Plugin execution is written to the plugintracelog table. Use --level all (default) to log everything, or --level exception to log only failures. Note: 'all' adds write overhead on busy environments." +)] +public class PluginProfileEnableCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginProfileEnableCliCommand)); + + [CliOption(Name = "--level", Description = "Trace level to set: all (default) or exception.", Required = false)] + public string? Level { get; set; } + + protected override async Task ExecuteAsync() + { + if (!PluginTraceLevelParser.TryParse(Level, PluginTraceLevel.All, out var level, out var error)) + { + Logger.LogError("{Error}", error); + return ExitValidationError; + } + + var service = TxcServices.Get(); + var current = await service.GetSettingAsync(Profile, CancellationToken.None).ConfigureAwait(false); + if (current.Level == level) + { + OutputFormatter.WriteResult("succeeded", $"Plugin trace logging is already set to {level}."); + return ExitSuccess; + } + + var updated = await service.SetSettingAsync(Profile, level, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteResult("succeeded", $"Plugin trace logging set to {updated.Level}."); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileStatusCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileStatusCliCommand.cs new file mode 100644 index 00000000..9ffa2ace --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginProfileStatusCliCommand.cs @@ -0,0 +1,36 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Plugin.Profile; + +[CliReadOnly] +[CliCommand( + Name = "status", + Description = "Show the current organization-wide plugin trace log level (Off, Exception, or All)." +)] +public class PluginProfileStatusCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(PluginProfileStatusCliCommand)); + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var setting = await service.GetSettingAsync(Profile, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteData(setting, PrintStatus); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteData — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintStatus(PluginTraceSetting setting) + { + var org = string.IsNullOrWhiteSpace(setting.OrganizationName) ? setting.OrganizationId.ToString() : setting.OrganizationName; + OutputWriter.WriteLine($"Plugin trace log level: {setting.Level} (organization: {org})"); + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginTraceLevelParser.cs b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginTraceLevelParser.cs new file mode 100644 index 00000000..c75c2d99 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Plugin/Profile/PluginTraceLevelParser.cs @@ -0,0 +1,50 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Features.Environment.Plugin.Profile; + +/// +/// Parses the --level value for the plugin trace commands into a +/// . Pure and public so it stays unit-testable. +/// +public static class PluginTraceLevelParser +{ + /// + /// Parses a --level value. An empty/whitespace value yields + /// . + /// + public static bool TryParse( + string? value, + PluginTraceLevel @default, + out PluginTraceLevel level, + out string? error) + { + if (string.IsNullOrWhiteSpace(value)) + { + level = @default; + error = null; + return true; + } + + switch (value.Trim().ToLowerInvariant()) + { + case "all": + level = PluginTraceLevel.All; + break; + case "exception": + case "exceptions": + level = PluginTraceLevel.Exception; + break; + case "off": + case "none": + level = PluginTraceLevel.Off; + break; + default: + level = @default; + error = $"Invalid --level value '{value}'. Expected: all, exception, or off."; + return false; + } + + error = null; + return true; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs index fcb0ee7a..491272b8 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddTxcDataverseApplication(this IServiceCollect services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginTraceManager.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginTraceManager.cs new file mode 100644 index 00000000..89991823 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginTraceManager.cs @@ -0,0 +1,51 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Sdk; + +/// +/// Reads and writes the organization-wide plugin trace log level +/// (organization.plugintracelogsetting). +/// +internal static class PluginTraceManager +{ + public static async Task GetSettingAsync( + IOrganizationServiceAsync2 service, CancellationToken ct) + { + var org = await RetrieveOrganizationAsync(service, ct).ConfigureAwait(false); + return Map(org); + } + + public static async Task SetSettingAsync( + IOrganizationServiceAsync2 service, PluginTraceLevel level, CancellationToken ct) + { + var org = await RetrieveOrganizationAsync(service, ct).ConfigureAwait(false); + var update = new Entity("organization", org.Id) + { + ["plugintracelogsetting"] = new OptionSetValue((int)level), + }; + await service.UpdateAsync(update, ct).ConfigureAwait(false); + return Map(org) with { Level = level }; + } + + private static async Task RetrieveOrganizationAsync( + IOrganizationServiceAsync2 service, CancellationToken ct) + { + var query = new QueryExpression("organization") + { + ColumnSet = new ColumnSet("organizationid", "name", "plugintracelogsetting"), + TopCount = 1, + }; + var result = await service.RetrieveMultipleAsync(query, ct).ConfigureAwait(false); + var org = result.Entities.FirstOrDefault() + ?? throw new InvalidOperationException("Could not read the organization record to determine the plugin trace setting."); + return org; + } + + private static PluginTraceSetting Map(Entity e) => new( + OrganizationId: e.Id, + OrganizationName: e.GetAttributeValue("name"), + Level: (PluginTraceLevel)(e.GetAttributeValue("plugintracelogsetting")?.Value ?? 0)); +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginTraceService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginTraceService.cs new file mode 100644 index 00000000..a153208c --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataversePluginTraceService.cs @@ -0,0 +1,20 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Application.Sdk; +using TALXIS.CLI.Platform.Dataverse.Runtime; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Services; + +internal sealed class DataversePluginTraceService : IPluginTraceService +{ + public async Task GetSettingAsync(string? profileName, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await PluginTraceManager.GetSettingAsync(conn.Client, ct).ConfigureAwait(false); + } + + public async Task SetSettingAsync(string? profileName, PluginTraceLevel level, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await PluginTraceManager.SetSettingAsync(conn.Client, level, ct).ConfigureAwait(false); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginTraceLevelParserTests.cs b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginTraceLevelParserTests.cs new file mode 100644 index 00000000..57bc6804 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Plugin/PluginTraceLevelParserTests.cs @@ -0,0 +1,47 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Plugin.Profile; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Plugin; + +public class PluginTraceLevelParserTests +{ + [Theory] + [InlineData("all", PluginTraceLevel.All)] + [InlineData("ALL", PluginTraceLevel.All)] + [InlineData("exception", PluginTraceLevel.Exception)] + [InlineData("exceptions", PluginTraceLevel.Exception)] + [InlineData("off", PluginTraceLevel.Off)] + [InlineData("none", PluginTraceLevel.Off)] + public void TryParse_MapsKnownValues(string value, PluginTraceLevel expected) + { + var ok = PluginTraceLevelParser.TryParse(value, PluginTraceLevel.All, out var level, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal(expected, level); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryParse_EmptyUsesDefault(string? value) + { + var ok = PluginTraceLevelParser.TryParse(value, PluginTraceLevel.All, out var level, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal(PluginTraceLevel.All, level); + } + + [Fact] + public void TryParse_Invalid_ReturnsError() + { + var ok = PluginTraceLevelParser.TryParse("verbose", PluginTraceLevel.All, out var level, out var error); + + Assert.False(ok); + Assert.NotNull(error); + Assert.Contains("verbose", error); + } +}