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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <guid>
```

`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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ public interface ISolutionComponentMutationService
{
Task AddAsync(string? profileName, ComponentAddOptions options, CancellationToken ct);
Task RemoveAsync(string? profileName, ComponentRemoveOptions options, CancellationToken ct);

/// <summary>
/// Permanently deletes a component's underlying object from the environment.
/// Only record-backed types in <see cref="SolutionComponentEntityMap"/> are supported.
/// </summary>
Task DeleteFromEnvironmentAsync(string? profileName, int componentType, Guid objectId, CancellationToken ct);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace TALXIS.CLI.Core.Contracts.Dataverse;

/// <summary>
/// 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.
/// </summary>
public static class SolutionComponentEntityMap
{
// Standard Dataverse solution component type codes -> owning entity logical name.
private static readonly IReadOnlyDictionary<int, string> Map = new Dictionary<int, string>
{
[20] = "role",
[26] = "savedquery",
[29] = "workflow",
[60] = "systemform",
[61] = "webresource",
[70] = "fieldsecurityprofile",
[80] = "appmodule",
[91] = "pluginassembly",
[92] = "sdkmessageprocessingstep",
[380] = "environmentvariabledefinition",
};

/// <summary>
/// Returns the owning entity logical name for a record-backed component type.
/// </summary>
public static bool TryGetEntityLogicalName(int componentType, out string? entityLogicalName) => Map.TryGetValue(componentType, out entityLogicalName);

/// <summary>
/// Whether this command can delete the given component type from the environment.
/// </summary>
public static bool IsSupported(int componentType) => Map.ContainsKey(componentType);

/// <summary>
/// Human-readable list of supported entities, for error messages.
/// </summary>
public static string SupportedSummary => string.Join(", ", Map.Values.OrderBy(v => v, StringComparer.Ordinal));
}
7 changes: 7 additions & 0 deletions src/TALXIS.CLI.Features.Docs/Skills/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace TALXIS.CLI.Features.Environment.Component;
typeof(Url.UrlCliCommand),
typeof(Layer.ComponentLayerCliCommand),
typeof(Dependency.ComponentDependencyCliCommand),
typeof(ComponentDeleteCliCommand),
},
ShortFormAutoGenerate = CliNameAutoGenerate.None
)]
Expand Down
Original file line number Diff line number Diff line change
@@ -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<int> 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<ISolutionDependencyService>();
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<ISolutionComponentMutationService>();
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}