diff --git a/README.md b/README.md index c79092cd..82efa7ab 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,29 @@ txc env sln component list MySolution --type entity # Drill into component layers and dependencies by name — no GUIDs needed txc env component layer list --entity account --attribute revenue txc env component dep delete-check --entity tom_project + +# Delete a component from the environment> +# Checks dependencies first and refuses if anything still depends on it. +txc env component delete --type Role --id ``` +`component delete` supports these record-backed component types (pass the name or the numeric code to `--type`): + +| Type | Code | Deletes | +|------|------|---------| +| `Role` | 20 | security role | +| `SavedQuery` | 26 | view | +| `Workflow` | 29 | workflow / business rule / flow | +| `SystemForm` | 60 | form / dashboard | +| `WebResource` | 61 | web resource | +| `FieldSecurityProfile` | 70 | field security profile | +| `AppModule` | 80 | model-driven app | +| `PluginAssembly` | 91 | plugin assembly | +| `SdkMessageProcessingStep` | 92 | plugin step | +| `EnvironmentVariableDefinition` | 380 | environment variable definition | + +Tables and columns are schema objects and aren't deletable through this command — use the workspace/schema tooling for those. + ### Data Plane Query, create, update, and bulk-operate on Dataverse records. diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionComponentMutationService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionComponentMutationService.cs index b2024757..f74e9299 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionComponentMutationService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionComponentMutationService.cs @@ -16,4 +16,10 @@ public interface ISolutionComponentMutationService { Task AddAsync(string? profileName, ComponentAddOptions options, CancellationToken ct); Task RemoveAsync(string? profileName, ComponentRemoveOptions options, CancellationToken ct); + + /// + /// Permanently deletes a component's underlying object from the environment. + /// Only record-backed types in are supported. + /// + Task DeleteFromEnvironmentAsync(string? profileName, int componentType, Guid objectId, CancellationToken ct); } diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/SolutionComponentEntityMap.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/SolutionComponentEntityMap.cs new file mode 100644 index 00000000..9d66753f --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/SolutionComponentEntityMap.cs @@ -0,0 +1,38 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +/// +/// Maps record-backed solution component types to the Dataverse entity that holds them, +/// so a component can be deleted from the environment with a plain record delete. +/// +public static class SolutionComponentEntityMap +{ + // Standard Dataverse solution component type codes -> owning entity logical name. + private static readonly IReadOnlyDictionary Map = new Dictionary + { + [20] = "role", + [26] = "savedquery", + [29] = "workflow", + [60] = "systemform", + [61] = "webresource", + [70] = "fieldsecurityprofile", + [80] = "appmodule", + [91] = "pluginassembly", + [92] = "sdkmessageprocessingstep", + [380] = "environmentvariabledefinition", + }; + + /// + /// Returns the owning entity logical name for a record-backed component type. + /// + public static bool TryGetEntityLogicalName(int componentType, out string? entityLogicalName) => Map.TryGetValue(componentType, out entityLogicalName); + + /// + /// Whether this command can delete the given component type from the environment. + /// + public static bool IsSupported(int componentType) => Map.ContainsKey(componentType); + + /// + /// Human-readable list of supported entities, for error messages. + /// + public static string SupportedSummary => string.Join(", ", Map.Values.OrderBy(v => v, StringComparer.Ordinal)); +} diff --git a/src/TALXIS.CLI.Features.Docs/Skills/troubleshooting.md b/src/TALXIS.CLI.Features.Docs/Skills/troubleshooting.md index 08533e48..4518def4 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/troubleshooting.md +++ b/src/TALXIS.CLI.Features.Docs/Skills/troubleshooting.md @@ -11,6 +11,10 @@ Import failed │ └─→ Identify which solution owns the conflicting component │ └─→ Update that solution first, or remove the conflict │ + ├─→ Error: Conflicting object blocks import (e.g. duplicate security role) + │ └─→ environment_component_dependency_delete_check (confirm nothing depends on it) + │ └─→ environment_component_delete (remove the leftover object, then retry) + │ ├─→ Error: Missing dependency │ └─→ environment_component_dependency_required │ └─→ Import the solution containing the required component first @@ -55,6 +59,8 @@ Tool: environment_component_dependency_delete_check ``` Shows what depends on the component you're trying to delete. Remove or update those dependencies first. +Once nothing depends on it, `environment_component_delete` removes the object from the environment (a leftover security role, plugin step, or assembly blocking an import). It runs the same dependency check and refuses if anything still depends on the component. This is different from `environment_solution_component_remove`, which only unlinks a component from a solution without deleting it. + ### Missing Required Dependencies ``` Tool: environment_component_dependency_required @@ -75,6 +81,7 @@ If data or schema doesn't match expectations: | Import failed | `environment_deployment_show --latest` | | Component conflict | `environment_component_layer_list` | | Can't delete something | `environment_component_dependency_delete_check` | +| Leftover object blocks import | `environment_component_delete` (after delete-check) | | Missing dependency | `environment_component_dependency_required` | | Auth errors | `config_profile_validate` | | Wrong data showing | `config_profile_show` | diff --git a/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs index a2d5d6e6..d08a29df 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs @@ -11,6 +11,7 @@ namespace TALXIS.CLI.Features.Environment.Component; typeof(Url.UrlCliCommand), typeof(Layer.ComponentLayerCliCommand), typeof(Dependency.ComponentDependencyCliCommand), + typeof(ComponentDeleteCliCommand), }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] diff --git a/src/TALXIS.CLI.Features.Environment/Component/ComponentDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/ComponentDeleteCliCommand.cs new file mode 100644 index 00000000..89686dbb --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/ComponentDeleteCliCommand.cs @@ -0,0 +1,100 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Features.Environment.Component.Dependency; +using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; + +namespace TALXIS.CLI.Features.Environment.Component; + +[CliDestructive("Permanently deletes the component object from the environment. This cannot be undone.")] +[CliCommand( + Name = "delete", + Description = "Permanently delete a component object from the LIVE environment (e.g. a leftover security role, plugin step, or assembly blocking an import). Refuses if other components still depend on it — run 'environment component dependency delete-check' first to preview. Different from 'solution component remove', which only unlinks a component from a solution without deleting it." +)] +public class ComponentDeleteCliCommand : ProfiledCliCommand, IDestructiveCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ComponentDeleteCliCommand)); + + [CliOption(Name = "--id", Description = "Component GUID (objectId / MetadataId). Required unless --entity is given.", Required = false)] + public string? Id { get; set; } + + [CliOption(Name = "--type", Description = "Component type (name or code, e.g. 'Role' or '20'). Auto-detected when using --entity.", Required = false)] + public string? Type { get; set; } + + [CliOption(Name = "--entity", Description = "Entity logical name. Resolves MetadataId automatically.", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--attribute", Description = "Attribute logical name (requires --entity).", Required = false)] + public string? Attribute { get; set; } + + [CliOption(Name = "--yes", Description = "Skip the interactive confirmation for this destructive delete.", Required = false)] + public bool Yes { get; set; } + + protected override async Task ExecuteAsync() + { + var ct = CancellationToken.None; + + var resolved = await ComponentIdResolver.TryResolveAsync(Id, Type, Entity, Attribute, Profile, Logger, ct).ConfigureAwait(false); + if (resolved is null) + return ExitValidationError; + var (componentId, typeName) = resolved.Value; + + if (!Guid.TryParse(componentId, out var id)) + { + Logger.LogError("Invalid component ID '{ComponentId}'. Must be a valid GUID.", componentId); + + return ExitValidationError; + } + + var def = ComponentDefinitionRegistry.GetByName(typeName); + if (def is null && int.TryParse(typeName, out var parsedCode)) + def = ComponentDefinitionRegistry.GetByType((ComponentType)parsedCode); + if (def is null) + { + var known = string.Join(", ", ComponentDefinitionRegistry.GetAll().Select(d => d.Name).Take(15)); + Logger.LogError("Unknown component type '{Type}'. Available types: {Known}. Or use an integer code.", typeName, known); + + return ExitValidationError; + } + var typeCode = (int)def.TypeCode; + + if (!SolutionComponentEntityMap.IsSupported(typeCode)) + { + Logger.LogError( + "Deleting '{TypeName}' from the environment is not supported by this command. Supported component entities: {Supported}. " + + "Tables and columns are schema objects — use workspace/schema tooling instead.", + typeName, SolutionComponentEntityMap.SupportedSummary); + + return ExitValidationError; + } + + // Safety: refuse to delete while other components still depend on this one. + var dependencyService = TxcServices.Get(); + var blockers = await dependencyService.CheckDeleteAsync(Profile, id, typeCode, ct).ConfigureAwait(false); + if (blockers.Count > 0) + { + OutputFormatter.WriteData( + new { status = "blocked", componentId, componentType = typeName, blockingDependencies = blockers.Count, dependencies = blockers }, + _ => DependencyOutputHelper.PrintDependencyTable(blockers, "Dependent", "Required", + $"Cannot delete {typeName} {componentId}: {blockers.Count} component(s) still depend on it. Remove or repoint them first.")); + return ExitError; + } + + var service = TxcServices.Get(); + await service.DeleteFromEnvironmentAsync(Profile, typeCode, id, ct).ConfigureAwait(false); + + OutputFormatter.WriteData( + new { status = "deleted", componentId, componentType = typeName }, + _ => + { +#pragma warning disable TXC003 + OutputWriter.WriteLine($"Deleted {typeName} {componentId} from the environment."); +#pragma warning restore TXC003 + }); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.MCP/Skills/Internal/troubleshooting-patterns.md b/src/TALXIS.CLI.MCP/Skills/Internal/troubleshooting-patterns.md index cbfa57d2..0dacffaf 100644 --- a/src/TALXIS.CLI.MCP/Skills/Internal/troubleshooting-patterns.md +++ b/src/TALXIS.CLI.MCP/Skills/Internal/troubleshooting-patterns.md @@ -11,6 +11,7 @@ Match the user's symptom to the correct FIRST tool to run: "import failed" / "deployment error" → environment_deployment_show --latest "component won't update" / "conflict" → environment_component_layer_list "can't delete" → environment_component_dependency_delete_check +"leftover object blocks import" → environment_component_dependency_delete_check → environment_component_delete "missing dependency" → environment_component_dependency_required "auth error" / "401" / "403" → config_profile_validate "wrong data" / "stale" → config_profile_show (verify target env) @@ -25,6 +26,7 @@ Match the user's symptom to the correct FIRST tool to run: ``` environment_deployment_show → findings? ├─ Component error → environment_component_layer_list → environment_component_layer_show + ├─ Conflicting object (e.g. duplicate role) → environment_component_dependency_delete_check → environment_component_delete → retry ├─ Missing dependency → environment_component_dependency_required → import missing solution ├─ Version conflict → increment version locally → rebuild → retry └─ Generic/timeout → retry once with --wait → if still fails, check env health diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionComponentMutator.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionComponentMutator.cs index 1cc026b9..d6a7f3dc 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionComponentMutator.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/SolutionComponentMutator.cs @@ -41,4 +41,20 @@ public static async Task RemoveAsync( await service.ExecuteAsync(request, ct).ConfigureAwait(false); } + + public static async Task DeleteFromEnvironmentAsync( + IOrganizationServiceAsync2 service, + int componentType, + Guid objectId, + CancellationToken ct) + { + if (!SolutionComponentEntityMap.TryGetEntityLogicalName(componentType, out var entityLogicalName) || entityLogicalName is null) + { + throw new NotSupportedException( + $"Deleting component type {componentType} from the environment is not supported. " + + $"Supported component entities: {SolutionComponentEntityMap.SupportedSummary}."); + } + + await service.DeleteAsync(entityLogicalName, objectId, ct).ConfigureAwait(false); + } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionComponentMutationService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionComponentMutationService.cs index f5a2a80b..0f4a86a7 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionComponentMutationService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionComponentMutationService.cs @@ -17,4 +17,10 @@ public async Task RemoveAsync(string? profileName, ComponentRemoveOptions option using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); await SolutionComponentMutator.RemoveAsync(conn.Client, options, ct).ConfigureAwait(false); } + + public async Task DeleteFromEnvironmentAsync(string? profileName, int componentType, Guid objectId, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + await SolutionComponentMutator.DeleteFromEnvironmentAsync(conn.Client, componentType, objectId, ct).ConfigureAwait(false); + } } diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionComponentEntityMapTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionComponentEntityMapTests.cs new file mode 100644 index 00000000..af1774d5 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionComponentEntityMapTests.cs @@ -0,0 +1,40 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +public class SolutionComponentEntityMapTests +{ + [Theory] + [InlineData(20, "role")] // security role (the issue's case) + [InlineData(91, "pluginassembly")] + [InlineData(92, "sdkmessageprocessingstep")] + [InlineData(61, "webresource")] + [InlineData(380, "environmentvariabledefinition")] + public void TryGetEntityLogicalName_MapsRecordBackedTypes(int componentType, string expected) + { + Assert.True(SolutionComponentEntityMap.TryGetEntityLogicalName(componentType, out var entity)); + Assert.Equal(expected, entity); + Assert.True(SolutionComponentEntityMap.IsSupported(componentType)); + } + + [Theory] + [InlineData(1)] // Entity (metadata table) - intentionally unsupported + [InlineData(2)] // Attribute (metadata column) + [InlineData(9)] // OptionSet + [InlineData(99999)] + public void TryGetEntityLogicalName_RejectsMetadataAndUnknownTypes(int componentType) + { + Assert.False(SolutionComponentEntityMap.TryGetEntityLogicalName(componentType, out var entity)); + Assert.Null(entity); + Assert.False(SolutionComponentEntityMap.IsSupported(componentType)); + } + + [Fact] + public void SupportedSummary_ListsKnownEntities() + { + var summary = SolutionComponentEntityMap.SupportedSummary; + Assert.Contains("role", summary); + Assert.Contains("sdkmessageprocessingstep", summary); + } +}