From b331200121f55f93511aee22b7c86612aa0fb4e2 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 22 Apr 2026 22:22:04 +0200 Subject: [PATCH 1/6] Replace string-based root dispatch with typed ExecuteFromProfile The root command previously re-dispatched sync items by reconstructing string[] args and re-invoking System.CommandLine parsing, requiring every option to be mirrored on the root command via ProfileOverrideProvider. Replace this with a typed dispatch contract: each sync command implements ExecuteFromProfile(SyncItem, ProfileExecutionContext, CancellationToken), which receives already-resolved values and calls the command's existing DI + sync logic directly. The root command now merges CLI overrides inline (one switch expression per sync item type) and calls ExecuteFromProfile on each sub-command until one claims the item. Remove ProfileOverrideProvider, XrmSyncSyncCommandBase, ArgumentOverrides, and all Execute*/ExecuteSubCommand/AddCommonArgs helpers from the root command. Root-level override options (--folder, --prefix, --operation, --client-id, --tenant-id, --file-extensions) are now first-class options on XrmSyncRootCommand rather than being advertised dynamically by sub-commands. Absorb sync-specific shared options and SyncCommandAction into XrmSyncCommandBase, flattening the command class hierarchy. --- XrmSync/Commands/ConfigCommand.cs | 6 +- XrmSync/Commands/IXrmSyncCommand.cs | 12 +- XrmSync/Commands/IdentityCommand.cs | 87 ++++--- XrmSync/Commands/PluginAnalyzeCommand.cs | 58 ++--- XrmSync/Commands/PluginSyncCommand.cs | 50 ++-- XrmSync/Commands/ProfileExecutionContext.cs | 14 + XrmSync/Commands/ProfileOverrideProvider.cs | 28 -- XrmSync/Commands/WebresourceSyncCommand.cs | 58 ++--- XrmSync/Commands/XrmSyncCommandBase.cs | 76 +++++- XrmSync/Commands/XrmSyncRootCommand.cs | 269 +++++--------------- XrmSync/Commands/XrmSyncSyncCommandBase.cs | 66 ----- 11 files changed, 293 insertions(+), 431 deletions(-) create mode 100644 XrmSync/Commands/ProfileExecutionContext.cs delete mode 100644 XrmSync/Commands/ProfileOverrideProvider.cs delete mode 100644 XrmSync/Commands/XrmSyncSyncCommandBase.cs diff --git a/XrmSync/Commands/ConfigCommand.cs b/XrmSync/Commands/ConfigCommand.cs index f0659c6..b57bf05 100644 --- a/XrmSync/Commands/ConfigCommand.cs +++ b/XrmSync/Commands/ConfigCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using XrmSync.Model; namespace XrmSync.Commands; @@ -14,7 +15,8 @@ public ConfigCommand() : base("config", "Configuration management commands") public Command GetCommand() => this; /// - /// Config command has no profile sync items to override. + /// Config command does not handle profile sync items. /// - public ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => null; + public Task ExecuteFromProfile(SyncItem syncItem, ProfileExecutionContext ctx, CancellationToken ct) + => Task.FromResult(null); } diff --git a/XrmSync/Commands/IXrmSyncCommand.cs b/XrmSync/Commands/IXrmSyncCommand.cs index 62f2e5d..e23bf65 100644 --- a/XrmSync/Commands/IXrmSyncCommand.cs +++ b/XrmSync/Commands/IXrmSyncCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using XrmSync.Model; namespace XrmSync.Commands; @@ -13,12 +14,9 @@ internal interface IXrmSyncCommand Command GetCommand(); /// - /// Returns a that advertises this command's unique - /// root-level override options and provides merge logic for applying CLI values into - /// profile sync items. The and - /// options are shared, owned by the root command — use them in merge callbacks but do - /// not call Add() on them. - /// Returns null when the command has no profile overrides to advertise. + /// Executes this command using values already resolved from a profile sync item + /// and root-level execution context. Returns null when this command does not + /// handle the given sync item type; returns an exit code otherwise. /// - ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution); + Task ExecuteFromProfile(SyncItem syncItem, ProfileExecutionContext ctx, CancellationToken ct); } diff --git a/XrmSync/Commands/IdentityCommand.cs b/XrmSync/Commands/IdentityCommand.cs index 8bfdf1f..2655e25 100644 --- a/XrmSync/Commands/IdentityCommand.cs +++ b/XrmSync/Commands/IdentityCommand.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.CommandLine; using XrmSync.Constants; @@ -11,18 +12,13 @@ namespace XrmSync.Commands; -internal class IdentityCommand : XrmSyncSyncCommandBase +internal class IdentityCommand : XrmSyncCommandBase { private readonly Option operation; private readonly Option assemblyFile; private readonly Option clientId; private readonly Option tenantId; - // Root-level override options (advertised to XrmSyncRootCommand via GetProfileOverrides) - private readonly Option rootOperation = CliOptions.ManagedIdentity.Operation.CreateOption(); - private readonly Option rootClientId = CliOptions.ManagedIdentity.ClientId.CreateOption(); - private readonly Option rootTenantId = CliOptions.ManagedIdentity.TenantId.CreateOption(); - public IdentityCommand() : base("identity", "Manage the managed identity linked to a plugin assembly") { operation = CliOptions.ManagedIdentity.Operation.CreateOption(); @@ -36,32 +32,33 @@ public IdentityCommand() : base("identity", "Manage the managed identity linked Add(tenantId); AddSharedOptions(); - AddSyncSharedOptions(); + AddSyncOptions(); SetAction(ExecuteAsync); } - /// - /// Advertises --client-id and --tenant-id as root-level overrides. - /// The shared assembly option is used in the merge callback but owned by the root command. - /// - public override ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => new( - options: [rootOperation, rootClientId, rootTenantId], - mergeSyncItem: (item, parseResult) => + public override async Task ExecuteFromProfile(SyncItem syncItem, ProfileExecutionContext ctx, CancellationToken ct) + { + if (syncItem is not IdentitySyncItem identity) return null; + + if (identity.Operation == null) { - if (item is not IdentitySyncItem identity) return null; - var operationValue = parseResult.GetValue(rootOperation); - var clientIdValue = parseResult.GetValue(rootClientId); - var tenantIdValue = parseResult.GetValue(rootTenantId); - var assemblyValue = parseResult.GetValue(assembly); - return identity with - { - Operation = operationValue ?? identity.Operation, - AssemblyPath = !string.IsNullOrWhiteSpace(assemblyValue) ? assemblyValue : identity.AssemblyPath, - ClientId = !string.IsNullOrWhiteSpace(clientIdValue) ? clientIdValue : identity.ClientId, - TenantId = !string.IsNullOrWhiteSpace(tenantIdValue) ? tenantIdValue : identity.TenantId - }; - }); + Console.Error.WriteLine("Identity sync item has no operation configured and none was supplied via --operation."); + return E_ERROR; + } + + return await RunCore( + identity.Operation.Value, + identity.AssemblyPath, + ctx.SolutionName, + identity.ClientId, + identity.TenantId, + ctx.DryRun, + ctx.CiMode, + ctx.LogLevel, + ctx.ProfileName, + ct); + } private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { @@ -134,10 +131,38 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken if (errors.Count > 0) return ValidationError($"identity --operation {finalOperation?.ToString() ?? ""}", errors); - // Build service provider with validated options + return await RunCore(finalOperation!.Value, finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId, dryRun, ciMode, logLevel, sharedOptions.ProfileName, cancellationToken); + } + + private async Task RunCore( + IdentityOperation operation, + string assemblyPath, + string solutionName, + string? clientId, + string? tenantId, + bool? dryRun, + bool? ciMode, + LogLevel? logLevel, + string? profileName, + CancellationToken ct) + { + // Validate resolved values (when called from ExecuteAsync, validation already done above) + var errors = new List(); + errors.AddRange(XrmSyncConfigurationValidator.ValidateAssemblyPath(assemblyPath)); + errors.AddRange(XrmSyncConfigurationValidator.ValidateSolutionName(solutionName)); + + if (operation == IdentityOperation.Ensure) + { + errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(clientId ?? string.Empty, "Client ID")); + errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(tenantId ?? string.Empty, "Tenant ID")); + } + + if (errors.Count > 0) + return ValidationError($"identity --operation {operation}", errors); + var serviceProvider = new ServiceCollection() .AddIdentityService() - .AddXrmSyncConfiguration(sharedOptions) + .AddXrmSyncConfiguration(new SharedOptions(profileName)) .AddOptions( baseOptions => baseOptions with { @@ -145,7 +170,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken CiMode = ciMode ?? baseOptions.CiMode, DryRun = dryRun ?? baseOptions.DryRun }) - .AddSingleton(MSOptions.Create(new IdentityCommandOptions(finalOperation!.Value, finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId))) + .AddSingleton(MSOptions.Create(new IdentityCommandOptions(operation, assemblyPath, solutionName, clientId, tenantId))) .AddSingleton(sp => { var config = sp.GetRequiredService>().Value; @@ -154,7 +179,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken .AddLogger() .BuildServiceProvider(); - return await RunAction(serviceProvider, ConfigurationScope.None, CommandAction, cancellationToken) + return await RunAction(serviceProvider, ConfigurationScope.None, SyncCommandAction, ct) ? E_OK : E_ERROR; } diff --git a/XrmSync/Commands/PluginAnalyzeCommand.cs b/XrmSync/Commands/PluginAnalyzeCommand.cs index 5c4bcc4..cd38501 100644 --- a/XrmSync/Commands/PluginAnalyzeCommand.cs +++ b/XrmSync/Commands/PluginAnalyzeCommand.cs @@ -15,14 +15,10 @@ namespace XrmSync.Commands; internal class PluginAnalyzeCommand : XrmSyncCommandBase { - private readonly Option assemblyFile; private readonly Option prefix; private readonly Option prettyPrint; - // Root-level override options (advertised to XrmSyncRootCommand via GetProfileOverrides) - private readonly Option rootPrefix = CliOptions.Analysis.Prefix.CreateOption(); - public PluginAnalyzeCommand() : base("analyze", "Analyze a plugin assembly and output info as JSON") { assemblyFile = CliOptions.Assembly.CreateOption(); @@ -37,29 +33,17 @@ public PluginAnalyzeCommand() : base("analyze", "Analyze a plugin assembly and o SetAction(ExecuteAsync); } - /// - /// Advertises --prefix as a root-level override. - /// The shared assembly option is used in the merge callback but owned by the root command. - /// - public override ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => new( - options: [rootPrefix], - mergeSyncItem: (item, parseResult) => - { - if (item is not PluginAnalysisSyncItem analysis) return null; - var assemblyValue = parseResult.GetValue(assembly); - var prefixValue = parseResult.GetValue(rootPrefix); - return analysis with - { - AssemblyPath = !string.IsNullOrWhiteSpace(assemblyValue) ? assemblyValue : analysis.AssemblyPath, - PublisherPrefix = !string.IsNullOrWhiteSpace(prefixValue) ? prefixValue : analysis.PublisherPrefix - }; - }); + public override async Task ExecuteFromProfile(SyncItem syncItem, ProfileExecutionContext ctx, CancellationToken ct) + { + if (syncItem is not PluginAnalysisSyncItem analysis) return null; + return await RunCore(analysis.AssemblyPath, analysis.PublisherPrefix, analysis.PrettyPrint, ctx.ProfileName, ct); + } private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var assemblyPath = parseResult.GetValue(assemblyFile); var publisherPrefix = parseResult.GetValue(prefix); - var prettyPrint = parseResult.GetValue(this.prettyPrint); + var prettyPrintValue = parseResult.GetValue(this.prettyPrint); var sharedOptions = GetSharedOptionValues(parseResult); // Resolve final options eagerly (CLI + profile merge) @@ -72,7 +56,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken // Standalone mode: all required values supplied via CLI finalAssemblyPath = assemblyPath; finalPublisherPrefix = publisherPrefix; - finalPrettyPrint = prettyPrint; + finalPrettyPrint = prettyPrintValue; } else { @@ -92,30 +76,38 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken finalAssemblyPath = !string.IsNullOrWhiteSpace(assemblyPath) ? assemblyPath : (pluginAnalysisItem?.AssemblyPath ?? string.Empty); finalPublisherPrefix = !string.IsNullOrWhiteSpace(publisherPrefix) ? publisherPrefix : (pluginAnalysisItem?.PublisherPrefix ?? string.Empty); - finalPrettyPrint = prettyPrint || (pluginAnalysisItem?.PrettyPrint ?? false); + finalPrettyPrint = prettyPrintValue || (pluginAnalysisItem?.PrettyPrint ?? false); } - // Validate resolved values - var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(finalAssemblyPath) - .Concat(XrmSyncConfigurationValidator.ValidatePublisherPrefix(finalPublisherPrefix)) + return await RunCore(finalAssemblyPath, finalPublisherPrefix, finalPrettyPrint, sharedOptions.ProfileName, cancellationToken); + } + + private async Task RunCore( + string assemblyPath, + string publisherPrefix, + bool prettyPrintValue, + string? profileName, + CancellationToken ct) + { + var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(assemblyPath) + .Concat(XrmSyncConfigurationValidator.ValidatePublisherPrefix(publisherPrefix)) .ToList(); if (errors.Count > 0) return ValidationError("analyze", errors); - // Build service provider with validated options var serviceProvider = GetAnalyzerServices() - .AddXrmSyncConfiguration(sharedOptions) + .AddXrmSyncConfiguration(new SharedOptions(profileName)) .AddOptions(baseOptions => baseOptions) - .AddSingleton(MSOptions.Create(new PluginAnalysisCommandOptions(finalAssemblyPath, finalPublisherPrefix, finalPrettyPrint))) + .AddSingleton(MSOptions.Create(new PluginAnalysisCommandOptions(assemblyPath, publisherPrefix, prettyPrintValue))) .AddLogger() .BuildServiceProvider(); - return await RunAction(serviceProvider, ConfigurationScope.None, CommandAction, cancellationToken) + return await RunAction(serviceProvider, ConfigurationScope.None, AnalyzeCommandAction, ct) ? E_OK : E_ERROR; } - private static async Task CommandAction(IServiceProvider serviceProvider, CancellationToken cancellationToken) + private static async Task AnalyzeCommandAction(IServiceProvider serviceProvider, CancellationToken cancellationToken) { return await Task.Run(() => { @@ -145,9 +137,7 @@ private static async Task CommandAction(IServiceProvider serviceProvider, private static IServiceCollection GetAnalyzerServices(IServiceCollection? services = null) { services ??= new ServiceCollection(); - services.AddAssemblyAnalyzer(); - return services; } } diff --git a/XrmSync/Commands/PluginSyncCommand.cs b/XrmSync/Commands/PluginSyncCommand.cs index aec7fca..7ca03d2 100644 --- a/XrmSync/Commands/PluginSyncCommand.cs +++ b/XrmSync/Commands/PluginSyncCommand.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.CommandLine; using XrmSync.Constants; @@ -10,7 +11,7 @@ namespace XrmSync.Commands; -internal class PluginSyncCommand : XrmSyncSyncCommandBase +internal class PluginSyncCommand : XrmSyncCommandBase { private readonly Option assemblyFile; @@ -21,25 +22,16 @@ public PluginSyncCommand() : base("plugins", "Synchronize plugins in a plugin as Add(assemblyFile); AddSharedOptions(); - AddSyncSharedOptions(); + AddSyncOptions(); SetAction(ExecuteAsync); } - /// - /// Plugin sync has no unique override options — it only uses the shared assembly + solution options. - /// - public override ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => new( - options: [], - mergeSyncItem: (item, parseResult) => - { - if (item is not PluginSyncItem plugin) return null; - var assemblyValue = parseResult.GetValue(assembly); - return plugin with - { - AssemblyPath = !string.IsNullOrWhiteSpace(assemblyValue) ? assemblyValue : plugin.AssemblyPath - }; - }); + public override async Task ExecuteFromProfile(SyncItem syncItem, ProfileExecutionContext ctx, CancellationToken ct) + { + if (syncItem is not PluginSyncItem plugin) return null; + return await RunCore(plugin.AssemblyPath, ctx.SolutionName, ctx.DryRun, ctx.CiMode, ctx.LogLevel, ctx.ProfileName, ct); + } private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { @@ -77,16 +69,26 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken finalSolutionName = !string.IsNullOrWhiteSpace(solutionName) ? solutionName : profile.SolutionName; } - // Validate resolved values - var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(finalAssemblyPath) - .Concat(XrmSyncConfigurationValidator.ValidateSolutionName(finalSolutionName)) + return await RunCore(finalAssemblyPath, finalSolutionName, dryRun, ciMode, logLevel, sharedOptions.ProfileName, cancellationToken); + } + + private async Task RunCore( + string assemblyPath, + string solutionName, + bool? dryRun, + bool? ciMode, + LogLevel? logLevel, + string? profileName, + CancellationToken ct) + { + var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(assemblyPath) + .Concat(XrmSyncConfigurationValidator.ValidateSolutionName(solutionName)) .ToList(); if (errors.Count > 0) return ValidationError("plugins", errors); - // Build service provider with validated options var serviceProvider = GetPluginSyncServices() - .AddXrmSyncConfiguration(sharedOptions) + .AddXrmSyncConfiguration(new SharedOptions(profileName)) .AddOptions( baseOptions => baseOptions with { @@ -94,7 +96,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken CiMode = ciMode ?? baseOptions.CiMode, DryRun = dryRun ?? baseOptions.DryRun }) - .AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(finalAssemblyPath, finalSolutionName))) + .AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(assemblyPath, solutionName))) .AddSingleton(sp => { var config = sp.GetRequiredService>().Value; @@ -103,7 +105,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken .AddLogger() .BuildServiceProvider(); - return await RunAction(serviceProvider, ConfigurationScope.None, CommandAction, cancellationToken) + return await RunAction(serviceProvider, ConfigurationScope.None, SyncCommandAction, ct) ? E_OK : E_ERROR; } @@ -111,9 +113,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken private static IServiceCollection GetPluginSyncServices(IServiceCollection? services = null) { services ??= new ServiceCollection(); - services.AddPluginSyncService(); - return services; } } diff --git a/XrmSync/Commands/ProfileExecutionContext.cs b/XrmSync/Commands/ProfileExecutionContext.cs new file mode 100644 index 0000000..c7d7f24 --- /dev/null +++ b/XrmSync/Commands/ProfileExecutionContext.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Logging; + +namespace XrmSync.Commands; + +/// +/// Carries the already-resolved execution values the root command passes to sub-commands +/// when dispatching profile sync items. +/// +internal record ProfileExecutionContext( + string SolutionName, + bool DryRun, + bool CiMode, + LogLevel LogLevel, + string? ProfileName); diff --git a/XrmSync/Commands/ProfileOverrideProvider.cs b/XrmSync/Commands/ProfileOverrideProvider.cs deleted file mode 100644 index b528dd9..0000000 --- a/XrmSync/Commands/ProfileOverrideProvider.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.CommandLine; -using System.CommandLine.Parsing; -using XrmSync.Model; - -namespace XrmSync.Commands; - -/// -/// Advertises a command's unique root-level override options and provides merge logic -/// for applying CLI override values into profile sync items. -/// -internal sealed class ProfileOverrideProvider( - IReadOnlyList