Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginInventoryService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 sealed record PluginStepImageRecord(
Guid Id,
Guid StepId,
string ImageType,
string? EntityAlias,
string? Attributes);

public interface IPluginInventoryService
{
Task<IReadOnlyList<PluginAssemblyRecord>> ListAssembliesAsync(
string? profileName,
string? nameContains,
CancellationToken ct);

Task<IReadOnlyList<PluginTypeRecord>> ListTypesAsync(
string? profileName,
string? assemblyContains,
PluginKind? kind,
CancellationToken ct);

Task<IReadOnlyList<PluginStepRecord>> ListStepsAsync(
string? profileName,
string? assemblyContains,
CancellationToken ct);

Task<IReadOnlyList<PluginStepImageRecord>> ListStepImagesAsync(
string? profileName,
string? assemblyContains,
CancellationToken ct);

Task SetStepStateAsync(
string? profileName,
Guid stepId,
bool enabled,
CancellationToken ct);

Task<int> SetStepsStateAsync(
string? profileName,
IReadOnlyCollection<Guid> stepIds,
bool enabled,
CancellationToken ct);
}
25 changes: 25 additions & 0 deletions src/TALXIS.CLI.Core/Contracts/Dataverse/IPluginTraceService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace TALXIS.CLI.Core.Contracts.Dataverse;

/// <summary>
/// Organization-wide plugin trace log level (the <c>plugintracelogsetting</c>
/// attribute on the <c>organization</c> entity). Controls whether plugin
/// execution is written to the <c>plugintracelog</c> table.
/// </summary>
public enum PluginTraceLevel
{
Off = 0,
Exception = 1,
All = 2,
}

public sealed record PluginTraceSetting(
Guid OrganizationId,
string? OrganizationName,
PluginTraceLevel Level);

public interface IPluginTraceService
{
Task<PluginTraceSetting> GetSettingAsync(string? profileName, CancellationToken ct);

Task<PluginTraceSetting> SetSettingAsync(string? profileName, PluginTraceLevel level, CancellationToken ct);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using DotMake.CommandLine;

namespace TALXIS.CLI.Features.Environment.Plugin.Assemblies;

[CliCommand(
Name = "assembly",
Description = "List and inspect plugin assemblies registered in the connected environment.",
Children = new[]
{
typeof(PluginAssemblyListCliCommand),
typeof(PluginAssemblyShowCliCommand),
},
ShortFormAutoGenerate = CliNameAutoGenerate.None
)]
public class PluginAssemblyCliCommand
{
public void Run(CliContext context)
{
context.ShowHelp();
}
}
Original file line number Diff line number Diff line change
@@ -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<int> ExecuteAsync()
{
var service = TxcServices.Get<IPluginInventoryService>();
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<PluginAssemblyRecord> 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using TALXIS.CLI.Core.Contracts.Dataverse;

namespace TALXIS.CLI.Features.Environment.Plugin.Assemblies;

/// <summary>
/// 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.
/// </summary>
public static class PluginAssemblyResolver
{
public sealed record Resolution(PluginAssemblyRecord? Assembly, string? Error);

public static Resolution Resolve(IReadOnlyList<PluginAssemblyRecord> 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<PluginAssemblyRecord> 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}";
}
}
Original file line number Diff line number Diff line change
@@ -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<int> ExecuteAsync()
{
var service = TxcServices.Get<IPluginInventoryService>();

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;
}

/// <summary>
/// Builds the human-readable detail block for an assembly and its components.
/// Pure and public so rendering is unit-testable without a live environment.
/// </summary>
public static IReadOnlyList<string> BuildDetailLines(
PluginAssemblyRecord asm,
IReadOnlyList<PluginTypeRecord> types,
IReadOnlyList<PluginStepRecord> steps,
IReadOnlyList<PluginStepImageRecord> images)
{
var imagesByStep = images
.GroupBy(i => i.StepId)
.ToDictionary(g => g.Key, g => g.Count());

var lines = new List<string>
{
$"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<PluginTypeRecord> types,
IReadOnlyList<PluginStepRecord> steps,
IReadOnlyList<PluginStepImageRecord> images)
{
foreach (var line in BuildDetailLines(asm, types, steps, images))
OutputWriter.WriteLine(line);
}
#pragma warning restore TXC003
}
23 changes: 23 additions & 0 deletions src/TALXIS.CLI.Features.Environment/Plugin/PluginCliCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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),
typeof(Profile.PluginProfileCliCommand),
},
ShortFormAutoGenerate = CliNameAutoGenerate.None
)]
public class PluginCliCommand
{
public void Run(CliContext context)
{
context.ShowHelp();
}
}
Loading