diff --git a/README.md b/README.md index e117dd7..06d0842 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ dvx plugin sync --project [options] | `--client-id` | | env var / config | Service principal client ID | | `--client-secret` | | env var / config | Service principal client secret | | `--solution-unique-name` | | from config | Add all registered steps to this Dataverse solution | +| `--delete-orphaned` | | | Delete steps in Dataverse no longer present in code. Steps backing Custom APIs and Custom Actions are never removed. Destructive — run with `--dry-run` first | | `--dry-run` | | | Print what would change without writing to Dataverse | | `--config` | | auto-discovered | Path to config file | | `--verbose` | | | Log upload details + inner exception details on error | @@ -348,7 +349,7 @@ dvx plugin sync --project [options] 3. Uploads the new `.nupkg` by updating the `pluginpackage` `content` column via the Dataverse SDK 4. Queries the child `pluginassembly` record for the assembly ID 5. Reflects the `.dll` for `[PluginStep]` attributes -6. Fully syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, deletes orphan steps +6. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps (removed only when `--delete-orphaned` is passed) 7. Syncs `sdkmessageprocessingstepimage` records (pre/post images) for each step > **Note:** `sync` and `deploy` only support **updating** an existing plugin package. @@ -426,6 +427,7 @@ dvx plugin register (--project | --assembly-name ) [options] | `--client-id` | | Service principal client ID | | `--client-secret` | | Service principal client secret | | `--solution-unique-name` | | Add all registered steps to this Dataverse solution. Falls back to `solutionUniqueName` in config | +| `--delete-orphaned` | | Delete steps in Dataverse no longer present in code. Steps backing Custom APIs and Custom Actions are never removed. Destructive — run with `--dry-run` first | | `--dry-run` | | Print what would change without writing to Dataverse | | `--verbose` | | Log solution validation and step-assignment details | | `--config` | | Path to config file | diff --git a/src/dvx.Tests/StepRegistrarTests.cs b/src/dvx.Tests/StepRegistrarTests.cs index e7c437d..03cea09 100644 --- a/src/dvx.Tests/StepRegistrarTests.cs +++ b/src/dvx.Tests/StepRegistrarTests.cs @@ -98,6 +98,14 @@ private static IOrganizationService BuildDefaultSvc( svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "sdkmessageprocessingstepimage")) .Returns(existingImages ?? new EntityCollection()); + // custom APIs (none by default) + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "customapi")) + .Returns(new EntityCollection()); + + // custom actions / workflows (none by default) + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "workflow")) + .Returns(new EntityCollection()); + // Create returns a fresh Guid svc.Create(Arg.Any()).Returns(_ => Guid.NewGuid()); @@ -216,15 +224,123 @@ public void OrphanStep_DeletedFromDataverse_ResultDeletedIsOne() svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "sdkmessageprocessingstepimage")) .Returns(new EntityCollection()); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "customapi")) + .Returns(new EntityCollection()); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "workflow")) + .Returns(new EntityCollection()); + svc.Create(Arg.Any()).Returns(_ => Guid.NewGuid()); // definitions don't include the old plugin type - var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { MakeDef() }); + var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { MakeDef() }, deleteOrphaned: true); result.Deleted.ShouldBe(1); svc.Received(1).Delete("sdkmessageprocessingstep", orphanId); } + [Fact] + public void OrphanStep_DeleteOrphanedFalse_Warns_NotDeleted() + { + // Deletion is opt-in: by default an orphan is reported as a warning, not removed. + var orphanId = Guid.NewGuid(); + var orphan = new Entity("sdkmessageprocessingstep", orphanId) + { + ["name"] = "Ns.OldPlugin | account | create | PreOperation", + ["plugintypeid"] = new EntityReference("plugintype", PluginTypeId), + ["sdkmessageid"] = new EntityReference("sdkmessage", MsgId), + }; + + var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List { orphan })); + var result = MakeRegistrar(svc).Sync(AssemblyId, Array.Empty()); + + result.Deleted.ShouldBe(0); + result.Warnings.ShouldContain(w => w.Contains("Ns.OldPlugin | account | create | PreOperation")); + svc.DidNotReceive().Delete("sdkmessageprocessingstep", orphanId); + } + + [Fact] + public void OrphanStep_OnCustomApiMessage_NotDeleted() + { + // A Custom API's backing step lives on this assembly's plugin types but is owned by the + // Custom API definition — it must never be treated as an orphan, even when pruning. + var customApiMsgId = Guid.NewGuid(); + var stepId = Guid.NewGuid(); + + var orphanOnCustomApi = new Entity("sdkmessageprocessingstep", stepId) + { + ["name"] = "MyCustomApi.Handler", + ["plugintypeid"] = new EntityReference("plugintype", PluginTypeId), + ["sdkmessageid"] = new EntityReference("sdkmessage", customApiMsgId), + }; + + var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List { orphanOnCustomApi })); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "customapi")) + .Returns(new EntityCollection(new List + { + new Entity("customapi", Guid.NewGuid()) + { + ["sdkmessageid"] = new EntityReference("sdkmessage", customApiMsgId) + } + })); + + var result = MakeRegistrar(svc).Sync(AssemblyId, Array.Empty(), deleteOrphaned: true); + + result.Deleted.ShouldBe(0); + svc.DidNotReceive().Delete("sdkmessageprocessingstep", stepId); + } + + [Fact] + public void OrphanStep_OnCustomActionMessage_NotDeleted() + { + // A Custom Action (workflow, category = Action) owns an SDK message named after its + // uniquename. A step on that message backs the action and must never be orphan-deleted. + var actionMsgId = Guid.NewGuid(); + const string actionUnique = "new_MyAction"; + var stepId = Guid.NewGuid(); + + var orphanOnAction = new Entity("sdkmessageprocessingstep", stepId) + { + ["name"] = "MyAction.Handler", + ["plugintypeid"] = new EntityReference("plugintype", PluginTypeId), + ["sdkmessageid"] = new EntityReference("sdkmessage", actionMsgId), + }; + + var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List { orphanOnAction })); + + // The action's message must be resolvable by name (workflow.uniquename → sdkmessage.name). + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "sdkmessage")) + .Returns(new EntityCollection(new List + { + new Entity("sdkmessage", MsgId) { ["name"] = MessageName }, + new Entity("sdkmessage", actionMsgId) { ["name"] = actionUnique }, + })); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "workflow")) + .Returns(new EntityCollection(new List + { + new Entity("workflow", Guid.NewGuid()) { ["uniquename"] = actionUnique } + })); + + var result = MakeRegistrar(svc).Sync(AssemblyId, Array.Empty(), deleteOrphaned: true); + + result.Deleted.ShouldBe(0); + svc.DidNotReceive().Delete("sdkmessageprocessingstep", stepId); + } + + [Fact] + public void ConsumedStep_DeleteOrphanedTrue_NotDeleted() + { + // Pruning must only remove genuine orphans — a step matching a definition is never deleted. + var def = MakeDef(); + var stepId = Guid.NewGuid(); + var existing = new Entity("sdkmessageprocessingstep", stepId) { ["name"] = def.StepName }; + + var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List { existing })); + var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { def }, deleteOrphaned: true); + + result.Deleted.ShouldBe(0); + svc.DidNotReceive().Delete("sdkmessageprocessingstep", stepId); + } + [Fact] public void UnknownMessage_AddsWarning_CreateNotCalled() { @@ -653,6 +769,55 @@ public void ImageAttributes_Removed_ClearedOnUpdate() e.GetAttributeValue("attributes") == null)); } + // ── Image message property name ──────────────────────────────────────── + + [Fact] + public void CreateStep_PostImage_UsesIdMessagePropertyName() + { + // The Create message binds post-images via 'Id'. dvx previously hardcoded 'Target', + // which Dataverse rejects ("Message property name 'Target' is not valid on message Create"). + var def = MakeDef(stage: 40, postImage: true); // message defaults to "Create" + var svc = BuildDefaultSvc(); + + MakeRegistrar(svc).Sync(AssemblyId, new[] { def }); + + svc.Received().Create(Arg.Is(e => + e.LogicalName == "sdkmessageprocessingstepimage" && + e.GetAttributeValue("messagepropertyname") == "Id")); + } + + [Fact] + public void UpdateStep_PostImage_UsesTargetMessagePropertyName() + { + var def = MakeDef(message: "Update", stage: 40, postImage: true); + var svc = BuildDefaultSvc(); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "sdkmessage")) + .Returns(new EntityCollection(new List + { + new Entity("sdkmessage", MsgId) { ["name"] = "Update" } + })); + + MakeRegistrar(svc).Sync(AssemblyId, new[] { def }); + + svc.Received().Create(Arg.Is(e => + e.LogicalName == "sdkmessageprocessingstepimage" && + e.GetAttributeValue("messagepropertyname") == "Target")); + } + + [Fact] + public void MessageWithoutImageSupport_SkipsImage_AddsWarning() + { + // Associate has no valid image property name. The image must be skipped (not registered + // with an invalid property name), and the user warned. + var def = MakeDef(entity: "", message: "Associate", stage: 40, postImage: true); + var svc = BuildEntitylessSvc("Associate"); + + var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { def }); + + svc.DidNotReceive().Create(Arg.Is(e => e.LogicalName == "sdkmessageprocessingstepimage")); + result.Warnings.ShouldContain(w => w.Contains("does not support entity images")); + } + // ── Change detection (skip unchanged) ────────────────────────────────── [Fact] diff --git a/src/dvx/Commands/RegisterCommand.cs b/src/dvx/Commands/RegisterCommand.cs index 223d63a..639270e 100644 --- a/src/dvx/Commands/RegisterCommand.cs +++ b/src/dvx/Commands/RegisterCommand.cs @@ -24,11 +24,12 @@ public static Command Build(ILoggerFactory loggerFactory) var dryRun = CommandOptions.DryRun(); var verbose = CommandOptions.Verbose(); var solutionUniqueName = CommandOptions.SolutionUniqueName(); + var deleteOrphaned = CommandOptions.DeleteOrphanedSteps(); // --project and --assembly-name are mutually exclusive; both optional — validated at runtime cmd.AddOptions(env, config, url, clientId, clientSecret, project, assemblyName, - dryRun, verbose, solutionUniqueName); + dryRun, verbose, solutionUniqueName, deleteOrphaned); cmd.SetHandler((InvocationContext ctx) => { @@ -42,6 +43,7 @@ public static Command Build(ILoggerFactory loggerFactory) var isDryRun = ctx.ParseResult.GetValueForOption(dryRun); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName); + var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned); if (!string.IsNullOrEmpty(projectPath) && !string.IsNullOrEmpty(asmName)) { @@ -93,7 +95,8 @@ public static Command Build(ILoggerFactory loggerFactory) Out.DryRun("— no changes will be made to Dataverse."); var registrar = new StepRegistrar(svc, loggerFactory.CreateLogger()); - var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution, isVerbose); + var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution, + deleteOrphaned: delOrphaned, verbose: isVerbose); Out.SyncSummary(syncResult, isDryRun); diff --git a/src/dvx/Commands/Shared/CommandOptions.cs b/src/dvx/Commands/Shared/CommandOptions.cs index 0ddc22a..f45c0de 100644 --- a/src/dvx/Commands/Shared/CommandOptions.cs +++ b/src/dvx/Commands/Shared/CommandOptions.cs @@ -68,6 +68,12 @@ public static class CommandOptions "Delete web resources that are in the target solution but not in the folder/manifest. " + "Requires a solution. Destructive — run with --dry-run first."); + public static Option DeleteOrphanedSteps() => new Option( + "--delete-orphaned", + "Delete plugin steps registered in Dataverse but no longer present in code. " + + "Steps backing Custom APIs and Custom Actions are never removed. " + + "Destructive — run with --dry-run first."); + public static Option DryRun() => new Option( "--dry-run", "Print what would happen without making any changes to Dataverse."); diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index 92a64db..58bb9df 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -23,9 +23,10 @@ public static Command Build(ILoggerFactory loggerFactory) var dryRun = CommandOptions.DryRun(); var verbose = CommandOptions.Verbose(); var solutionUniqueName = CommandOptions.SolutionUniqueName(); + var deleteOrphaned = CommandOptions.DeleteOrphanedSteps(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix, - dryRun, verbose, solutionUniqueName); + dryRun, verbose, solutionUniqueName, deleteOrphaned); cmd.SetHandler((InvocationContext ctx) => { @@ -39,6 +40,7 @@ public static Command Build(ILoggerFactory loggerFactory) var isDryRun = ctx.ParseResult.GetValueForOption(dryRun); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName); + var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned); try { @@ -78,7 +80,8 @@ public static Command Build(ILoggerFactory loggerFactory) Out.DryRun("— no step changes will be made."); var registrar = new StepRegistrar(svc, loggerFactory.CreateLogger()); - var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution, isVerbose); + var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution, + deleteOrphaned: delOrphaned, verbose: isVerbose); Out.SyncSummary(syncResult, isDryRun); diff --git a/src/dvx/Models/ImageDefinition.cs b/src/dvx/Models/ImageDefinition.cs index 987fe18..3035f32 100644 --- a/src/dvx/Models/ImageDefinition.cs +++ b/src/dvx/Models/ImageDefinition.cs @@ -1,12 +1,15 @@ namespace dvx.Models { - public enum ImageType { Pre = 0, Post = 1 } + public enum ImageType + { + Pre = 0, + Post = 1 + } public class ImageDefinition { - public ImageType ImageType { get; set; } - public string Alias { get; set; } = string.Empty; - public string[] Attributes { get; set; } = Array.Empty(); - // messagepropertyname is always "Target" for standard messages + public ImageType ImageType { get; set; } + public string Alias { get; set; } = string.Empty; + public string[] Attributes { get; set; } = Array.Empty(); } -} +} \ No newline at end of file diff --git a/src/dvx/Services/SdkMetadata.cs b/src/dvx/Services/SdkMetadata.cs index 4349ba2..c4dd625 100644 --- a/src/dvx/Services/SdkMetadata.cs +++ b/src/dvx/Services/SdkMetadata.cs @@ -29,6 +29,7 @@ internal class SdkMetadata(IOrganizationService svc) { private List? _messages; private List? _customApis; + private List? _customActions; private Guid? _systemUserId; public Guid SystemUserId() @@ -82,6 +83,27 @@ public Guid SystemUserId() ColumnSet = new ColumnSet("customapiid", "uniquename", "plugintypeid", "sdkmessageid") }).Entities.ToList(); + /// + /// Gets the Custom Action (process) definitions cached within the instance. + /// + /// + /// Custom Actions are workflow records with category Action (3) and type Definition (1). + /// Each registers an SDK message named after the action's uniquename. + /// + private IReadOnlyList CustomActions => + _customActions ??= svc.RetrieveMultiple(new QueryExpression("workflow") + { + ColumnSet = new ColumnSet("workflowid", "uniquename"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("category", ConditionOperator.Equal, 3), // Action + new ConditionExpression("type", ConditionOperator.Equal, 1), // Definition + } + } + }).Entities.ToList(); + /// /// Maps message names to their corresponding GUIDs. /// @@ -213,6 +235,24 @@ public HashSet CustomApiMessageIds() return set; } + /// + /// Retrieves the set of GUIDs for SDK messages owned by Custom Actions, resolved by matching + /// each action's uniquename to an sdkmessage name. + /// + /// A hash set containing the unique identifiers for SDK messages linked to Custom Actions. + public HashSet CustomActionMessageIds() + { + var messageIdByName = MessageIdByName(); + var set = new HashSet(); + foreach (var e in CustomActions) + { + var uniqueName = e.GetAttributeValue("uniquename"); + if (uniqueName is not null && messageIdByName.TryGetValue(uniqueName, out var id)) + set.Add(id); + } + return set; + } + /// /// Retrieves a dictionary mapping plugin type IDs to their respective type names for a given assembly. /// diff --git a/src/dvx/Services/StepRegistrar.cs b/src/dvx/Services/StepRegistrar.cs index 00f2bdf..0a26093 100644 --- a/src/dvx/Services/StepRegistrar.cs +++ b/src/dvx/Services/StepRegistrar.cs @@ -15,7 +15,8 @@ public class StepRegistrar( private Guid _systemUserId; public SyncResult Sync(Guid assemblyId, IReadOnlyList definitions, - bool dryRun = false, string? solutionUniqueName = null, bool verbose = false) + bool dryRun = false, string? solutionUniqueName = null, + bool deleteOrphaned = false, bool verbose = false) { var result = new SyncResult(); var originalSvc = _svc; @@ -147,15 +148,30 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList defi } } - // ── Orphan deletion ──────────────────────────────────────────────── - foreach (var s in existingSteps) - { - if (consumed.Contains(s.StepId)) - continue; + // ── Orphan handling ───────────────────────────────────────────────── + // Steps that back a Custom API or Custom Action live on this assembly's plugin types + // but are owned by the API/action definition, not by dvx — never treat them as orphans. + var protectedMessageIds = meta.CustomApiMessageIds(); + protectedMessageIds.UnionWith(meta.CustomActionMessageIds()); + + var orphans = existingSteps + .Where(s => !consumed.Contains(s.StepId) && !protectedMessageIds.Contains(s.MessageId)) + .ToList(); - logger.LogInformation("Deleting orphan step: {Name}", s.Name); - _svc.Delete("sdkmessageprocessingstep", s.StepId); - result.Deleted++; + foreach (var orphan in orphans) + { + if (deleteOrphaned) + { + logger.LogInformation("Deleting orphan step: {Name}", orphan.Name); + _svc.Delete("sdkmessageprocessingstep", orphan.StepId); + result.Deleted++; + } + else + { + result.Warnings.Add( + $"Orphaned step '{orphan.Name}' exists in Dataverse but not in code. " + + "Re-run with --delete-orphaned to remove it."); + } } return result; @@ -371,29 +387,35 @@ private static List ReferencedEntityNames(IReadOnlyList(); - foreach (var img in def.Images) + // A message with no valid image property (e.g. Associate) cannot carry images. Leave + // desiredImages empty so any are skipped here; ValidateImages surfaces the warning. + if (messagePropertyName is not null) { - // PostImage only valid on PostOperation (stage 40) - if (img.ImageType == ImageType.Post && def.Stage != 40) + foreach (var img in def.Images) { - logger.LogWarning( - "PostImage requested on stage {Stage} for {Type} — only valid on PostOperation. Skipping.", - PluginStepDefinition.StageName(def.Stage), def.TypeFullName); - continue; - } + // PostImage only valid on PostOperation (stage 40) + if (img.ImageType == ImageType.Post && def.Stage != 40) + { + logger.LogWarning( + "PostImage requested on stage {Stage} for {Type} — only valid on PostOperation. Skipping.", + PluginStepDefinition.StageName(def.Stage), def.TypeFullName); + continue; + } - desiredImages.Add((img.ImageType, img.Alias)); + desiredImages.Add((img.ImageType, img.Alias)); - if (existingImages.TryGetValue((img.ImageType, img.Alias), out var existingId)) - { - _svc.Update(BuildImageEntity(img, stepId, existingId)); - } - else - { - _svc.Create(BuildImageEntity(img, stepId)); + if (existingImages.TryGetValue((img.ImageType, img.Alias), out var existingId)) + { + _svc.Update(BuildImageEntity(img, stepId, messagePropertyName, existingId)); + } + else + { + _svc.Create(BuildImageEntity(img, stepId, messagePropertyName)); + } } } @@ -428,7 +450,7 @@ private void SyncImages(Guid stepId, PluginStepDefinition def) return cache; } - private static Entity BuildImageEntity(ImageDefinition img, Guid stepId, Guid? existingId = null) + private static Entity BuildImageEntity(ImageDefinition img, Guid stepId, string messagePropertyName, Guid? existingId = null) { var e = existingId.HasValue ? new Entity("sdkmessageprocessingstepimage", existingId.Value) @@ -438,7 +460,7 @@ private static Entity BuildImageEntity(ImageDefinition img, Guid stepId, Guid? e e["imagetype"] = new OptionSetValue((int)img.ImageType); e["name"] = img.Alias; e["entityalias"] = img.Alias; - e["messagepropertyname"] = "Target"; + e["messagepropertyname"] = messagePropertyName; e["attributes"] = img.Attributes.Length > 0 ? string.Join(",", img.Attributes) : null; return e; @@ -455,6 +477,10 @@ private bool IsCustomStepAllowed(Guid filterId) private static void ValidateImages(PluginStepDefinition def, SyncResult result) { + if (def.Images.Count > 0 && ImageMessagePropertyName(def.Message) is null) + result.Warnings.Add( + $"Message '{def.Message}' does not support entity images — image(s) on {def.TypeFullName} will be skipped."); + foreach (var img in def.Images) { if (img.ImageType == ImageType.Post && def.Stage != 40) @@ -462,5 +488,29 @@ private static void ValidateImages(PluginStepDefinition def, SyncResult result) $"PostImage on {def.TypeFullName} ({PluginStepDefinition.StageName(def.Stage)}) will be skipped — only valid on PostOperation."); } } + + /// + /// The entity-image message-property name for an SDK message, or null when the message has no + /// valid image property (no image can be registered). Mirrors the mapping used by the + /// XrmToolBox Plugin Registration tool. + /// + private static string? ImageMessagePropertyName(string message) => message switch + { + "Assign" => "Target", + "Create" => "Id", + "CreateMultiple" => "Ids", + "Delete" => "Target", + "DeliverIncoming" => "EmailId", + "DeliverPromote" => "EmailId", + "ExecuteWorkflow" => "Target", + "Merge" => "Target", + "Route" => "Target", + "Send" => "EmailId", + "SetState" => "EntityMoniker", + "SetStateDynamicEntity" => "EntityMoniker", + "Update" => "Target", + "UpdateMultiple" => "Targets", + _ => null, + }; } } \ No newline at end of file diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index 7f396bb..c699cbb 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -7,7 +7,7 @@ true dvx dvx.cli - 1.6.3 + 1.7.0 Byron Matus CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources. default @@ -17,6 +17,10 @@ https://github.com/beyro/dvx-cli package-icon.png MIT + 1.7.0: +- Orphaned plugin step deletion is now opt-in: pass --delete-orphaned to `plugin sync` / `plugin register` to remove steps that exist in Dataverse but are no longer in code. By default, orphans are reported as warnings only. +- Bug fix: Steps backing Custom APIs and Custom Actions are now never removed as orphans. +- Bug fix: entity images on Create steps (and other non-Update messages) failed to register because the image property name was always 'Target'. It is now derived per message (Create -> Id, SetState -> EntityMoniker, etc.); messages with no valid image property skip the image with a warning.