From 5790e2f7a0d7fcebccfd2bdae43419b59e583816 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Wed, 7 Oct 2026 12:53:36 +0200 Subject: [PATCH 01/14] Add plugin build mode plumbing and deployer seam Tasks 1-3 of the bare DLL deployment plan: - Add PluginBuildMode enum, AppConfig.pluginBuildMode, ConfigLoader.ResolvePluginBuildMode, and the --plugin-build-mode CLI option (deploy/sync). - ProjectBuilder: add BuildAssembly which tolerates projects that emit no .nupkg and make BuildResult.NupkgPath nullable; extract a virtual RunBuild seam for testing. - Introduce IPluginDeployer + PluginArtifact and PluginDeployerBase shared skeleton; refactor PackageDeployer onto the base while keeping its existing public Deploy overload. - Add ConfigLoaderTests and ProjectBuilderTests. --- .../plans/2026-07-05-bare-dll-deployment.md | 148 ++++++++++++++++++ src/dvx.Tests/ConfigLoaderTests.cs | 45 ++++++ src/dvx.Tests/ProjectBuilderTests.cs | 84 ++++++++++ src/dvx/Commands/DeployCommand.cs | 4 +- src/dvx/Commands/Shared/CommandOptions.cs | 7 + src/dvx/Commands/SyncCommand.cs | 4 +- src/dvx/Config/ConfigLoader.cs | 8 + src/dvx/Models/AppConfig.cs | 7 + src/dvx/Models/PluginBuildMode.cs | 13 ++ src/dvx/Services/IPluginDeployer.cs | 24 +++ src/dvx/Services/PackageDeployer.cs | 67 +++----- src/dvx/Services/PluginDeployerBase.cs | 46 ++++++ src/dvx/Services/ProjectBuilder.cs | 82 ++++++---- 13 files changed, 464 insertions(+), 75 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-05-bare-dll-deployment.md create mode 100644 src/dvx.Tests/ProjectBuilderTests.cs create mode 100644 src/dvx/Models/PluginBuildMode.cs create mode 100644 src/dvx/Services/IPluginDeployer.cs create mode 100644 src/dvx/Services/PluginDeployerBase.cs diff --git a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md new file mode 100644 index 0000000..630b9ff --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md @@ -0,0 +1,148 @@ +# Bare DLL Plugin Deployment — Implementation Plan + +**Goal:** Let `dvx plugin deploy` and `dvx plugin sync` deploy a bare plugin assembly +(`pluginassembly.content`) as an alternative to the NuGet plugin-package path +(`pluginpackage.content`), without breaking the existing package workflow. + +**Tech Stack:** .NET 8, System.CommandLine, Microsoft.Xrm.Sdk, xUnit, Shouldly, NSubstitute. + +## Locked Decisions + +| # | Decision | +|---|----------| +| A1–A3 | Explicit flag `--plugin-build-mode package\|assembly` on `deploy` and `sync`; defaults to `package` | +| A4 | Config field `pluginBuildMode` (project-level) | +| A5 | CLI wins over config | +| B6 | Assembly mode still runs `dotnet build` | +| B8 | Distinct build method for assemblies; share code where it makes sense | +| C9 | Create the `pluginassembly` record when none exists | +| C10 | Resolve by `name`; throw if more than one match | +| C11 | Set only required attributes; none configurable | +| C12 | `version` from the assembly's `AssemblyVersion` | +| C13 | `isolationmode` fixed at Sandbox (2) | +| C14 | Add the assembly to the target solution | +| D15 | `IPluginDeployer` with `PackageDeployer` + `AssemblyDeployer` | +| D16 | Shared "resolve record → upload content → return assembly id" skeleton | +| D17 | Dry-run reports "would create" and skips | +| E18 | Warn when mode and produced artifacts disagree | +| E19 | Default stays `package` (backward compatible) | +| E20 | No client-side assembly validation — let Dataverse reject | +| F21–F22 | NSubstitute-based tests mirroring `PackageDeployerTests`; add `ProjectBuilder` tests | +| F23 | Update README | + +## Architecture + +``` +DeployCommand / SyncCommand + │ resolve mode (CLI > config), build artifact, pick deployer + ▼ +IPluginDeployer.Deploy(PluginArtifact, verbose, dryRun) → Guid assemblyId + ├── PackageDeployer : pluginpackage.content → child pluginassembly id + └── AssemblyDeployer : pluginassembly.content (create or update) → id + ▼ +StepRegistrar.Sync(assemblyId, …) ← unchanged +``` + +**Shared skeleton** (`PluginDeployerBase`): `ResolveExistingId(...)` → `UploadContent(id, path, verbose)` +→ `ReturnAssemblyId(...)`. `PackageDeployer` keeps its package lookup + child-assembly lookup; +`AssemblyDeployer` overrides create/update and returns the record's own id. + +## Files + +**Create** +- `src/dvx/Models/PluginBuildMode.cs` — `enum PluginBuildMode { Package, Assembly }` +- `src/dvx/Services/IPluginDeployer.cs` — interface + `PluginArtifact` record +- `src/dvx/Services/PluginDeployerBase.cs` — shared skeleton +- `src/dvx/Services/AssemblyDeployer.cs` +- `src/dvx.Tests/AssemblyDeployerTests.cs` +- `src/dvx.Tests/ProjectBuilderTests.cs` + +**Modify** +- `src/dvx/Services/PackageDeployer.cs` — implement `IPluginDeployer`, derive from base +- `src/dvx/Services/ProjectBuilder.cs` — add assembly build path (nullable nupkg / distinct method) +- `src/dvx/Models/AppConfig.cs` — add `PluginBuildMode? PluginBuildMode` +- `src/dvx/Config/ConfigLoader.cs` — add `ResolvePluginBuildMode(config, cliOverride)` +- `src/dvx/Commands/Shared/CommandOptions.cs` — add `PluginBuildMode()` option (`--plugin-build-mode`) +- `src/dvx/Commands/DeployCommand.cs` — wire mode + deployer selection +- `src/dvx/Commands/SyncCommand.cs` — same +- `src/dvx/Services/SolutionService.cs` — add `AddAssemblyToSolution` (component type 91) +- `README.md` + +## Interfaces + +```csharp +public enum PluginBuildMode { Package, Assembly } + +public sealed record PluginArtifact( + string Path, // .nupkg (Package) or .dll (Assembly) + string AssemblyName, // bare assembly name; record name for Assembly mode, stem for Package unique name + string UniqueName, // {prefix}_{assemblyName} — Package lookup key + Version Version); // from the built DLL's AssemblyVersion (Assembly mode) + +public interface IPluginDeployer +{ + Guid Deploy(PluginArtifact artifact, bool verbose = false, bool dryRun = false); +} +``` + +`AssemblyDeployer` sets required `pluginassembly` attributes: +`name = artifact.AssemblyName` (bare), `sourcetype = 0` (Database), `isolationmode = 2` (Sandbox), +`version = artifact.Version.ToString()`, `culture = "neutral"`, `content = base64(dll)`. +`version` via `AssemblyName.GetAssemblyName(dllPath).Version` (metadata-only; no dependency load). + +## Task Breakdown + +All tasks follow **TDD**: write the failing test(s) first, run them to confirm they fail, then +implement until green. Task 3 is a pure refactor, so its "red" step is the existing +`PackageDeployerTests` passing before and after. + +### Task 1 — `PluginBuildMode` enum + config plumbing +- [x] **Red:** Write `ConfigLoaderTests` for `ResolvePluginBuildMode` (CLI wins, config fallback, + default Package) and run `dotnet test --filter ConfigLoaderTests` — confirm failure. +- [x] **Green:** Add enum, `AppConfig.PluginBuildMode`, `ConfigLoader.ResolvePluginBuildMode`, + `CommandOptions.PluginBuildMode()`. +- [x] Run `dotnet test --filter ConfigLoaderTests` — confirm pass. + +### Task 2 — `ProjectBuilder` assembly build path +- [x] **Red:** Write `ProjectBuilderTests`: DLL-only project returns artifact without requiring + `.nupkg`; package project still returns both; missing DLL still throws. Run + `dotnet test --filter ProjectBuilderTests` — confirm failure. +- [x] **Green:** Add a build method that tolerates a missing `.nupkg` (share `RunDotnet`/`FindBuiltDll`). +- [x] Run `dotnet test --filter ProjectBuilderTests` — confirm pass. + +### Task 3 — `IPluginDeployer` + shared base (refactor) +- [x] **Baseline:** Run `dotnet test --filter PackageDeployerTests` — confirm existing tests pass. +- [x] Define interface + `PluginArtifact`. +- [x] Extract shared skeleton; refactor `PackageDeployer` to implement it. +- [x] Re-run `dotnet test --filter PackageDeployerTests` — confirm still pass (no behaviour change). + +### Task 4 — `AssemblyDeployer` +- [ ] **Red:** Write `AssemblyDeployerTests` (NSubstitute `IOrganizationService`): + create when absent; update when present; multiple matches throws; dry-run create reports + "would create" and issues no `Create`/`Update`; returns record id. Run + `dotnet test --filter AssemblyDeployerTests` — confirm failure. +- [ ] **Green:** Implement resolve-by-name (throw on >1), create/update `content`, return id. +- [ ] Run `dotnet test --filter AssemblyDeployerTests` — confirm pass. + +### Task 5 — Solution membership +- [ ] **Red:** Add a test asserting the `AddSolutionComponent` request shape for + `AddAssemblyToSolution` (component type 91) — confirm failure. +- [ ] **Green:** Add `SolutionService.AddAssemblyToSolution` (component type 91). +- [ ] Run the solution-service tests — confirm pass. + +### Task 6 — Command wiring +- [ ] **Red:** Add command-level tests for mode selection (if feasible with current test seams) — + confirm failure. +- [ ] **Green:** `DeployCommand` / `SyncCommand`: resolve mode, build accordingly, select deployer, + warn on artifact/mode mismatch (E18), add assembly to solution when `--solution-unique-name` set. +- [ ] Run the command tests — confirm pass. + +### Task 7 — Docs +- [ ] README: document `--plugin-build-mode`, `pluginBuildMode` config field, assembly-mode + create/update semantics, and solution-membership behaviour. + +## Verification +- `dotnet build dvx.sln` +- `dotnet test` +- Manual: `dvx plugin deploy --project ./MyPlugin.csproj --plugin-build-mode assembly --dry-run` + then without `--dry-run` against a dev org; re-run to confirm update path. diff --git a/src/dvx.Tests/ConfigLoaderTests.cs b/src/dvx.Tests/ConfigLoaderTests.cs index f6b0972..7937aea 100644 --- a/src/dvx.Tests/ConfigLoaderTests.cs +++ b/src/dvx.Tests/ConfigLoaderTests.cs @@ -291,6 +291,51 @@ public void ResolveSolutionUniqueName_NeitherSet_ReturnsNull() ConfigLoader.ResolveSolutionUniqueName(config, " ").ShouldBeNull(); } + // ── pluginBuildMode ──────────────────────────────────────────────────── + + [Fact] + public void Load_WithPluginBuildMode_PopulatesField() + { + var path = WriteConfig("""{ "environments": [], "pluginBuildMode": "assembly" }"""); + ConfigLoader.TryLoad(path)!.PluginBuildMode.ShouldBe(PluginBuildMode.Assembly); + } + + [Fact] + public void Load_WithoutPluginBuildMode_IsNull() + { + var path = WriteConfig("""{ "environments": [] }"""); + ConfigLoader.TryLoad(path)!.PluginBuildMode.ShouldBeNull(); + } + + [Fact] + public void ResolvePluginBuildMode_CliOverrideTakesPriority() + { + var path = WriteConfig("""{ "environments": [], "pluginBuildMode": "package" }"""); + var config = ConfigLoader.TryLoad(path); + + ConfigLoader.ResolvePluginBuildMode(config, PluginBuildMode.Assembly) + .ShouldBe(PluginBuildMode.Assembly); + } + + [Fact] + public void ResolvePluginBuildMode_FallsBackToConfig() + { + var path = WriteConfig("""{ "environments": [], "pluginBuildMode": "assembly" }"""); + var config = ConfigLoader.TryLoad(path); + + ConfigLoader.ResolvePluginBuildMode(config, null).ShouldBe(PluginBuildMode.Assembly); + } + + [Fact] + public void ResolvePluginBuildMode_NeitherSet_DefaultsToPackage() + { + var path = WriteConfig("""{ "environments": [] }"""); + var config = ConfigLoader.TryLoad(path); + + ConfigLoader.ResolvePluginBuildMode(config, null).ShouldBe(PluginBuildMode.Package); + ConfigLoader.ResolvePluginBuildMode(null, null).ShouldBe(PluginBuildMode.Package); + } + // ── defaultEnvironment ───────────────────────────────────────────────── [Fact] diff --git a/src/dvx.Tests/ProjectBuilderTests.cs b/src/dvx.Tests/ProjectBuilderTests.cs new file mode 100644 index 0000000..5f55764 --- /dev/null +++ b/src/dvx.Tests/ProjectBuilderTests.cs @@ -0,0 +1,84 @@ +using dvx.Services; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class ProjectBuilderTests : IDisposable + { + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"dvx-pb-{Guid.NewGuid():N}"); + private readonly string _projectPath; + + public ProjectBuilderTests() + { + Directory.CreateDirectory(Path.Combine(_tempDir, "bin", "Release")); + _projectPath = Path.Combine(_tempDir, "MyPlugin.csproj"); + File.WriteAllText(_projectPath, ""); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private string ReleaseDir => Path.Combine(_tempDir, "bin", "Release"); + + // Skips the real 'dotnet build' so tests exercise only artifact discovery. + private sealed class NoBuildProjectBuilder : ProjectBuilder + { + protected override void RunBuild(string projectPath) { } + } + + [Fact] + public void BuildAssembly_DllOnly_ReturnsArtifactWithoutRequiringNupkg() + { + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.dll"), "dll"); + + var result = new NoBuildProjectBuilder().BuildAssembly(_projectPath); + + result.DllPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.dll")); + result.NupkgPath.ShouldBeNull(); + } + + [Fact] + public void BuildAssembly_WithNupkg_ReturnsBoth() + { + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg"), "pkg"); + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.dll"), "dll"); + + var result = new NoBuildProjectBuilder().BuildAssembly(_projectPath); + + result.NupkgPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg")); + result.DllPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.dll")); + } + + [Fact] + public void Build_PackageProject_ReturnsBothArtifacts() + { + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg"), "pkg"); + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.dll"), "dll"); + + var result = new NoBuildProjectBuilder().Build(_projectPath); + + result.NupkgPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg")); + result.DllPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.dll")); + } + + [Fact] + public void Build_PackageProject_MissingNupkg_Throws() + { + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.dll"), "dll"); + + Should.Throw(() => new NoBuildProjectBuilder().Build(_projectPath)); + } + + [Fact] + public void BuildAssembly_MissingDll_Throws() + { + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg"), "pkg"); + + Should.Throw(() => new NoBuildProjectBuilder().BuildAssembly(_projectPath)); + } + } +} diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 399db46..9ac532d 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -56,12 +56,12 @@ public static Command Build() Out.Step("Building", resolvedProject); var build = new ProjectBuilder().Build(resolvedProject); var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - Out.Success("Built", Path.GetFileName(build.NupkgPath)); + Out.Success("Built", Path.GetFileName(build.NupkgPath)!); Out.Step("Deploying", $"to {envConfig.Url}"); var uniqueName = $"{prefix}_{assemblyName}"; var deployer = new PackageDeployer(svc); - var assemblyId = deployer.Deploy(build.NupkgPath, uniqueName, isVerbose); + var assemblyId = deployer.Deploy(build.NupkgPath!, uniqueName, isVerbose); Out.Success("Deployed.", $"Assembly ID: {assemblyId}"); } diff --git a/src/dvx/Commands/Shared/CommandOptions.cs b/src/dvx/Commands/Shared/CommandOptions.cs index ac95ca4..bcd9b4f 100644 --- a/src/dvx/Commands/Shared/CommandOptions.cs +++ b/src/dvx/Commands/Shared/CommandOptions.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using dvx.Models; namespace dvx.Commands.Shared { @@ -39,6 +40,12 @@ public static class CommandOptions "Path to the plugin .csproj file. The tool will run 'dotnet build' to produce the .nupkg and .dll. " + "If omitted, falls back to the 'project' field in config, then a single .csproj in the current directory."); + public static Option PluginBuildMode() => new Option( + "--plugin-build-mode", + "Deployment target: 'package' uploads the .nupkg to a pluginpackage record (default); " + + "'assembly' uploads the .dll directly to a pluginassembly record. " + + "Falls back to pluginBuildMode in config."); + public static Option PublisherPrefix() => new Option( "--publisher-prefix", "Dataverse publisher customization prefix (e.g. 'solu'). Required for package " + diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index 533a6f0..d64adc0 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -62,13 +62,13 @@ public static Command Build(ILoggerFactory loggerFactory) Out.Step("Building", resolvedProject); var build = new ProjectBuilder().Build(resolvedProject); var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - Out.Success("Built", Path.GetFileName(build.NupkgPath)); + Out.Success("Built", Path.GetFileName(build.NupkgPath)!); // ── Deploy ────────────────────────────────────────────── Out.Step("Deploying", $"to {envConfig.Url}"); var uniqueName = $"{prefix}_{assemblyName}"; var deployer = new PackageDeployer(svc); - var assemblyId = deployer.Deploy(build.NupkgPath, uniqueName, isVerbose, isDryRun); + var assemblyId = deployer.Deploy(build.NupkgPath!, uniqueName, isVerbose, isDryRun); Out.Success(isDryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", $"Assembly ID: {assemblyId}"); diff --git a/src/dvx/Config/ConfigLoader.cs b/src/dvx/Config/ConfigLoader.cs index 37c85e9..6c9941d 100644 --- a/src/dvx/Config/ConfigLoader.cs +++ b/src/dvx/Config/ConfigLoader.cs @@ -161,6 +161,14 @@ public static EnvironmentConfig ResolveEnvironmentConfig( : !string.IsNullOrWhiteSpace(config?.SolutionUniqueName) ? config.SolutionUniqueName : null; + /// + /// Resolves the plugin build mode from --plugin-build-mode > pluginBuildMode + /// in config, defaulting to when neither is set + /// (preserving the existing package-based workflow). + /// + public static PluginBuildMode ResolvePluginBuildMode(AppConfig? config, PluginBuildMode? cliOverride) + => cliOverride ?? config?.PluginBuildMode ?? PluginBuildMode.Package; + // ── Web resource resolvers ───────────────────────────────────────────── public static string? ResolveWebResourceFolder(AppConfig? config, string? cliOverride) diff --git a/src/dvx/Models/AppConfig.cs b/src/dvx/Models/AppConfig.cs index 009b5f9..1487202 100644 --- a/src/dvx/Models/AppConfig.cs +++ b/src/dvx/Models/AppConfig.cs @@ -39,6 +39,13 @@ public class AppConfig /// public string? Project { get; set; } + /// + /// Which record dvx plugin deploy/sync uploads the build artifact to: + /// package (pluginpackage, default) or assembly (pluginassembly). + /// Can be overridden per-command with --plugin-build-mode. + /// + public PluginBuildMode? PluginBuildMode { get; set; } + /// /// Optional defaults for dvx webresource sync (source folder/manifest, name prefix, /// publish behaviour). diff --git a/src/dvx/Models/PluginBuildMode.cs b/src/dvx/Models/PluginBuildMode.cs new file mode 100644 index 0000000..7f3aa82 --- /dev/null +++ b/src/dvx/Models/PluginBuildMode.cs @@ -0,0 +1,13 @@ +namespace dvx.Models +{ + /// + /// Selects which Dataverse record the plugin build artifact is deployed to: + /// uploads the .nupkg to a pluginpackage record, + /// uploads the .dll directly to a pluginassembly record. + /// + public enum PluginBuildMode + { + Package, + Assembly, + } +} diff --git a/src/dvx/Services/IPluginDeployer.cs b/src/dvx/Services/IPluginDeployer.cs new file mode 100644 index 0000000..af27e5a --- /dev/null +++ b/src/dvx/Services/IPluginDeployer.cs @@ -0,0 +1,24 @@ +namespace dvx.Services +{ + /// + /// A built plugin artifact plus the Dataverse metadata needed to deploy it. + /// + /// The file to upload — a .nupkg (Package mode) or .dll (Assembly mode). + /// Bare assembly name; the record name in Assembly mode, the DLL stem in Package mode. + /// {prefix}_{assemblyName} — the lookup key in Package mode. + /// The assembly AssemblyVersion (Assembly mode only). + public sealed record PluginArtifact( + string Path, + string AssemblyName, + string UniqueName, + Version? Version); + + /// + /// Deploys a built plugin artifact to Dataverse and returns the pluginassembly id + /// used for downstream plugin step registration. + /// + public interface IPluginDeployer + { + Guid Deploy(PluginArtifact artifact, bool verbose = false, bool dryRun = false); + } +} diff --git a/src/dvx/Services/PackageDeployer.cs b/src/dvx/Services/PackageDeployer.cs index b6879e9..b3a436e 100644 --- a/src/dvx/Services/PackageDeployer.cs +++ b/src/dvx/Services/PackageDeployer.cs @@ -9,72 +9,53 @@ namespace dvx.Services /// .nupkg to the content column of the existing pluginpackage record /// (the same mechanism pac plugin push uses internally — no external CLI required). /// Only supports updating an existing package — the initial upload must be performed - /// once manually (e.g. with the Plugin Registration Tool). After a successful update, - /// returns the child pluginassembly ID for step registration. + /// once manually (e.g. with the Plugin Registration Tool). After a successful update, returns + /// the child pluginassembly ID for step registration. /// - public class PackageDeployer + public class PackageDeployer : PluginDeployerBase { - private readonly IOrganizationService _svc; - - public PackageDeployer(IOrganizationService svc) => _svc = svc; - - // ── Public API ───────────────────────────────────────────────────────── + public PackageDeployer(IOrganizationService svc) : base(svc) { } /// - /// Looks up the existing pluginpackage record, uploads the new package content - /// via an call, then returns the child - /// pluginassembly ID. Dataverse re-extracts the assemblies and plugin types - /// from the uploaded package automatically. + /// Backward-compatible entry point taking the raw package paths used by the commands. + /// Delegates to the shared skeleton. /// public Guid Deploy(string nupkgPath, string packageUniqueName, bool verbose = false, bool dryRun = false) - { - var packageId = FindExistingPackage(packageUniqueName) - ?? throw new InvalidOperationException( - $"Plugin package '{packageUniqueName}' was not found in Dataverse. " + - "The initial upload must be done once manually (e.g. with the Plugin " + - "Registration Tool). Once the record exists, dvx can push updates to it."); + => Deploy(new PluginArtifact(nupkgPath, packageUniqueName, packageUniqueName, null), verbose, dryRun); - if (!dryRun) - { - UploadContent(nupkgPath, packageId, verbose); - } - - return FindAssemblyInPackage(packageId, packageUniqueName); - } + // ── Skeleton hooks ───────────────────────────────────────────────────── - // ── Upload ───────────────────────────────────────────────────────────── + protected override Guid? ResolveExistingId(PluginArtifact artifact, bool verbose) + => FindIdByUniqueName("pluginpackage", artifact.UniqueName) + ?? throw new InvalidOperationException( + $"Plugin package '{artifact.UniqueName}' was not found in Dataverse. " + + "The initial upload must be done once manually (e.g. with the Plugin " + + "Registration Tool). Once the record exists, dvx can push updates to it."); - private void UploadContent(string nupkgPath, Guid packageId, bool verbose) + protected override Guid? UploadContent(Guid? existingId, PluginArtifact artifact, bool verbose) { - var bytes = File.ReadAllBytes(nupkgPath); + var bytes = File.ReadAllBytes(artifact.Path); if (verbose) - Out.Dim($" Uploading {bytes.Length / 1024} KB to pluginpackage {packageId}"); + Out.Dim($" Uploading {bytes.Length / 1024} KB to pluginpackage {existingId}"); Out.SubStep("Uploading package content..."); // Only set content — name/uniquename/version are immutable once the package // exists; Dataverse re-extracts the assemblies and plugin types from the new // content on update. - _svc.Update(new Entity("pluginpackage", packageId) + Svc.Update(new Entity("pluginpackage", existingId!.Value) { ["content"] = Convert.ToBase64String(bytes), }); + + return existingId; } - // ── Dataverse queries ────────────────────────────────────────────────── + protected override Guid ReturnAssemblyId(Guid? id, PluginArtifact artifact) + => FindAssemblyInPackage(id!.Value, artifact.UniqueName); - private Guid? FindExistingPackage(string uniqueName) - { - var query = new QueryExpression("pluginpackage") - { - ColumnSet = new ColumnSet("pluginpackageid"), - Criteria = new FilterExpression(), - }; - query.Criteria.AddCondition("uniquename", ConditionOperator.Equal, uniqueName); - var result = _svc.RetrieveMultiple(query); - return result.Entities.Count > 0 ? result.Entities[0].Id : null; - } + // ── Dataverse queries ────────────────────────────────────────────────── private Guid FindAssemblyInPackage(Guid packageId, string packageUniqueName) { @@ -84,7 +65,7 @@ private Guid FindAssemblyInPackage(Guid packageId, string packageUniqueName) Criteria = new FilterExpression(), }; query.Criteria.AddCondition("packageid", ConditionOperator.Equal, packageId); - var result = _svc.RetrieveMultiple(query); + var result = Svc.RetrieveMultiple(query); if (result.Entities.Count == 0) throw new InvalidOperationException( diff --git a/src/dvx/Services/PluginDeployerBase.cs b/src/dvx/Services/PluginDeployerBase.cs new file mode 100644 index 0000000..da1347e --- /dev/null +++ b/src/dvx/Services/PluginDeployerBase.cs @@ -0,0 +1,46 @@ +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace dvx.Services +{ + /// + /// Shared deployment skeleton for the two Dataverse plugin targets: + /// resolve the target record → upload the artifact content (skipped on dry runs) → + /// return the pluginassembly id used for downstream step registration. + /// + public abstract class PluginDeployerBase : IPluginDeployer + { + protected IOrganizationService Svc { get; } + + protected PluginDeployerBase(IOrganizationService svc) => Svc = svc; + + public Guid Deploy(PluginArtifact artifact, bool verbose = false, bool dryRun = false) + { + var existingId = ResolveExistingId(artifact, verbose); + var id = dryRun ? existingId : UploadContent(existingId, artifact, verbose); + return ReturnAssemblyId(id, artifact); + } + + /// Resolves the existing target record id, or null when none exists (create path). + protected abstract Guid? ResolveExistingId(PluginArtifact artifact, bool verbose); + + /// Uploads the artifact content, creating a new record when is null. + protected abstract Guid? UploadContent(Guid? existingId, PluginArtifact artifact, bool verbose); + + /// Returns the assembly id downstream step registration should target. + protected abstract Guid ReturnAssemblyId(Guid? id, PluginArtifact artifact); + + /// Finds the id of the record whose uniquename matches, or null. + protected Guid? FindIdByUniqueName(string entityName, string uniqueName) + { + var query = new QueryExpression(entityName) + { + ColumnSet = new ColumnSet($"{entityName}id"), + Criteria = new FilterExpression(), + }; + query.Criteria.AddCondition("uniquename", ConditionOperator.Equal, uniqueName); + var result = Svc.RetrieveMultiple(query); + return result.Entities.Count > 0 ? result.Entities[0].Id : null; + } + } +} diff --git a/src/dvx/Services/ProjectBuilder.cs b/src/dvx/Services/ProjectBuilder.cs index a8f312a..259611c 100644 --- a/src/dvx/Services/ProjectBuilder.cs +++ b/src/dvx/Services/ProjectBuilder.cs @@ -2,45 +2,64 @@ namespace dvx.Services { - public record BuildResult(string NupkgPath, string DllPath); + public record BuildResult(string? NupkgPath, string DllPath); public class ProjectBuilder { /// - /// Runs dotnet build on the given .csproj (Release config). - /// Plugin package projects created by pac plugin init emit a .nupkg - /// alongside the DLL as part of the standard build — no separate pack step needed. - /// Returns paths to both files for upload and reflection-based step discovery. + /// Runs dotnet build on the given .csproj (Release config) and returns the emitted + /// artifact paths. Plugin package projects created by pac plugin init emit a + /// .nupkg alongside the DLL as part of the standard build — no separate pack step + /// needed. Throws when no .nupkg is produced (this is the Package-mode path). /// public BuildResult Build(string projectPath) { - if (!File.Exists(projectPath)) - throw new FileNotFoundException($"Project file not found: '{projectPath}'", projectPath); - - RunDotnet($"build \"{projectPath}\" --configuration Release --nologo"); + EnsureProjectExists(projectPath); + RunBuild(projectPath); return new BuildResult(FindNupkg(projectPath), FindBuiltDll(projectPath)); } - private static string FindNupkg(string projectPath) + /// + /// Runs dotnet build (Release config) and returns the DLL, tolerating projects that + /// emit no .nupkg (bare plugin assemblies deployed straight to pluginassembly). + /// The .nupkg path is null when the project does not produce one. + /// + public BuildResult BuildAssembly(string projectPath) { - var projectDir = Path.GetDirectoryName(Path.GetFullPath(projectPath))!; - var projectName = Path.GetFileNameWithoutExtension(projectPath); - var releaseDir = Path.Combine(projectDir, "bin", "Release"); + EnsureProjectExists(projectPath); + RunBuild(projectPath); - if (!Directory.Exists(releaseDir)) - throw new InvalidOperationException( - $"Build output directory not found: '{releaseDir}'. " + - "Ensure the project built successfully."); + return new BuildResult(FindNupkgOrNull(projectPath), FindBuiltDll(projectPath)); + } - var candidates = Directory.GetFiles(releaseDir, "*.nupkg", SearchOption.AllDirectories); + private static void EnsureProjectExists(string projectPath) + { + if (!File.Exists(projectPath)) + throw new FileNotFoundException($"Project file not found: '{projectPath}'", projectPath); + } - if (candidates.Length == 0) - throw new InvalidOperationException( - $"No .nupkg found under '{releaseDir}'. " + + /// Runs the Release build. Overridable so tests can exercise artifact discovery + /// without invoking the real dotnet process. + protected virtual void RunBuild(string projectPath) + => RunDotnet($"build \"{projectPath}\" --configuration Release --nologo"); + + private static string FindNupkg(string projectPath) + => FindNupkgOrNull(projectPath) + ?? throw new InvalidOperationException( + $"No .nupkg found under '{ReleaseDirectory(projectPath)}'. " + "Ensure the project is a Dataverse plugin package project created with 'pac plugin init'. " + "The build should automatically emit a .nupkg alongside the DLL."); + private static string? FindNupkgOrNull(string projectPath) + { + var releaseDir = ReleaseDirectory(projectPath); + var projectName = Path.GetFileNameWithoutExtension(projectPath); + + var candidates = Directory.GetFiles(releaseDir, "*.nupkg", SearchOption.AllDirectories); + if (candidates.Length == 0) + return null; + // Prefer the nupkg whose versioned stem starts with the project name, e.g. // MyPlugin.1.0.0.nupkg → stem "MyPlugin.1.0.0" starts with "MyPlugin". // Falls back to the first candidate if nothing matches. @@ -57,14 +76,8 @@ private static string FindNupkg(string projectPath) /// private static string FindBuiltDll(string projectPath) { - var projectDir = Path.GetDirectoryName(Path.GetFullPath(projectPath))!; + var releaseDir = ReleaseDirectory(projectPath); var projectName = Path.GetFileNameWithoutExtension(projectPath); - var releaseDir = Path.Combine(projectDir, "bin", "Release"); - - if (!Directory.Exists(releaseDir)) - throw new InvalidOperationException( - $"Build output directory not found: '{releaseDir}'. " + - "Ensure the project built successfully."); var candidates = Directory.GetFiles(releaseDir, $"{projectName}.dll", SearchOption.AllDirectories); @@ -77,6 +90,19 @@ private static string FindBuiltDll(string projectPath) return candidates[0]; } + private static string ReleaseDirectory(string projectPath) + { + var projectDir = Path.GetDirectoryName(Path.GetFullPath(projectPath))!; + var releaseDir = Path.Combine(projectDir, "bin", "Release"); + + if (!Directory.Exists(releaseDir)) + throw new InvalidOperationException( + $"Build output directory not found: '{releaseDir}'. " + + "Ensure the project built successfully."); + + return releaseDir; + } + private static void RunDotnet(string args) { var psi = new ProcessStartInfo("dotnet", args) From bbe421c61233cd3931644b3aad3cc1e325e292ba Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Wed, 7 Oct 2026 12:59:39 +0200 Subject: [PATCH 02/14] Add bare DLL plugin deployment (assembly mode) Tasks 4-7 of the bare DLL deployment plan: - AssemblyDeployer: create or update a pluginassembly from the built .dll, resolve by name (throw on duplicates), dry-run reports "would create". - SolutionService.AddAssemblyToSolution (component type 91). - PluginDeploymentPlan: maps requested mode + build output to the artifact deployed, warning when they disagree (E18). - Wire plugin deploy/sync: resolve mode, select deployer, warn on mismatch, add the assembly to the target solution; add --dry-run to plugin deploy. - README: document --plugin-build-mode, pluginBuildMode, assembly-mode semantics and solution membership. - Tests: AssemblyDeployerTests, PluginDeploymentPlanTests, AddAssemblyToSolution. --- README.md | 47 +++++-- .../plans/2026-07-05-bare-dll-deployment.md | 20 +-- src/dvx.Tests/AssemblyDeployerTests.cs | 124 ++++++++++++++++++ src/dvx.Tests/PluginDeploymentPlanTests.cs | 55 ++++++++ src/dvx.Tests/SolutionServiceTests.cs | 22 +++- src/dvx/Commands/DeployCommand.cs | 32 ++++- .../Commands/Shared/PluginDeploymentPlan.cs | 29 ++++ src/dvx/Commands/SyncCommand.cs | 36 ++++- src/dvx/Services/AssemblyDeployer.cs | 85 ++++++++++++ src/dvx/Services/SolutionService.cs | 3 + 10 files changed, 421 insertions(+), 32 deletions(-) create mode 100644 src/dvx.Tests/AssemblyDeployerTests.cs create mode 100644 src/dvx.Tests/PluginDeploymentPlanTests.cs create mode 100644 src/dvx/Commands/Shared/PluginDeploymentPlan.cs create mode 100644 src/dvx/Services/AssemblyDeployer.cs diff --git a/README.md b/README.md index b4ea743..d01e9d2 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ If no file is found, connection details must be supplied entirely via CLI option ], "publisherPrefix": "yourprefix", "solutionUniqueName": "MySolution", + "pluginBuildMode": "package", "webResources": { "folder": "./WebResources", "manifest": "./webresources.json", @@ -204,6 +205,7 @@ If no file is found, connection details must be supplied entirely via CLI option | `authType` | | `clientSecret` (default) or `interactive`. `interactive` signs in through the browser for local development and needs only `url` (no `clientId` / `clientSecret`) — see [Interactive login](#interactive-login-local-development) | | `publisherPrefix` | when no solution given | Dataverse publisher customization prefix (e.g. `"pub"`). Used to form the `pluginpackage` unique name (`{prefix}_{assemblyName}`) and to prefix folder-derived web-resource names. **Fallback only** — when a solution is provided, its publisher's prefix is used instead (and this value, if also set, is ignored with a warning). Can be supplied via `--publisher-prefix`. | | `solutionUniqueName` | | Unique name of the Dataverse solution to add deployed components (plugin steps / web resources) to. **Authoritative for the customization prefix**: when set, the prefix is read from this solution's publisher rather than `publisherPrefix`. Can be overridden per-command with `--solution-unique-name`. | +| `pluginBuildMode` | | Which record `plugin deploy` / `plugin sync` upload the build artifact to: `package` (default — the `.nupkg` on a `pluginpackage`) or `assembly` (the `.dll` on a `pluginassembly`). Can be overridden per-command with `--plugin-build-mode`. See [Plugin deployment modes](#plugin-deployment-modes). | | `webResources` | | Defaults for `webresource sync`: `folder`, `manifest`, and `publish` (default `true`). See [Web resources](#web-resources). | ### Connection value resolution @@ -358,6 +360,25 @@ var postImage = context.PostEntityImages["PostImage"]; // UsePostImage = true > (`--env` / `--url` / `--client-id` / `--client-secret` / `--interactive-auth`), `--config`, `--dry-run`, > and `--verbose` are shared across all commands. +### Plugin deployment modes + +`plugin deploy` and `plugin sync` can push a plugin to Dataverse in one of two ways, selected with +`--plugin-build-mode` (or the `pluginBuildMode` config field): + +| Mode | Artifact | Target record | Semantics | +|---|---|---|---| +| `package` (default) | `.nupkg` | `pluginpackage` | Updates an existing package's `content`. The initial upload must be done once manually. Backward compatible. | +| `assembly` | `.dll` | `pluginassembly` | Creates the assembly record when no assembly with the same `name` exists, otherwise updates its `content`. `version` comes from the built DLL, `isolationmode` is fixed at Sandbox. | + +Both modes still run `dotnet build` (Release). CLI `--plugin-build-mode` wins over `pluginBuildMode` in config. + +**When the mode and the build output disagree** — e.g. `package` mode selected but the project +emitted no `.nupkg`, or `assembly` mode on a project that also produced one — dvx prints a warning +and deploys the artifact that is actually available. + +When `--solution-unique-name` (or `solutionUniqueName`) is set, **assembly** mode adds the deployed +assembly to that solution as a component. + ### plugin sync > Build, deploy, and register steps in a single operation. **This is the plugin command you'll use most.** @@ -375,6 +396,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 | +| `--plugin-build-mode` | | from config | Deploy target: `package` (default) or `assembly`. Falls back to `pluginBuildMode` in config. See [Plugin deployment modes](#plugin-deployment-modes) | | `--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 | @@ -383,16 +405,16 @@ dvx plugin sync --project [options] **What it does:** 1. Runs `dotnet build` on the `.csproj` to produce a `.nupkg` and `.dll` -2. Looks up the existing `pluginpackage` record by `uniquename` (`{prefix}_{assemblyName}`) -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. 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. +2. In **package** mode: looks up the existing `pluginpackage` by `uniquename` (`{prefix}_{assemblyName}`), updates its `content`, and reads the child `pluginassembly` ID. In **assembly** mode: creates (or updates) the `pluginassembly` by `name` from the built `.dll` and uses its own ID +3. Reflects the `.dll` for `[PluginStep]` attributes +4. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps (removed only when `--delete-orphaned` is passed) +5. Syncs `sdkmessageprocessingstepimage` records (pre/post images) for each step + +> **Note:** in **package** mode, `sync` and `deploy` only support **updating** an existing plugin package. > For the very first upload, register the package once with the Plugin Registration Tool. > After that, dvx handles all subsequent updates itself using the Dataverse SDK. +> **Assembly** mode has no such restriction — the `pluginassembly` record is created automatically +> on first deploy. **Examples:** @@ -433,6 +455,9 @@ dvx plugin deploy --project [options] | `--url` | | env var / config | Dataverse environment URL | | `--client-id` | | env var / config | Service principal client ID | | `--client-secret` | | env var / config | Service principal client secret | +| `--solution-unique-name` | | from config | Add the deployed assembly to this Dataverse solution (assembly mode) | +| `--plugin-build-mode` | | from config | Deploy target: `package` (default) or `assembly`. Falls back to `pluginBuildMode` in config. See [Plugin deployment modes](#plugin-deployment-modes) | +| `--dry-run` | | | Print what would happen without writing to Dataverse | | `--config` | | auto-discovered | Path to config file | | `--verbose` | | | Log upload details + inner exception details on error | @@ -445,6 +470,12 @@ or when step registrations are managed separately. dvx plugin deploy --env uat --project ./src/MyPlugin/MyPlugin.csproj ``` +Deploy a bare plugin assembly without a NuGet package: + +``` +dvx plugin deploy --project ./src/MyPlugin/MyPlugin.csproj --plugin-build-mode assembly +``` + --- ### plugin register diff --git a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md index 630b9ff..fe471a0 100644 --- a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md +++ b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md @@ -117,28 +117,28 @@ implement until green. Task 3 is a pure refactor, so its "red" step is the exist - [x] Re-run `dotnet test --filter PackageDeployerTests` — confirm still pass (no behaviour change). ### Task 4 — `AssemblyDeployer` -- [ ] **Red:** Write `AssemblyDeployerTests` (NSubstitute `IOrganizationService`): +- [x] **Red:** Write `AssemblyDeployerTests` (NSubstitute `IOrganizationService`): create when absent; update when present; multiple matches throws; dry-run create reports "would create" and issues no `Create`/`Update`; returns record id. Run `dotnet test --filter AssemblyDeployerTests` — confirm failure. -- [ ] **Green:** Implement resolve-by-name (throw on >1), create/update `content`, return id. -- [ ] Run `dotnet test --filter AssemblyDeployerTests` — confirm pass. +- [x] **Green:** Implement resolve-by-name (throw on >1), create/update `content`, return id. +- [x] Run `dotnet test --filter AssemblyDeployerTests` — confirm pass. ### Task 5 — Solution membership -- [ ] **Red:** Add a test asserting the `AddSolutionComponent` request shape for +- [x] **Red:** Add a test asserting the `AddSolutionComponent` request shape for `AddAssemblyToSolution` (component type 91) — confirm failure. -- [ ] **Green:** Add `SolutionService.AddAssemblyToSolution` (component type 91). -- [ ] Run the solution-service tests — confirm pass. +- [x] **Green:** Add `SolutionService.AddAssemblyToSolution` (component type 91). +- [x] Run the solution-service tests — confirm pass. ### Task 6 — Command wiring -- [ ] **Red:** Add command-level tests for mode selection (if feasible with current test seams) — - confirm failure. -- [ ] **Green:** `DeployCommand` / `SyncCommand`: resolve mode, build accordingly, select deployer, +- [x] **Red:** Add command-level tests for mode selection (via the extracted `PluginDeploymentPlan` + seam) — confirm failure. +- [x] **Green:** `DeployCommand` / `SyncCommand`: resolve mode, build accordingly, select deployer, warn on artifact/mode mismatch (E18), add assembly to solution when `--solution-unique-name` set. - [ ] Run the command tests — confirm pass. ### Task 7 — Docs -- [ ] README: document `--plugin-build-mode`, `pluginBuildMode` config field, assembly-mode +- [x] README: document `--plugin-build-mode`, `pluginBuildMode` config field, assembly-mode create/update semantics, and solution-membership behaviour. ## Verification diff --git a/src/dvx.Tests/AssemblyDeployerTests.cs b/src/dvx.Tests/AssemblyDeployerTests.cs new file mode 100644 index 0000000..f6cdfa7 --- /dev/null +++ b/src/dvx.Tests/AssemblyDeployerTests.cs @@ -0,0 +1,124 @@ +using dvx.Services; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class AssemblyDeployerTests + { + private const string AssemblyName = "MyPlugin"; + + private static PluginArtifact Artifact(string dllPath) => + new(dllPath, AssemblyName, AssemblyName, new Version(1, 2, 3, 4)); + + private static IOrganizationService BuildSvc(params Guid[] existingIds) + { + var svc = Substitute.For(); + var entities = existingIds.Select(id => new Entity("pluginassembly", id)).ToList(); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "pluginassembly")) + .Returns(new EntityCollection(entities)); + return svc; + } + + private static string WriteTempDll() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.dll"); + File.WriteAllBytes(path, new byte[] { 1, 2, 3, 4 }); + return path; + } + + [Fact] + public void Deploy_NotFound_CreatesRecord_ReturnsNewId() + { + var svc = BuildSvc(); + var newId = Guid.NewGuid(); + svc.Create(Arg.Any()).Returns(newId); + var dll = WriteTempDll(); + + try + { + var result = new AssemblyDeployer(svc).Deploy(Artifact(dll)); + + result.ShouldBe(newId); + svc.Received(1).Create(Arg.Is(e => + e.LogicalName == "pluginassembly" && + (string)e["name"] == AssemblyName && + ((OptionSetValue)e["sourcetype"]).Value == 0 && + ((OptionSetValue)e["isolationmode"]).Value == 2 && + (string)e["culture"] == "neutral" && + (string)e["version"] == "1.2.3.4" && + e.Contains("content"))); + svc.DidNotReceive().Update(Arg.Any()); + } + finally + { + File.Delete(dll); + } + } + + [Fact] + public void Deploy_Found_UpdatesContent_ReturnsExistingId() + { + var existingId = Guid.NewGuid(); + var svc = BuildSvc(existingId); + var dll = WriteTempDll(); + + try + { + var result = new AssemblyDeployer(svc).Deploy(Artifact(dll)); + + result.ShouldBe(existingId); + svc.Received(1).Update(Arg.Is(e => + e.LogicalName == "pluginassembly" && + e.Id == existingId && + e.Contains("content"))); + svc.DidNotReceive().Create(Arg.Any()); + } + finally + { + File.Delete(dll); + } + } + + [Fact] + public void Deploy_MultipleMatches_Throws() + { + var svc = BuildSvc(Guid.NewGuid(), Guid.NewGuid()); + + // Throws during the name lookup, before any file read — path need not exist. + var ex = Should.Throw(() => + new AssemblyDeployer(svc).Deploy(Artifact("nonexistent.dll"))); + + ex.Message.ShouldContain(AssemblyName); + } + + [Fact] + public void Deploy_DryRun_NotFound_ReportsWouldCreate_NoWrites() + { + var svc = BuildSvc(); + + // No existing record and no write path in a dry run — path need not exist. + var result = new AssemblyDeployer(svc).Deploy(Artifact("nonexistent.dll"), dryRun: true); + + result.ShouldBe(Guid.Empty); + svc.DidNotReceive().Create(Arg.Any()); + svc.DidNotReceive().Update(Arg.Any()); + } + + [Fact] + public void Deploy_DryRun_Found_NoWrites_ReturnsExistingId() + { + var existingId = Guid.NewGuid(); + var svc = BuildSvc(existingId); + + var result = new AssemblyDeployer(svc).Deploy(Artifact("nonexistent.dll"), dryRun: true); + + result.ShouldBe(existingId); + svc.DidNotReceive().Create(Arg.Any()); + svc.DidNotReceive().Update(Arg.Any()); + } + } +} diff --git a/src/dvx.Tests/PluginDeploymentPlanTests.cs b/src/dvx.Tests/PluginDeploymentPlanTests.cs new file mode 100644 index 0000000..19bf13c --- /dev/null +++ b/src/dvx.Tests/PluginDeploymentPlanTests.cs @@ -0,0 +1,55 @@ +using dvx.Commands.Shared; +using dvx.Models; +using dvx.Services; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class PluginDeploymentPlanTests + { + private static BuildResult Build(string? nupkg) => new(nupkg, @"C:\out\MyPlugin.dll"); + + [Fact] + public void PackageMode_WithNupkg_DeploysNupkg_NoWarning() + { + var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Package, Build(@"C:\out\MyPlugin.1.0.0.nupkg")); + + plan.Mode.ShouldBe(PluginBuildMode.Package); + plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.1.0.0.nupkg"); + plan.Warning.ShouldBeNull(); + } + + [Fact] + public void PackageMode_WithoutNupkg_FallsBackToAssembly_WithWarning() + { + var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Package, Build(null)); + + plan.Mode.ShouldBe(PluginBuildMode.Assembly); + plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.dll"); + plan.Warning.ShouldNotBeNull(); + plan.Warning!.ShouldContain(".nupkg"); + } + + [Fact] + public void AssemblyMode_WithoutNupkg_DeploysDll_NoWarning() + { + var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Assembly, Build(null)); + + plan.Mode.ShouldBe(PluginBuildMode.Assembly); + plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.dll"); + plan.Warning.ShouldBeNull(); + } + + [Fact] + public void AssemblyMode_WithNupkg_DeploysDll_WithWarning() + { + var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Assembly, Build(@"C:\out\MyPlugin.1.0.0.nupkg")); + + plan.Mode.ShouldBe(PluginBuildMode.Assembly); + plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.dll"); + plan.Warning.ShouldNotBeNull(); + plan.Warning!.ShouldContain(".nupkg"); + } + } +} diff --git a/src/dvx.Tests/SolutionServiceTests.cs b/src/dvx.Tests/SolutionServiceTests.cs index 3f6da58..4793407 100644 --- a/src/dvx.Tests/SolutionServiceTests.cs +++ b/src/dvx.Tests/SolutionServiceTests.cs @@ -94,7 +94,27 @@ public void AddStepToSolution_Verbose_DoesNotThrowAndStillExecutes() svc.Received(1).Execute(Arg.Any()); } - // ── AddWebResourceToSolution ─────────────────────────────────────────── + // ── AddWebResourceToSolution ──────────────────────────────── + + [Fact] + public void AddAssemblyToSolution_ExecutesRequestWithComponentType91() + { + var svc = Substitute.For(); + var id = Guid.NewGuid(); + OrganizationRequest? captured = null; + svc.Execute(Arg.Do(r => captured = r)); + + new SolutionService(svc).AddAssemblyToSolution(id, "MySolution"); + + captured.ShouldNotBeNull(); + captured!.RequestName.ShouldBe("AddSolutionComponent"); + captured["ComponentId"].ShouldBe(id); + captured["ComponentType"].ShouldBe(91); + captured["SolutionUniqueName"].ShouldBe("MySolution"); + captured["AddRequiredComponents"].ShouldBe(false); + } + + // ── AddWebResourceToSolution ─────────────────────────────────────────────── [Fact] public void AddWebResourceToSolution_ExecutesRequestWithComponentType61() diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 9ac532d..24ecfea 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -1,7 +1,9 @@ using System.CommandLine; using System.CommandLine.Invocation; +using System.Reflection; using dvx.Commands.Shared; using dvx.Config; +using dvx.Models; using dvx.Output; using dvx.Services; @@ -20,11 +22,13 @@ public static Command Build() var project = CommandOptions.Project(); var publisherPrefix = CommandOptions.PublisherPrefix(); var solutionUniqueName = CommandOptions.SolutionUniqueName(); + var pluginBuildMode = CommandOptions.PluginBuildMode(); var interactiveAuth = CommandOptions.InteractiveAuth(); + var dryRun = CommandOptions.DryRun(); var verbose = CommandOptions.Verbose(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix, - solutionUniqueName, interactiveAuth, verbose); + solutionUniqueName, pluginBuildMode, interactiveAuth, dryRun, verbose); cmd.SetHandler((InvocationContext ctx) => { @@ -36,7 +40,9 @@ public static Command Build() var projectPath = ctx.ParseResult.GetValueForOption(project)!; var pubPrefix = ctx.ParseResult.GetValueForOption(publisherPrefix); var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName); + var cliMode = ctx.ParseResult.GetValueForOption(pluginBuildMode); var cliInteractive = ctx.ParseResult.GetValueForOption(interactiveAuth); + var isDryRun = ctx.ParseResult.GetValueForOption(dryRun); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); try @@ -47,6 +53,7 @@ public static Command Build() var configured = ConfigLoader.ResolveConfiguredPublisherPrefix(appConfig, pubPrefix); var solution = ConfigLoader.ResolveSolutionUniqueName(appConfig, cliSolution); var resolvedProject = ConfigLoader.ResolveProject(appConfig, projectPath); + var mode = ConfigLoader.ResolvePluginBuildMode(appConfig, cliMode); using var svc = DataverseClientFactory.Create(envConfig); var (prefix, prefixWarning) = PublisherPrefixResolution.Resolve( @@ -54,16 +61,27 @@ public static Command Build() if (prefixWarning is not null) Out.Warn(prefixWarning); Out.Step("Building", resolvedProject); - var build = new ProjectBuilder().Build(resolvedProject); + var build = new ProjectBuilder().BuildAssembly(resolvedProject); + var plan = PluginDeploymentPlan.Resolve(mode, build); + if (plan.Warning is not null) Out.Warn(plan.Warning); + var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - Out.Success("Built", Path.GetFileName(build.NupkgPath)!); + var uniqueName = $"{prefix}_{assemblyName}"; + var version = AssemblyName.GetAssemblyName(build.DllPath).Version; + var artifact = new PluginArtifact(plan.ArtifactPath, assemblyName, uniqueName, version); + Out.Success("Built", Path.GetFileName(plan.ArtifactPath)); Out.Step("Deploying", $"to {envConfig.Url}"); - var uniqueName = $"{prefix}_{assemblyName}"; - var deployer = new PackageDeployer(svc); - var assemblyId = deployer.Deploy(build.NupkgPath!, uniqueName, isVerbose); + IPluginDeployer deployer = plan.Mode == PluginBuildMode.Package + ? new PackageDeployer(svc) + : new AssemblyDeployer(svc); + var assemblyId = deployer.Deploy(artifact, isVerbose, isDryRun); + + if (plan.Mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) + new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution, isVerbose); - Out.Success("Deployed.", $"Assembly ID: {assemblyId}"); + Out.Success(isDryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", + $"Assembly ID: {assemblyId}"); } catch (Exception ex) { diff --git a/src/dvx/Commands/Shared/PluginDeploymentPlan.cs b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs new file mode 100644 index 0000000..b093fe2 --- /dev/null +++ b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs @@ -0,0 +1,29 @@ +using dvx.Models; +using dvx.Services; + +namespace dvx.Commands.Shared +{ + /// + /// Maps a requested and the artifacts a build produced onto the + /// mode and artifact actually deployed, warning when the two disagree (e.g. package mode + /// selected for a project that emitted no .nupkg). + /// + public sealed record PluginDeploymentPlan(PluginBuildMode Mode, string ArtifactPath, string? Warning) + { + public static PluginDeploymentPlan Resolve(PluginBuildMode requested, BuildResult build) + { + if (requested == PluginBuildMode.Package && build.NupkgPath is not null) + return new(PluginBuildMode.Package, build.NupkgPath, null); + + if (requested == PluginBuildMode.Package) + return new(PluginBuildMode.Assembly, build.DllPath, + "Package mode selected, but the project produced no .nupkg — deploying the DLL as a plugin assembly instead."); + + if (build.NupkgPath is not null) + return new(PluginBuildMode.Assembly, build.DllPath, + "Assembly mode selected, but the project also produced a .nupkg — deploying the DLL directly and ignoring the .nupkg."); + + return new(PluginBuildMode.Assembly, build.DllPath, null); + } + } +} diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index d64adc0..76155d4 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -1,7 +1,9 @@ using System.CommandLine; using System.CommandLine.Invocation; +using System.Reflection; using dvx.Commands.Shared; using dvx.Config; +using dvx.Models; using dvx.Output; using dvx.Services; using Microsoft.Extensions.Logging; @@ -20,6 +22,7 @@ public static Command Build(ILoggerFactory loggerFactory) var clientSecret = CommandOptions.ClientSecret(); var project = CommandOptions.Project(); var publisherPrefix = CommandOptions.PublisherPrefix(); + var pluginBuildMode = CommandOptions.PluginBuildMode(); var dryRun = CommandOptions.DryRun(); var verbose = CommandOptions.Verbose(); var solutionUniqueName = CommandOptions.SolutionUniqueName(); @@ -27,7 +30,7 @@ public static Command Build(ILoggerFactory loggerFactory) var interactiveAuth = CommandOptions.InteractiveAuth(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix, - dryRun, verbose, solutionUniqueName, deleteOrphaned, interactiveAuth); + pluginBuildMode, dryRun, verbose, solutionUniqueName, deleteOrphaned, interactiveAuth); cmd.SetHandler((InvocationContext ctx) => { @@ -38,6 +41,7 @@ public static Command Build(ILoggerFactory loggerFactory) var cliSecret = ctx.ParseResult.GetValueForOption(clientSecret); var projectPath = ctx.ParseResult.GetValueForOption(project)!; var cliPrefix = ctx.ParseResult.GetValueForOption(publisherPrefix); + var cliMode = ctx.ParseResult.GetValueForOption(pluginBuildMode); var isDryRun = ctx.ParseResult.GetValueForOption(dryRun); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName); @@ -52,6 +56,7 @@ public static Command Build(ILoggerFactory loggerFactory) var configured = ConfigLoader.ResolveConfiguredPublisherPrefix(appConfig, cliPrefix); var solution = ConfigLoader.ResolveSolutionUniqueName(appConfig, cliSolution); var resolvedProject = ConfigLoader.ResolveProject(appConfig, projectPath); + var mode = ConfigLoader.ResolvePluginBuildMode(appConfig, cliMode); using var svc = DataverseClientFactory.Create(envConfig); var (prefix, prefixWarning) = PublisherPrefixResolution.Resolve( @@ -60,19 +65,38 @@ public static Command Build(ILoggerFactory loggerFactory) // ── Build ─────────────────────────────────────────────── Out.Step("Building", resolvedProject); - var build = new ProjectBuilder().Build(resolvedProject); + var build = new ProjectBuilder().BuildAssembly(resolvedProject); + var plan = PluginDeploymentPlan.Resolve(mode, build); + if (plan.Warning is not null) Out.Warn(plan.Warning); + var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - Out.Success("Built", Path.GetFileName(build.NupkgPath)!); + var uniqueName = $"{prefix}_{assemblyName}"; + var version = AssemblyName.GetAssemblyName(build.DllPath).Version; + var artifact = new PluginArtifact(plan.ArtifactPath, assemblyName, uniqueName, version); + Out.Success("Built", Path.GetFileName(plan.ArtifactPath)); // ── Deploy ────────────────────────────────────────────── Out.Step("Deploying", $"to {envConfig.Url}"); - var uniqueName = $"{prefix}_{assemblyName}"; - var deployer = new PackageDeployer(svc); - var assemblyId = deployer.Deploy(build.NupkgPath!, uniqueName, isVerbose, isDryRun); + IPluginDeployer deployer = plan.Mode == PluginBuildMode.Package + ? new PackageDeployer(svc) + : new AssemblyDeployer(svc); + var assemblyId = deployer.Deploy(artifact, isVerbose, isDryRun); + + if (plan.Mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) + new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution, isVerbose); + Out.Success(isDryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", $"Assembly ID: {assemblyId}"); // ── Register ──────────────────────────────────────────── + // A brand-new assembly under a dry run has no id (nothing was created), so there + // are no plugin types to reconcile — report and stop rather than fault the run. + if (assemblyId == Guid.Empty) + { + Out.Info("Assembly does not exist yet — step registration skipped (dry run)."); + return; + } + Out.Step("Discovering", "plugin steps via reflection..."); var discovery = new PluginDiscovery(loggerFactory.CreateLogger()); var definitions = discovery.Discover(build.DllPath, isVerbose); diff --git a/src/dvx/Services/AssemblyDeployer.cs b/src/dvx/Services/AssemblyDeployer.cs new file mode 100644 index 0000000..6697a85 --- /dev/null +++ b/src/dvx/Services/AssemblyDeployer.cs @@ -0,0 +1,85 @@ +using dvx.Output; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace dvx.Services +{ + /// + /// Deploys a bare plugin assembly by writing the base64-encoded .dll to the + /// content column of a pluginassembly record — creating the record when no + /// assembly with the same name exists, otherwise updating its content. Returns the + /// record's own id for step registration. Only the required attributes are set; none are + /// configurable. + /// + public class AssemblyDeployer : PluginDeployerBase + { + public AssemblyDeployer(IOrganizationService svc) : base(svc) { } + + // ── Skeleton hooks ───────────────────────────────────────────────────── + + protected override Guid? ResolveExistingId(PluginArtifact artifact, bool verbose) + { + var query = new QueryExpression("pluginassembly") + { + ColumnSet = new ColumnSet("pluginassemblyid"), + Criteria = new FilterExpression(), + }; + query.Criteria.AddCondition("name", ConditionOperator.Equal, artifact.AssemblyName); + var result = Svc.RetrieveMultiple(query); + + if (result.Entities.Count > 1) + throw new InvalidOperationException( + $"Multiple pluginassembly records named '{artifact.AssemblyName}' found in Dataverse. " + + "Remove the duplicates before deploying."); + + return result.Entities.Count == 1 ? result.Entities[0].Id : null; + } + + protected override Guid? UploadContent(Guid? existingId, PluginArtifact artifact, bool verbose) + { + var bytes = File.ReadAllBytes(artifact.Path); + + if (verbose) + Out.Dim($" Uploading {bytes.Length / 1024} KB to pluginassembly"); + + var content = Convert.ToBase64String(bytes); + + if (existingId is null) + { + Out.SubStep("Creating plugin assembly..."); + + var id = Svc.Create(new Entity("pluginassembly") + { + ["name"] = artifact.AssemblyName, + ["sourcetype"] = new OptionSetValue(0), // Database + ["isolationmode"] = new OptionSetValue(2), // Sandbox + ["version"] = artifact.Version!.ToString(), + ["culture"] = "neutral", + ["content"] = content, + }); + return id; + } + + Out.SubStep("Updating plugin assembly content..."); + + Svc.Update(new Entity("pluginassembly", existingId.Value) + { + ["content"] = content, + ["version"] = artifact.Version!.ToString(), + }); + return existingId; + } + + protected override Guid ReturnAssemblyId(Guid? id, PluginArtifact artifact) + { + if (id is null) + { + // Only reachable on a dry run against a not-yet-created assembly. + Out.DryRun($"Assembly '{artifact.AssemblyName}' not found — would create."); + return Guid.Empty; + } + + return id.Value; + } + } +} diff --git a/src/dvx/Services/SolutionService.cs b/src/dvx/Services/SolutionService.cs index 779eefb..3085283 100644 --- a/src/dvx/Services/SolutionService.cs +++ b/src/dvx/Services/SolutionService.cs @@ -52,6 +52,9 @@ public virtual void AddComponentToSolution( public virtual void AddStepToSolution(Guid stepId, string solutionUniqueName, bool verbose = false) => AddComponentToSolution(stepId, 92 /* sdkmessageprocessingstep */, solutionUniqueName, verbose); + public virtual void AddAssemblyToSolution(Guid assemblyId, string solutionUniqueName, bool verbose = false) + => AddComponentToSolution(assemblyId, 91 /* pluginassembly */, solutionUniqueName, verbose); + public virtual void AddWebResourceToSolution(Guid webResourceId, string solutionUniqueName, bool verbose = false) => AddComponentToSolution(webResourceId, 61 /* webresource */, solutionUniqueName, verbose); From a1cdbfe3b7e133e2c2ee15459fcf56ab78b0c296 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Wed, 7 Oct 2026 13:06:20 +0200 Subject: [PATCH 03/14] Make plugin build mode strict Package mode now fails when the build emits no .nupkg instead of falling back to the DLL. Assembly mode keeps the E18 warning when a .nupkg was also produced. PluginDeploymentPlan no longer carries a (never-changing) mode, and commands select the deployer from the resolved mode. Update README and the plan's E18 resolution note. --- README.md | 7 ++-- .../plans/2026-07-05-bare-dll-deployment.md | 4 +++ src/dvx.Tests/PluginDeploymentPlanTests.cs | 14 +++----- src/dvx/Commands/DeployCommand.cs | 4 +-- .../Commands/Shared/PluginDeploymentPlan.cs | 33 ++++++++++--------- src/dvx/Commands/SyncCommand.cs | 4 +-- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d01e9d2..94301cd 100644 --- a/README.md +++ b/README.md @@ -372,9 +372,10 @@ var postImage = context.PostEntityImages["PostImage"]; // UsePostImage = true Both modes still run `dotnet build` (Release). CLI `--plugin-build-mode` wins over `pluginBuildMode` in config. -**When the mode and the build output disagree** — e.g. `package` mode selected but the project -emitted no `.nupkg`, or `assembly` mode on a project that also produced one — dvx prints a warning -and deploys the artifact that is actually available. +**Package mode is strict:** if `package` mode is selected but the build emits no `.nupkg`, the +command **fails** rather than falling back to the DLL — pass `--plugin-build-mode assembly` to +intentionally deploy a bare assembly. Conversely, `assembly` mode on a project that also produced a +`.nupkg` prints a warning and deploys the DLL, ignoring the package. When `--solution-unique-name` (or `solutionUniqueName`) is set, **assembly** mode adds the deployed assembly to that solution as a component. diff --git a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md index fe471a0..6255ac5 100644 --- a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md +++ b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md @@ -30,6 +30,10 @@ | F21–F22 | NSubstitute-based tests mirroring `PackageDeployerTests`; add `ProjectBuilder` tests | | F23 | Update README | +> **Resolution note (implemented):** package mode is **strict** — a missing `.nupkg` is a hard error, +> not a warning, so the deploy never silently switches to the DLL. The E18 warning therefore applies +> only to assembly mode on a project that also emitted a `.nupkg` (deploy the DLL, ignore the package). + ## Architecture ``` diff --git a/src/dvx.Tests/PluginDeploymentPlanTests.cs b/src/dvx.Tests/PluginDeploymentPlanTests.cs index 19bf13c..eeb830f 100644 --- a/src/dvx.Tests/PluginDeploymentPlanTests.cs +++ b/src/dvx.Tests/PluginDeploymentPlanTests.cs @@ -15,20 +15,18 @@ public void PackageMode_WithNupkg_DeploysNupkg_NoWarning() { var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Package, Build(@"C:\out\MyPlugin.1.0.0.nupkg")); - plan.Mode.ShouldBe(PluginBuildMode.Package); plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.1.0.0.nupkg"); plan.Warning.ShouldBeNull(); } [Fact] - public void PackageMode_WithoutNupkg_FallsBackToAssembly_WithWarning() + public void PackageMode_WithoutNupkg_Throws() { - var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Package, Build(null)); + var ex = Should.Throw(() => + PluginDeploymentPlan.Resolve(PluginBuildMode.Package, Build(null))); - plan.Mode.ShouldBe(PluginBuildMode.Assembly); - plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.dll"); - plan.Warning.ShouldNotBeNull(); - plan.Warning!.ShouldContain(".nupkg"); + ex.Message.ShouldContain(".nupkg"); + ex.Message.ShouldContain("assembly"); } [Fact] @@ -36,7 +34,6 @@ public void AssemblyMode_WithoutNupkg_DeploysDll_NoWarning() { var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Assembly, Build(null)); - plan.Mode.ShouldBe(PluginBuildMode.Assembly); plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.dll"); plan.Warning.ShouldBeNull(); } @@ -46,7 +43,6 @@ public void AssemblyMode_WithNupkg_DeploysDll_WithWarning() { var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Assembly, Build(@"C:\out\MyPlugin.1.0.0.nupkg")); - plan.Mode.ShouldBe(PluginBuildMode.Assembly); plan.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.dll"); plan.Warning.ShouldNotBeNull(); plan.Warning!.ShouldContain(".nupkg"); diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 24ecfea..2afb2b1 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -72,12 +72,12 @@ public static Command Build() Out.Success("Built", Path.GetFileName(plan.ArtifactPath)); Out.Step("Deploying", $"to {envConfig.Url}"); - IPluginDeployer deployer = plan.Mode == PluginBuildMode.Package + IPluginDeployer deployer = mode == PluginBuildMode.Package ? new PackageDeployer(svc) : new AssemblyDeployer(svc); var assemblyId = deployer.Deploy(artifact, isVerbose, isDryRun); - if (plan.Mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) + if (mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution, isVerbose); Out.Success(isDryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", diff --git a/src/dvx/Commands/Shared/PluginDeploymentPlan.cs b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs index b093fe2..d524bdd 100644 --- a/src/dvx/Commands/Shared/PluginDeploymentPlan.cs +++ b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs @@ -4,26 +4,29 @@ namespace dvx.Commands.Shared { /// - /// Maps a requested and the artifacts a build produced onto the - /// mode and artifact actually deployed, warning when the two disagree (e.g. package mode - /// selected for a project that emitted no .nupkg). + /// Selects the build artifact to upload for the requested and + /// surfaces a warning when the mode and the produced artifacts disagree (E18). + /// Strict — package mode fails when the build emits no .nupkg rather than falling back + /// to the DLL. /// - public sealed record PluginDeploymentPlan(PluginBuildMode Mode, string ArtifactPath, string? Warning) + public sealed record PluginDeploymentPlan(string ArtifactPath, string? Warning) { - public static PluginDeploymentPlan Resolve(PluginBuildMode requested, BuildResult build) + public static PluginDeploymentPlan Resolve(PluginBuildMode mode, BuildResult build) { - if (requested == PluginBuildMode.Package && build.NupkgPath is not null) - return new(PluginBuildMode.Package, build.NupkgPath, null); + if (mode == PluginBuildMode.Package) + { + if (build.NupkgPath is null) + throw new InvalidOperationException( + "Package mode requires a .nupkg, but the build produced none. " + + "Use --plugin-build-mode assembly to deploy a bare plugin assembly."); - if (requested == PluginBuildMode.Package) - return new(PluginBuildMode.Assembly, build.DllPath, - "Package mode selected, but the project produced no .nupkg — deploying the DLL as a plugin assembly instead."); + return new(build.NupkgPath, null); + } - if (build.NupkgPath is not null) - return new(PluginBuildMode.Assembly, build.DllPath, - "Assembly mode selected, but the project also produced a .nupkg — deploying the DLL directly and ignoring the .nupkg."); - - return new(PluginBuildMode.Assembly, build.DllPath, null); + return new(build.DllPath, + build.NupkgPath is not null + ? "Assembly mode selected, but the project also produced a .nupkg — deploying the DLL directly and ignoring the .nupkg." + : null); } } } diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index 76155d4..930d1b3 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -77,12 +77,12 @@ public static Command Build(ILoggerFactory loggerFactory) // ── Deploy ────────────────────────────────────────────── Out.Step("Deploying", $"to {envConfig.Url}"); - IPluginDeployer deployer = plan.Mode == PluginBuildMode.Package + IPluginDeployer deployer = mode == PluginBuildMode.Package ? new PackageDeployer(svc) : new AssemblyDeployer(svc); var assemblyId = deployer.Deploy(artifact, isVerbose, isDryRun); - if (plan.Mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) + if (mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution, isVerbose); Out.Success(isDryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", From fe26d2152d3bd6d722c0c88913bbfa122c3abfdc Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Wed, 7 Oct 2026 13:31:07 +0200 Subject: [PATCH 04/14] Address review feedback: extract shared deploy runner, tighten tests Review-driven changes: - Extract PluginDeployRunner.BuildAndDeploy; both commands share one build+deploy path. - Compute the assembly Version only in assembly mode (package path no longer opens the DLL). - Add PluginDeploymentPlan.ShouldAddAssemblyToSolution and unit-test the guard truth table. - Rename ProjectBuilder.BuildAssembly -> BuildAllowingMissingPackage (the real distinction is package-optional, not assembly-specific). - Remove the production-dead PackageDeployer.Deploy(string,string) overload; point its tests at the PluginArtifact API. - Assert version + exact base64 content in AssemblyDeployerTests; drop the over-claiming name. - Docs: deploy description, pluginassembly table row, test header, plan doc drift. --- README.md | 4 +- .../plans/2026-07-05-bare-dll-deployment.md | 14 ++++- src/dvx.Tests/AssemblyDeployerTests.cs | 9 ++- src/dvx.Tests/PackageDeployerTests.cs | 16 +++-- src/dvx.Tests/PluginDeploymentPlanTests.cs | 32 ++++++++++ src/dvx.Tests/ProjectBuilderTests.cs | 12 ++-- src/dvx.Tests/SolutionServiceTests.cs | 2 +- src/dvx/Commands/DeployCommand.cs | 28 +-------- src/dvx/Commands/Shared/PluginDeployRunner.cs | 59 +++++++++++++++++++ .../Commands/Shared/PluginDeploymentPlan.cs | 11 ++++ src/dvx/Commands/SyncCommand.cs | 31 ++-------- src/dvx/Services/PackageDeployer.cs | 7 --- src/dvx/Services/ProjectBuilder.cs | 2 +- 13 files changed, 147 insertions(+), 80 deletions(-) create mode 100644 src/dvx/Commands/Shared/PluginDeployRunner.cs diff --git a/README.md b/README.md index 94301cd..95edb89 100644 --- a/README.md +++ b/README.md @@ -442,7 +442,7 @@ dvx plugin sync --project ./src/MyPlugin/MyPlugin.csproj --dry-run ### plugin deploy -> Build the project and push the plugin package to Dataverse. Does not touch step registrations. +> Build the project and deploy the plugin to Dataverse — the NuGet package, or the bare assembly with `--plugin-build-mode assembly`. Does not touch step registrations. ``` dvx plugin deploy --project [options] @@ -1001,7 +1001,7 @@ dvx reads and writes the following Dataverse tables: | Table (logical name) | Purpose | |---|---| | `pluginpackage` | Stores the plugin package (nupkg) in its `content` column. Queried by `uniquename`, then updated with the new `.nupkg` content on deploy. | -| `pluginassembly` | Child record created by Dataverse when it processes a plugin package. Queried after deploy to get the ID for step registration. Also queried by `--assembly-name` to download content bytes. | +| `pluginassembly` | The plugin assembly. In **package** mode this is a child record Dataverse creates when it processes a plugin package (queried after deploy to get the ID for step registration). In **assembly** mode dvx creates (or updates) it directly from the built `.dll`. Also queried by `--assembly-name` to download content bytes. | | `plugintype` | One record per plugin class. Queried to resolve class names to GUIDs for step registration. | | `customapi` | Queried by `adopt` to identify Custom API registrations (by `plugintypeid` / `sdkmessageid`) so their steps are skipped rather than scaffolded as `[PluginStep]`. | | `sdkmessage` | Lookup table for message names (`Create`, `Update`, `Delete`, …). Loaded once and cached per run. | diff --git a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md index 6255ac5..3856e9a 100644 --- a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md +++ b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md @@ -33,6 +33,12 @@ > **Resolution note (implemented):** package mode is **strict** — a missing `.nupkg` is a hard error, > not a warning, so the deploy never silently switches to the DLL. The E18 warning therefore applies > only to assembly mode on a project that also emitted a `.nupkg` (deploy the DLL, ignore the package). +> +> **Post-review refinements:** the duplicated command wiring was extracted into `PluginDeployRunner`; +> `ProjectBuilder.BuildAssembly` was renamed `BuildAllowingMissingPackage`; the `Version` is computed +> only in assembly mode (the package path no longer opens the DLL); the solution-add guard is the +> tested `PluginDeploymentPlan.ShouldAddAssemblyToSolution`; and the package-only `Deploy(string,string)` +> overload was removed (the string callers were test-only). ## Architecture @@ -58,8 +64,11 @@ StepRegistrar.Sync(assemblyId, …) ← unchanged - `src/dvx/Services/IPluginDeployer.cs` — interface + `PluginArtifact` record - `src/dvx/Services/PluginDeployerBase.cs` — shared skeleton - `src/dvx/Services/AssemblyDeployer.cs` +- `src/dvx/Commands/Shared/PluginDeploymentPlan.cs` — mode/artifact selection + solution-membership predicate +- `src/dvx/Commands/Shared/PluginDeployRunner.cs` — shared build-and-deploy step for deploy/sync - `src/dvx.Tests/AssemblyDeployerTests.cs` - `src/dvx.Tests/ProjectBuilderTests.cs` +- `src/dvx.Tests/PluginDeploymentPlanTests.cs` **Modify** - `src/dvx/Services/PackageDeployer.cs` — implement `IPluginDeployer`, derive from base @@ -81,7 +90,7 @@ public sealed record PluginArtifact( string Path, // .nupkg (Package) or .dll (Assembly) string AssemblyName, // bare assembly name; record name for Assembly mode, stem for Package unique name string UniqueName, // {prefix}_{assemblyName} — Package lookup key - Version Version); // from the built DLL's AssemblyVersion (Assembly mode) + Version? Version); // from the built DLL's AssemblyVersion (Assembly mode; null for Package) public interface IPluginDeployer { @@ -139,7 +148,8 @@ implement until green. Task 3 is a pure refactor, so its "red" step is the exist seam) — confirm failure. - [x] **Green:** `DeployCommand` / `SyncCommand`: resolve mode, build accordingly, select deployer, warn on artifact/mode mismatch (E18), add assembly to solution when `--solution-unique-name` set. -- [ ] Run the command tests — confirm pass. +- [x] Run the command tests — confirm pass (deployer selection + solution-membership predicate are + covered by `PluginDeploymentPlanTests`). ### Task 7 — Docs - [x] README: document `--plugin-build-mode`, `pluginBuildMode` config field, assembly-mode diff --git a/src/dvx.Tests/AssemblyDeployerTests.cs b/src/dvx.Tests/AssemblyDeployerTests.cs index f6cdfa7..abecd9e 100644 --- a/src/dvx.Tests/AssemblyDeployerTests.cs +++ b/src/dvx.Tests/AssemblyDeployerTests.cs @@ -41,6 +41,7 @@ public void Deploy_NotFound_CreatesRecord_ReturnsNewId() try { var result = new AssemblyDeployer(svc).Deploy(Artifact(dll)); + var bytes = File.ReadAllBytes(dll); result.ShouldBe(newId); svc.Received(1).Create(Arg.Is(e => @@ -50,7 +51,7 @@ public void Deploy_NotFound_CreatesRecord_ReturnsNewId() ((OptionSetValue)e["isolationmode"]).Value == 2 && (string)e["culture"] == "neutral" && (string)e["version"] == "1.2.3.4" && - e.Contains("content"))); + (string)e["content"] == Convert.ToBase64String(bytes))); svc.DidNotReceive().Update(Arg.Any()); } finally @@ -69,12 +70,14 @@ public void Deploy_Found_UpdatesContent_ReturnsExistingId() try { var result = new AssemblyDeployer(svc).Deploy(Artifact(dll)); + var bytes = File.ReadAllBytes(dll); result.ShouldBe(existingId); svc.Received(1).Update(Arg.Is(e => e.LogicalName == "pluginassembly" && e.Id == existingId && - e.Contains("content"))); + (string)e["version"] == "1.2.3.4" && + (string)e["content"] == Convert.ToBase64String(bytes))); svc.DidNotReceive().Create(Arg.Any()); } finally @@ -96,7 +99,7 @@ public void Deploy_MultipleMatches_Throws() } [Fact] - public void Deploy_DryRun_NotFound_ReportsWouldCreate_NoWrites() + public void Deploy_DryRun_NotFound_WritesNothing_ReturnsEmpty() { var svc = BuildSvc(); diff --git a/src/dvx.Tests/PackageDeployerTests.cs b/src/dvx.Tests/PackageDeployerTests.cs index b15384a..3bbfb1c 100644 --- a/src/dvx.Tests/PackageDeployerTests.cs +++ b/src/dvx.Tests/PackageDeployerTests.cs @@ -11,6 +11,10 @@ public class PackageDeployerTests { private const string UniqueName = "solu_TestPlugin"; + // PackageDeployer only uses artifact.UniqueName (the package lookup key) and artifact.Path. + private static PluginArtifact Artifact(string nupkgPath) + => new(nupkgPath, "TestPlugin", UniqueName, null); + // ── Mock builders ────────────────────────────────────────────────────── private static IOrganizationService BuildSvc( @@ -62,7 +66,7 @@ public void Deploy_PackageNotFound_ThrowsWithHelpfulMessage() // Package lookup fails before any file is read, so the path need not exist. var ex = Should.Throw(() => - new PackageDeployer(svc).Deploy("pkg.nupkg", UniqueName)); + new PackageDeployer(svc).Deploy(Artifact("pkg.nupkg"))); ex.Message.ShouldContain(UniqueName); ex.Message.ShouldContain("initial upload"); @@ -74,7 +78,7 @@ public void Deploy_PackageNotFound_UpdateNeverCalled() var svc = BuildSvc(existingPackageId: null); Should.Throw(() => - new PackageDeployer(svc).Deploy("pkg.nupkg", UniqueName)); + new PackageDeployer(svc).Deploy(Artifact("pkg.nupkg"))); svc.DidNotReceive().Update(Arg.Any()); } @@ -89,7 +93,7 @@ public void Deploy_HappyPath_UploadsContentAndReturnsAssemblyId() try { - var result = new PackageDeployer(svc).Deploy(nupkg, UniqueName); + var result = new PackageDeployer(svc).Deploy(Artifact(nupkg)); result.ShouldBe(assemblyId); svc.Received(1).Update(Arg.Is(e => @@ -114,7 +118,7 @@ public void Deploy_DryRun_DoesNotUpload_ButReturnsAssemblyId() try { - var result = new PackageDeployer(svc).Deploy(nupkg, UniqueName, dryRun: true); + var result = new PackageDeployer(svc).Deploy(Artifact(nupkg), dryRun: true); result.ShouldBe(assemblyId); svc.DidNotReceive().Update(Arg.Any()); @@ -137,7 +141,7 @@ public void Deploy_UploadFails_Throws() try { Should.Throw(() => - new PackageDeployer(svc).Deploy(nupkg, UniqueName)); + new PackageDeployer(svc).Deploy(Artifact(nupkg))); } finally { @@ -155,7 +159,7 @@ public void Deploy_AssemblyNotFoundAfterUpload_ThrowsContainingPackageName() try { var ex = Should.Throw(() => - new PackageDeployer(svc).Deploy(nupkg, UniqueName)); + new PackageDeployer(svc).Deploy(Artifact(nupkg))); ex.Message.ShouldContain(UniqueName); } diff --git a/src/dvx.Tests/PluginDeploymentPlanTests.cs b/src/dvx.Tests/PluginDeploymentPlanTests.cs index eeb830f..61adc0e 100644 --- a/src/dvx.Tests/PluginDeploymentPlanTests.cs +++ b/src/dvx.Tests/PluginDeploymentPlanTests.cs @@ -47,5 +47,37 @@ public void AssemblyMode_WithNupkg_DeploysDll_WithWarning() plan.Warning.ShouldNotBeNull(); plan.Warning!.ShouldContain(".nupkg"); } + + // ── ShouldAddAssemblyToSolution ──────────────────────────────────────── + + [Fact] + public void ShouldAddAssemblyToSolution_AssemblyMode_WithSolutionAndRealId_IsTrue() + => PluginDeploymentPlan + .ShouldAddAssemblyToSolution(PluginBuildMode.Assembly, "MySolution", dryRun: false, Guid.NewGuid()) + .ShouldBeTrue(); + + [Fact] + public void ShouldAddAssemblyToSolution_PackageMode_IsFalse() + => PluginDeploymentPlan + .ShouldAddAssemblyToSolution(PluginBuildMode.Package, "MySolution", dryRun: false, Guid.NewGuid()) + .ShouldBeFalse(); + + [Fact] + public void ShouldAddAssemblyToSolution_NoSolution_IsFalse() + => PluginDeploymentPlan + .ShouldAddAssemblyToSolution(PluginBuildMode.Assembly, null, dryRun: false, Guid.NewGuid()) + .ShouldBeFalse(); + + [Fact] + public void ShouldAddAssemblyToSolution_DryRun_IsFalse() + => PluginDeploymentPlan + .ShouldAddAssemblyToSolution(PluginBuildMode.Assembly, "MySolution", dryRun: true, Guid.NewGuid()) + .ShouldBeFalse(); + + [Fact] + public void ShouldAddAssemblyToSolution_EmptyAssemblyId_IsFalse() + => PluginDeploymentPlan + .ShouldAddAssemblyToSolution(PluginBuildMode.Assembly, "MySolution", dryRun: false, Guid.Empty) + .ShouldBeFalse(); } } diff --git a/src/dvx.Tests/ProjectBuilderTests.cs b/src/dvx.Tests/ProjectBuilderTests.cs index 5f55764..e1e7ea9 100644 --- a/src/dvx.Tests/ProjectBuilderTests.cs +++ b/src/dvx.Tests/ProjectBuilderTests.cs @@ -31,23 +31,23 @@ protected override void RunBuild(string projectPath) { } } [Fact] - public void BuildAssembly_DllOnly_ReturnsArtifactWithoutRequiringNupkg() + public void BuildAllowingMissingPackage_DllOnly_ReturnsArtifactWithoutRequiringNupkg() { File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.dll"), "dll"); - var result = new NoBuildProjectBuilder().BuildAssembly(_projectPath); + var result = new NoBuildProjectBuilder().BuildAllowingMissingPackage(_projectPath); result.DllPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.dll")); result.NupkgPath.ShouldBeNull(); } [Fact] - public void BuildAssembly_WithNupkg_ReturnsBoth() + public void BuildAllowingMissingPackage_WithNupkg_ReturnsBoth() { File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg"), "pkg"); File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.dll"), "dll"); - var result = new NoBuildProjectBuilder().BuildAssembly(_projectPath); + var result = new NoBuildProjectBuilder().BuildAllowingMissingPackage(_projectPath); result.NupkgPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg")); result.DllPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.dll")); @@ -74,11 +74,11 @@ public void Build_PackageProject_MissingNupkg_Throws() } [Fact] - public void BuildAssembly_MissingDll_Throws() + public void BuildAllowingMissingPackage_MissingDll_Throws() { File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg"), "pkg"); - Should.Throw(() => new NoBuildProjectBuilder().BuildAssembly(_projectPath)); + Should.Throw(() => new NoBuildProjectBuilder().BuildAllowingMissingPackage(_projectPath)); } } } diff --git a/src/dvx.Tests/SolutionServiceTests.cs b/src/dvx.Tests/SolutionServiceTests.cs index 4793407..bfd2668 100644 --- a/src/dvx.Tests/SolutionServiceTests.cs +++ b/src/dvx.Tests/SolutionServiceTests.cs @@ -94,7 +94,7 @@ public void AddStepToSolution_Verbose_DoesNotThrowAndStillExecutes() svc.Received(1).Execute(Arg.Any()); } - // ── AddWebResourceToSolution ──────────────────────────────── + // ── AddAssemblyToSolution ────────────────────────────────────────────── [Fact] public void AddAssemblyToSolution_ExecutesRequestWithComponentType91() diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 2afb2b1..1f2562b 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -1,9 +1,7 @@ using System.CommandLine; using System.CommandLine.Invocation; -using System.Reflection; using dvx.Commands.Shared; using dvx.Config; -using dvx.Models; using dvx.Output; using dvx.Services; @@ -13,7 +11,7 @@ public static class DeployCommand { public static Command Build() { - var cmd = new Command("deploy", "Build and push the plugin package to Dataverse."); + var cmd = new Command("deploy", "Build and deploy the plugin to Dataverse."); var env = CommandOptions.Env(); var config = CommandOptions.Config(); var url = CommandOptions.Url(); @@ -60,28 +58,8 @@ public static Command Build() configured, solution, new SolutionPublisherResolver(svc).GetCustomizationPrefix); if (prefixWarning is not null) Out.Warn(prefixWarning); - Out.Step("Building", resolvedProject); - var build = new ProjectBuilder().BuildAssembly(resolvedProject); - var plan = PluginDeploymentPlan.Resolve(mode, build); - if (plan.Warning is not null) Out.Warn(plan.Warning); - - var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - var uniqueName = $"{prefix}_{assemblyName}"; - var version = AssemblyName.GetAssemblyName(build.DllPath).Version; - var artifact = new PluginArtifact(plan.ArtifactPath, assemblyName, uniqueName, version); - Out.Success("Built", Path.GetFileName(plan.ArtifactPath)); - - Out.Step("Deploying", $"to {envConfig.Url}"); - IPluginDeployer deployer = mode == PluginBuildMode.Package - ? new PackageDeployer(svc) - : new AssemblyDeployer(svc); - var assemblyId = deployer.Deploy(artifact, isVerbose, isDryRun); - - if (mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) - new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution, isVerbose); - - Out.Success(isDryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", - $"Assembly ID: {assemblyId}"); + PluginDeployRunner.BuildAndDeploy(svc, mode, solution, envConfig.Url, + resolvedProject, prefix, isVerbose, isDryRun); } catch (Exception ex) { diff --git a/src/dvx/Commands/Shared/PluginDeployRunner.cs b/src/dvx/Commands/Shared/PluginDeployRunner.cs new file mode 100644 index 0000000..7c56a4b --- /dev/null +++ b/src/dvx/Commands/Shared/PluginDeployRunner.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using dvx.Models; +using dvx.Output; +using dvx.Services; +using Microsoft.Xrm.Sdk; + +namespace dvx.Commands.Shared +{ + /// + /// Shared build-and-deploy step for plugin deploy and plugin sync: build the + /// project, resolve the artifact for the mode, pick the deployer, deploy it, and add the + /// assembly to the target solution in assembly mode. + /// + public static class PluginDeployRunner + { + /// + /// Builds and deploys, returning the assembly id and the built DLL path (used for reflection). + /// + public static (Guid AssemblyId, string DllPath) BuildAndDeploy( + IOrganizationService svc, + PluginBuildMode mode, + string? solution, + string url, + string project, + string prefix, + bool verbose, + bool dryRun) + { + Out.Step("Building", project); + var build = new ProjectBuilder().BuildAllowingMissingPackage(project); + var plan = PluginDeploymentPlan.Resolve(mode, build); + if (plan.Warning is not null) Out.Warn(plan.Warning); + + var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); + var uniqueName = $"{prefix}_{assemblyName}"; + // The version is only consumed in assembly mode; reading it opens the DLL, so skip it + // entirely on the package path (which must stay behaviourally identical). + var version = mode == PluginBuildMode.Assembly + ? AssemblyName.GetAssemblyName(build.DllPath).Version + : null; + var artifact = new PluginArtifact(plan.ArtifactPath, assemblyName, uniqueName, version); + Out.Success("Built", Path.GetFileName(plan.ArtifactPath)); + + Out.Step("Deploying", $"to {url}"); + IPluginDeployer deployer = mode == PluginBuildMode.Package + ? new PackageDeployer(svc) + : new AssemblyDeployer(svc); + var assemblyId = deployer.Deploy(artifact, verbose, dryRun); + + if (PluginDeploymentPlan.ShouldAddAssemblyToSolution(mode, solution, dryRun, assemblyId)) + new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution!, verbose); + + Out.Success(dryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", + $"Assembly ID: {assemblyId}"); + + return (assemblyId, build.DllPath); + } + } +} diff --git a/src/dvx/Commands/Shared/PluginDeploymentPlan.cs b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs index d524bdd..13f0d71 100644 --- a/src/dvx/Commands/Shared/PluginDeploymentPlan.cs +++ b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs @@ -28,5 +28,16 @@ build.NupkgPath is not null ? "Assembly mode selected, but the project also produced a .nupkg — deploying the DLL directly and ignoring the .nupkg." : null); } + + /// + /// True when a deployed assembly should be added to the target solution: assembly mode only, + /// with a solution configured and a real (non-dry-run) assembly id. + /// + public static bool ShouldAddAssemblyToSolution( + PluginBuildMode mode, string? solution, bool dryRun, Guid assemblyId) + => mode == PluginBuildMode.Assembly + && solution is not null + && !dryRun + && assemblyId != Guid.Empty; } } diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index 930d1b3..87a147f 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -1,9 +1,7 @@ using System.CommandLine; using System.CommandLine.Invocation; -using System.Reflection; using dvx.Commands.Shared; using dvx.Config; -using dvx.Models; using dvx.Output; using dvx.Services; using Microsoft.Extensions.Logging; @@ -63,30 +61,9 @@ public static Command Build(ILoggerFactory loggerFactory) configured, solution, new SolutionPublisherResolver(svc).GetCustomizationPrefix); if (prefixWarning is not null) Out.Warn(prefixWarning); - // ── Build ─────────────────────────────────────────────── - Out.Step("Building", resolvedProject); - var build = new ProjectBuilder().BuildAssembly(resolvedProject); - var plan = PluginDeploymentPlan.Resolve(mode, build); - if (plan.Warning is not null) Out.Warn(plan.Warning); - - var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - var uniqueName = $"{prefix}_{assemblyName}"; - var version = AssemblyName.GetAssemblyName(build.DllPath).Version; - var artifact = new PluginArtifact(plan.ArtifactPath, assemblyName, uniqueName, version); - Out.Success("Built", Path.GetFileName(plan.ArtifactPath)); - - // ── Deploy ────────────────────────────────────────────── - Out.Step("Deploying", $"to {envConfig.Url}"); - IPluginDeployer deployer = mode == PluginBuildMode.Package - ? new PackageDeployer(svc) - : new AssemblyDeployer(svc); - var assemblyId = deployer.Deploy(artifact, isVerbose, isDryRun); - - if (mode == PluginBuildMode.Assembly && solution is not null && !isDryRun && assemblyId != Guid.Empty) - new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution, isVerbose); - - Out.Success(isDryRun ? "Resolved assembly (upload skipped — dry run)." : "Deployed.", - $"Assembly ID: {assemblyId}"); + // ── Build + deploy ────────────────────────────────────── + var (assemblyId, dllPath) = PluginDeployRunner.BuildAndDeploy( + svc, mode, solution, envConfig.Url, resolvedProject, prefix, isVerbose, isDryRun); // ── Register ──────────────────────────────────────────── // A brand-new assembly under a dry run has no id (nothing was created), so there @@ -99,7 +76,7 @@ public static Command Build(ILoggerFactory loggerFactory) Out.Step("Discovering", "plugin steps via reflection..."); var discovery = new PluginDiscovery(loggerFactory.CreateLogger()); - var definitions = discovery.Discover(build.DllPath, isVerbose); + var definitions = discovery.Discover(dllPath, isVerbose); Out.Info($"Found {definitions.Count} step definition(s)."); if (isDryRun) diff --git a/src/dvx/Services/PackageDeployer.cs b/src/dvx/Services/PackageDeployer.cs index b3a436e..6331ea2 100644 --- a/src/dvx/Services/PackageDeployer.cs +++ b/src/dvx/Services/PackageDeployer.cs @@ -16,13 +16,6 @@ public class PackageDeployer : PluginDeployerBase { public PackageDeployer(IOrganizationService svc) : base(svc) { } - /// - /// Backward-compatible entry point taking the raw package paths used by the commands. - /// Delegates to the shared skeleton. - /// - public Guid Deploy(string nupkgPath, string packageUniqueName, bool verbose = false, bool dryRun = false) - => Deploy(new PluginArtifact(nupkgPath, packageUniqueName, packageUniqueName, null), verbose, dryRun); - // ── Skeleton hooks ───────────────────────────────────────────────────── protected override Guid? ResolveExistingId(PluginArtifact artifact, bool verbose) diff --git a/src/dvx/Services/ProjectBuilder.cs b/src/dvx/Services/ProjectBuilder.cs index 259611c..8a7dfdc 100644 --- a/src/dvx/Services/ProjectBuilder.cs +++ b/src/dvx/Services/ProjectBuilder.cs @@ -25,7 +25,7 @@ public BuildResult Build(string projectPath) /// emit no .nupkg (bare plugin assemblies deployed straight to pluginassembly). /// The .nupkg path is null when the project does not produce one. /// - public BuildResult BuildAssembly(string projectPath) + public BuildResult BuildAllowingMissingPackage(string projectPath) { EnsureProjectExists(projectPath); RunBuild(projectPath); From 77f13e9e4284693d8de1b0b3005c25d110345437 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 09:13:02 +0200 Subject: [PATCH 05/14] Register plugintype rows in assembly mode A bare pluginassembly upload does not make Dataverse create the child plugintype rows (unlike a pluginpackage, which Dataverse extracts itself), so sync could not register steps - "No plugintype records found for assembly". Mirror the Plugin Registration Tool / spkl: reflect the built DLL for every concrete IPlugin class and create the missing plugintype records. - PluginDiscovery.DiscoverPluginTypeNames + shared metadata-load path (ForEachPluginType). - New PluginTypeRegistrar.EnsureRegistered (create missing, skip existing). - PluginDeployRunner registers types after an assembly-mode deploy (not on dry run). - Tests: PluginTypeRegistrarTests, PluginDiscoveryTests; README + plan notes. --- README.md | 11 +- .../plans/2026-07-05-bare-dll-deployment.md | 8 ++ src/dvx.Tests/PluginDiscoveryTests.cs | 21 ++++ src/dvx.Tests/PluginTypeRegistrarTests.cs | 88 ++++++++++++++ src/dvx/Commands/Shared/PluginDeployRunner.cs | 11 ++ src/dvx/Services/PluginDiscovery.cs | 108 +++++++++++------- src/dvx/Services/PluginTypeRegistrar.cs | 59 ++++++++++ 7 files changed, 263 insertions(+), 43 deletions(-) create mode 100644 src/dvx.Tests/PluginTypeRegistrarTests.cs create mode 100644 src/dvx/Services/PluginTypeRegistrar.cs diff --git a/README.md b/README.md index 95edb89..9667d46 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,12 @@ var postImage = context.PostEntityImages["PostImage"]; // UsePostImage = true | Mode | Artifact | Target record | Semantics | |---|---|---|---| | `package` (default) | `.nupkg` | `pluginpackage` | Updates an existing package's `content`. The initial upload must be done once manually. Backward compatible. | -| `assembly` | `.dll` | `pluginassembly` | Creates the assembly record when no assembly with the same `name` exists, otherwise updates its `content`. `version` comes from the built DLL, `isolationmode` is fixed at Sandbox. | +| `assembly` | `.dll` | `pluginassembly` | Creates the assembly record when no assembly with the same `name` exists, otherwise updates its `content`; then registers each plugin class as a `plugintype`. `version` comes from the built DLL, `isolationmode` is fixed at Sandbox. | + +**Assembly mode registers the plugin types too.** A bare `pluginassembly` upload does **not** make +Dataverse create the `plugintype` rows (unlike a `pluginpackage`, which it extracts itself), so in +assembly mode dvx creates a `plugintype` for every plugin class in the built `.dll` — the same job +the Plugin Registration Tool performs. This is what lets `sync` then register the steps. Both modes still run `dotnet build` (Release). CLI `--plugin-build-mode` wins over `pluginBuildMode` in config. @@ -406,7 +411,7 @@ dvx plugin sync --project [options] **What it does:** 1. Runs `dotnet build` on the `.csproj` to produce a `.nupkg` and `.dll` -2. In **package** mode: looks up the existing `pluginpackage` by `uniquename` (`{prefix}_{assemblyName}`), updates its `content`, and reads the child `pluginassembly` ID. In **assembly** mode: creates (or updates) the `pluginassembly` by `name` from the built `.dll` and uses its own ID +2. In **package** mode: looks up the existing `pluginpackage` by `uniquename` (`{prefix}_{assemblyName}`), updates its `content`, and reads the child `pluginassembly` ID. In **assembly** mode: creates (or updates) the `pluginassembly` by `name` from the built `.dll`, uses its own ID, and registers any missing `plugintype` records for the assembly's plugin classes 3. Reflects the `.dll` for `[PluginStep]` attributes 4. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps (removed only when `--delete-orphaned` is passed) 5. Syncs `sdkmessageprocessingstepimage` records (pre/post images) for each step @@ -1002,7 +1007,7 @@ dvx reads and writes the following Dataverse tables: |---|---| | `pluginpackage` | Stores the plugin package (nupkg) in its `content` column. Queried by `uniquename`, then updated with the new `.nupkg` content on deploy. | | `pluginassembly` | The plugin assembly. In **package** mode this is a child record Dataverse creates when it processes a plugin package (queried after deploy to get the ID for step registration). In **assembly** mode dvx creates (or updates) it directly from the built `.dll`. Also queried by `--assembly-name` to download content bytes. | -| `plugintype` | One record per plugin class. Queried to resolve class names to GUIDs for step registration. | +| `plugintype` | One record per plugin class. Queried to resolve class names to GUIDs for step registration. For packages Dataverse creates these from the `pluginpackage`; in **assembly** mode dvx creates the missing ones itself, mirroring the Plugin Registration Tool. | | `customapi` | Queried by `adopt` to identify Custom API registrations (by `plugintypeid` / `sdkmessageid`) so their steps are skipped rather than scaffolded as `[PluginStep]`. | | `sdkmessage` | Lookup table for message names (`Create`, `Update`, `Delete`, …). Loaded once and cached per run. | | `sdkmessagefilter` | Associates messages with entity types and indicates whether custom steps are allowed. | diff --git a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md index 3856e9a..57ca752 100644 --- a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md +++ b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md @@ -39,6 +39,12 @@ > only in assembly mode (the package path no longer opens the DLL); the solution-add guard is the > tested `PluginDeploymentPlan.ShouldAddAssemblyToSolution`; and the package-only `Deploy(string,string)` > overload was removed (the string callers were test-only). +> +> **Live-org finding (fixed):** a bare `pluginassembly` upload does **not** create the child +> `plugintype` rows — unlike a `pluginpackage`, which Dataverse extracts itself — so steps could not be +> registered. Assembly mode now reflects the built DLL and creates the missing `plugintype` records +> (`PluginTypeRegistrar`), mirroring the Plugin Registration Tool / spkl. This makes C9 (create the +> assembly) usable end-to-end, so the create path is retained. ## Architecture @@ -66,9 +72,11 @@ StepRegistrar.Sync(assemblyId, …) ← unchanged - `src/dvx/Services/AssemblyDeployer.cs` - `src/dvx/Commands/Shared/PluginDeploymentPlan.cs` — mode/artifact selection + solution-membership predicate - `src/dvx/Commands/Shared/PluginDeployRunner.cs` — shared build-and-deploy step for deploy/sync +- `src/dvx/Services/PluginTypeRegistrar.cs` — registers `plugintype` rows for a deployed assembly - `src/dvx.Tests/AssemblyDeployerTests.cs` - `src/dvx.Tests/ProjectBuilderTests.cs` - `src/dvx.Tests/PluginDeploymentPlanTests.cs` +- `src/dvx.Tests/PluginTypeRegistrarTests.cs` **Modify** - `src/dvx/Services/PackageDeployer.cs` — implement `IPluginDeployer`, derive from base diff --git a/src/dvx.Tests/PluginDiscoveryTests.cs b/src/dvx.Tests/PluginDiscoveryTests.cs index 96da6b9..ee50436 100644 --- a/src/dvx.Tests/PluginDiscoveryTests.cs +++ b/src/dvx.Tests/PluginDiscoveryTests.cs @@ -173,6 +173,27 @@ public class PluginDiscoveryTests private static List Discover() => new PluginDiscovery(NullLogger.Instance).Discover(TestAssemblyPath); + private static IReadOnlyList DiscoverTypeNames() => + new PluginDiscovery(NullLogger.Instance).DiscoverPluginTypeNames(TestAssemblyPath); + + // ── Plugin type names (for plugintype registration) ───────────────────── + + [Fact] + public void DiscoverPluginTypeNames_ReturnsEveryConcretePluginType() + { + var names = DiscoverTypeNames(); + + // Includes classes with no [PluginStep] and [CustomApi] classes — the PRT registers all. + names.ShouldContain(n => n.EndsWith(nameof(TestPluginSingle))); + names.ShouldContain(n => n.EndsWith(nameof(TestPluginNoStep))); + names.ShouldContain(n => n.EndsWith(nameof(TestPluginCustomApi))); + names.ShouldContain(n => n.EndsWith(nameof(TestPluginCustomApiWithStep))); + + // Abstract classes and non-IPlugin types are excluded. + names.ShouldNotContain(n => n.EndsWith(nameof(TestPluginAbstract))); + names.ShouldNotContain(n => n.EndsWith(nameof(NotAPlugin))); + } + // ── Single step ──────────────────────────────────────────────────────── [Fact] diff --git a/src/dvx.Tests/PluginTypeRegistrarTests.cs b/src/dvx.Tests/PluginTypeRegistrarTests.cs new file mode 100644 index 0000000..a1b4734 --- /dev/null +++ b/src/dvx.Tests/PluginTypeRegistrarTests.cs @@ -0,0 +1,88 @@ +using dvx.Services; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class PluginTypeRegistrarTests + { + private static IOrganizationService Svc(params string[] existingTypeNames) + { + var svc = Substitute.For(); + var entities = existingTypeNames + .Select(t => new Entity("plugintype", Guid.NewGuid()) { ["typename"] = t }) + .ToList(); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "plugintype")) + .Returns(new EntityCollection(entities)); + return svc; + } + + [Fact] + public void EnsureRegistered_CreatesMissingTypes() + { + var svc = Svc(); + var assemblyId = Guid.NewGuid(); + + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, new[] { "NS.A", "NS.B" }); + + svc.Received(1).Create(Arg.Is(e => + e.LogicalName == "plugintype" && + (string)e["typename"] == "NS.A" && + (string)e["name"] == "NS.A" && + (string)e["friendlyname"] == "NS.A" && + ((EntityReference)e["pluginassemblyid"]).Id == assemblyId)); + svc.Received(1).Create(Arg.Is(e => (string)e["typename"] == "NS.B")); + svc.Received(2).Create(Arg.Any()); + } + + [Fact] + public void EnsureRegistered_SkipsExistingTypes() + { + var svc = Svc("NS.A"); + var assemblyId = Guid.NewGuid(); + + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, new[] { "NS.A", "NS.B" }); + + svc.Received(1).Create(Arg.Is(e => (string)e["typename"] == "NS.B")); + svc.DidNotReceive().Create(Arg.Is(e => (string)e["typename"] == "NS.A")); + } + + [Fact] + public void EnsureRegistered_DeduplicatesTypeNames() + { + var svc = Svc(); + + new PluginTypeRegistrar(svc).EnsureRegistered(Guid.NewGuid(), new[] { "NS.A", "NS.A" }); + + svc.Received(1).Create(Arg.Any()); + } + + [Fact] + public void EnsureRegistered_EmptyList_DoesNothing() + { + var svc = Svc(); + + new PluginTypeRegistrar(svc).EnsureRegistered(Guid.NewGuid(), Array.Empty()); + + svc.DidNotReceive().Create(Arg.Any()); + svc.DidNotReceive().RetrieveMultiple(Arg.Any()); + } + + [Fact] + public void EnsureRegistered_QueriesTypesForTheGivenAssembly() + { + var svc = Svc(); + var assemblyId = Guid.NewGuid(); + + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, new[] { "NS.A" }); + + svc.Received(1).RetrieveMultiple(Arg.Is(q => + q.EntityName == "plugintype" && + q.Criteria.Conditions.Any(c => + c.AttributeName == "pluginassemblyid" && c.Values.Contains(assemblyId)))); + } + } +} diff --git a/src/dvx/Commands/Shared/PluginDeployRunner.cs b/src/dvx/Commands/Shared/PluginDeployRunner.cs index 7c56a4b..f074163 100644 --- a/src/dvx/Commands/Shared/PluginDeployRunner.cs +++ b/src/dvx/Commands/Shared/PluginDeployRunner.cs @@ -2,6 +2,7 @@ using dvx.Models; using dvx.Output; using dvx.Services; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Xrm.Sdk; namespace dvx.Commands.Shared @@ -47,6 +48,16 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( : new AssemblyDeployer(svc); var assemblyId = deployer.Deploy(artifact, verbose, dryRun); + // A bare pluginassembly upload does not create the plugintype rows (unlike a + // pluginpackage, which Dataverse extracts itself), so register them here — the same + // job the Plugin Registration Tool does — before steps are reconciled. + if (mode == PluginBuildMode.Assembly && !dryRun) + { + var typeNames = new PluginDiscovery(NullLogger.Instance) + .DiscoverPluginTypeNames(build.DllPath); + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, typeNames, verbose); + } + if (PluginDeploymentPlan.ShouldAddAssemblyToSolution(mode, solution, dryRun, assemblyId)) new SolutionService(svc).AddAssemblyToSolution(assemblyId, solution!, verbose); diff --git a/src/dvx/Services/PluginDiscovery.cs b/src/dvx/Services/PluginDiscovery.cs index c2542da..4f1b0b7 100644 --- a/src/dvx/Services/PluginDiscovery.cs +++ b/src/dvx/Services/PluginDiscovery.cs @@ -14,6 +14,35 @@ public class PluginDiscovery(ILogger logger) private const string CustomApiAttrFullName = "dvx.PluginAttributes.CustomApiAttribute"; public List Discover(string dllPath, bool verbose = false) + { + var results = new List(); + ForEachPluginType(dllPath, type => DiscoverSteps(type, verbose, results)); + return results; + } + + /// + /// Returns the full names of every concrete IPlugin type in the assembly. Unlike + /// , this includes types without [PluginStep] and Custom API + /// types — mirroring what the Plugin Registration Tool registers in plugintype. + /// + public IReadOnlyList DiscoverPluginTypeNames(string dllPath) + { + var names = new List(); + ForEachPluginType(dllPath, type => + { + if (type.FullName is not null) + names.Add(type.FullName); + }); + return names; + } + + /// + /// Loads into a metadata-only context and invokes + /// for each concrete type that implements IPlugin. The + /// callback runs inside the lifetime, so the reflected + /// types are only valid for its duration. + /// + private static void ForEachPluginType(string dllPath, Action action) { var pluginDir = Path.GetDirectoryName(dllPath)!; @@ -39,8 +68,6 @@ public List Discover(string dllPath, bool verbose = false) throw new InvalidOperationException($"Failed to load assembly '{dllPath}': {ex.Message}", ex); } - var results = new List(); - foreach (var type in asm.GetTypes()) { if (type.IsAbstract || type.IsInterface) @@ -49,54 +76,55 @@ public List Discover(string dllPath, bool verbose = false) if (!ImplementsIPlugin(type)) continue; - var stepAttrs = GetPluginStepAttributes(type); - // Skip Custom APIs — [CustomApi] takes precedence over any [PluginStep]. - if (HasCustomApiAttribute(type)) - { - if (verbose) - { - Out.Dim($" Skipping {type.FullName} — marked [CustomApi] (not an event plugin)"); - } + action(type); + } + } - if (stepAttrs.Count > 0) - { - Out.Warn( - $" {type.FullName} - has [CustomApi] AND [PluginStep] attributes. It should have only one or the other"); - } + private void DiscoverSteps(Type type, bool verbose, List results) + { + var stepAttrs = GetPluginStepAttributes(type); + // Skip Custom APIs — [CustomApi] takes precedence over any [PluginStep]. + if (HasCustomApiAttribute(type)) + { + if (verbose) + Out.Dim($" Skipping {type.FullName} — marked [CustomApi] (not an event plugin)"); - continue; - } - - if (stepAttrs.Count == 0) + if (stepAttrs.Count > 0) { - logger.LogWarning( - "IPlugin class {Type} has no [PluginStep] attribute — skipping.", type.FullName); - continue; + Out.Warn( + $" {type.FullName} - has [CustomApi] AND [PluginStep] attributes. It should have only one or the other"); } + return; + } + + if (stepAttrs.Count == 0) + { + logger.LogWarning( + "IPlugin class {Type} has no [PluginStep] attribute — skipping.", type.FullName); + return; + } + + if (verbose) + Out.Dim($" Reflecting {type.FullName} — {stepAttrs.Count} [PluginStep] attribute(s)"); + + foreach (var attr in stepAttrs) + { if (verbose) - Out.Dim($" Reflecting {type.FullName} — {stepAttrs.Count} [PluginStep] attribute(s)"); + DumpAttributeToConsole(type.FullName!, attr); - foreach (var attr in stepAttrs) + try { - if (verbose) - DumpAttributeToConsole(type.FullName!, attr); - - try - { - results.Add(BuildDefinition(type.FullName!, attr, verbose)); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Failed to read [PluginStep] attribute on '{type.FullName}'.\n" + - $"Attribute arguments at time of failure:\n{FormatAttributeDump(attr)}", - ex); - } + results.Add(BuildDefinition(type.FullName!, attr, verbose)); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to read [PluginStep] attribute on '{type.FullName}'.\n" + + $"Attribute arguments at time of failure:\n{FormatAttributeDump(attr)}", + ex); } } - - return results; } // ── Verbose console helpers ──────────────────────────────────────────── diff --git a/src/dvx/Services/PluginTypeRegistrar.cs b/src/dvx/Services/PluginTypeRegistrar.cs new file mode 100644 index 0000000..0a6ac30 --- /dev/null +++ b/src/dvx/Services/PluginTypeRegistrar.cs @@ -0,0 +1,59 @@ +using dvx.Output; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace dvx.Services +{ + /// + /// Ensures a plugintype row exists for every plugin class in a deployed assembly. + /// Unlike a pluginpackage upload — where Dataverse extracts the types itself — a bare + /// pluginassembly upload does not create the plugin types, so the registering tool must + /// (this mirrors the Plugin Registration Tool and spkl). Existing types are left untouched; + /// only missing ones are created. + /// + public class PluginTypeRegistrar(IOrganizationService svc) + { + public void EnsureRegistered(Guid assemblyId, IReadOnlyList typeNames, bool verbose = false) + { + if (typeNames.Count == 0) + return; + + var existing = ExistingTypeNames(assemblyId); + + foreach (var typeName in typeNames.Distinct(StringComparer.Ordinal)) + { + if (existing.Contains(typeName)) + continue; + + if (verbose) + Out.Dim($" Registering plugin type '{typeName}'"); + + svc.Create(new Entity("plugintype") + { + ["name"] = typeName, + ["typename"] = typeName, + ["friendlyname"] = typeName, + ["pluginassemblyid"] = new EntityReference("pluginassembly", assemblyId), + }); + } + } + + private HashSet ExistingTypeNames(Guid assemblyId) + { + var query = new QueryExpression("plugintype") + { + ColumnSet = new ColumnSet("typename"), + Criteria = new FilterExpression(), + }; + query.Criteria.AddCondition("pluginassemblyid", ConditionOperator.Equal, assemblyId); + + var set = new HashSet(StringComparer.Ordinal); + foreach (var e in svc.RetrieveMultiple(query).Entities) + { + var name = e.GetAttributeValue("typename"); + if (name is not null) set.Add(name); + } + return set; + } + } +} From b3e33f55f1040238f3f637fcce881387d0805f08 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 15:43:20 +0200 Subject: [PATCH 06/14] Prune orphaned plugin types in assembly mode A removed/renamed plugin class left its plugintype row behind forever. In assembly mode dvx creates those rows itself, so it must also reconcile them. - PluginTypeRegistrar.DeleteOrphans: deletes types absent from the built DLL, scoped to the deployed assembly; deletes each orphan's steps first; never touches types backing a Custom API (SdkMetadata.CustomApiPluginTypeIds) or Custom Action (new CustomActionPluginTypeIds, which required adding workflow.plugintypeid to the SdkMetadata query); no writes on dry-run; refuses to delete when the desired set is empty (reflection-failure safety rail). - PluginDeployRunner: in assembly mode reflect the DLL once, EnsureRegistered then, when --delete-orphaned, DeleteOrphans. Package mode unchanged (Dataverse prunes on package update). - plugin deploy gains --delete-orphaned (reusing the existing option; no new flag). - Tests: 8 new PluginTypeRegistrar cases. README + plan updated. --- README.md | 9 +- ...026-10-07-orphaned-plugin-type-deletion.md | 203 ++++++++++++++++++ src/dvx.Tests/PluginTypeRegistrarTests.cs | 151 ++++++++++++- src/dvx/Commands/DeployCommand.cs | 6 +- src/dvx/Commands/Shared/CommandOptions.cs | 6 +- src/dvx/Commands/Shared/PluginDeployRunner.cs | 16 +- src/dvx/Commands/SyncCommand.cs | 2 +- src/dvx/Services/PluginTypeRegistrar.cs | 116 ++++++++-- src/dvx/Services/SdkMetadata.cs | 17 +- 9 files changed, 487 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md diff --git a/README.md b/README.md index 9667d46..71ca590 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,9 @@ var postImage = context.PostEntityImages["PostImage"]; // UsePostImage = true **Assembly mode registers the plugin types too.** A bare `pluginassembly` upload does **not** make Dataverse create the `plugintype` rows (unlike a `pluginpackage`, which it extracts itself), so in assembly mode dvx creates a `plugintype` for every plugin class in the built `.dll` — the same job -the Plugin Registration Tool performs. This is what lets `sync` then register the steps. +the Plugin Registration Tool performs. This is what lets `sync` then register the steps. With +`--delete-orphaned`, `deploy`/`sync` also remove assembly-mode `plugintype` rows whose class is no +longer in the build (types backing a Custom API/Action are kept). Both modes still run `dotnet build` (Release). CLI `--plugin-build-mode` wins over `pluginBuildMode` in config. @@ -403,7 +405,7 @@ dvx plugin sync --project [options] | `--client-secret` | | env var / config | Service principal client secret | | `--solution-unique-name` | | from config | Add all registered steps to this Dataverse solution | | `--plugin-build-mode` | | from config | Deploy target: `package` (default) or `assembly`. Falls back to `pluginBuildMode` in config. See [Plugin deployment modes](#plugin-deployment-modes) | -| `--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 | +| `--delete-orphaned` | | | Delete plugin registrations no longer present in code: steps, and (assembly mode) plugin types. Steps and types 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 | @@ -413,7 +415,7 @@ dvx plugin sync --project [options] 1. Runs `dotnet build` on the `.csproj` to produce a `.nupkg` and `.dll` 2. In **package** mode: looks up the existing `pluginpackage` by `uniquename` (`{prefix}_{assemblyName}`), updates its `content`, and reads the child `pluginassembly` ID. In **assembly** mode: creates (or updates) the `pluginassembly` by `name` from the built `.dll`, uses its own ID, and registers any missing `plugintype` records for the assembly's plugin classes 3. Reflects the `.dll` for `[PluginStep]` attributes -4. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps (removed only when `--delete-orphaned` is passed) +4. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps; with `--delete-orphaned` it removes orphan steps and (assembly mode) orphan `plugintype` records 5. Syncs `sdkmessageprocessingstepimage` records (pre/post images) for each step > **Note:** in **package** mode, `sync` and `deploy` only support **updating** an existing plugin package. @@ -463,6 +465,7 @@ dvx plugin deploy --project [options] | `--client-secret` | | env var / config | Service principal client secret | | `--solution-unique-name` | | from config | Add the deployed assembly to this Dataverse solution (assembly mode) | | `--plugin-build-mode` | | from config | Deploy target: `package` (default) or `assembly`. Falls back to `pluginBuildMode` in config. See [Plugin deployment modes](#plugin-deployment-modes) | +| `--delete-orphaned` | | | Delete `plugintype` rows in Dataverse no longer present in code (assembly mode). Types backing Custom APIs and Custom Actions are never removed. Destructive — run with `--dry-run` first | | `--dry-run` | | | Print what would happen without writing to Dataverse | | `--config` | | auto-discovered | Path to config file | | `--verbose` | | | Log upload details + inner exception details on error | diff --git a/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md b/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md new file mode 100644 index 0000000..c645863 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md @@ -0,0 +1,203 @@ +# Orphaned Plugin Type Deletion — Implementation Plan + +**Goal:** When a deployed plugin assembly no longer contains a class that Dataverse still has a +`plugintype` record for, remove that orphaned `plugintype` — and the steps/images hanging off it — +in **assembly** deployment mode (`--plugin-build-mode assembly`), behind the existing +`--delete-orphaned` flag, with full `--dry-run` support. + +**Out of scope — package mode:** Dataverse already prunes types when a `pluginpackage` is updated +(version bump), so dvx does not own package-mode type cleanup. + +**Tech Stack:** .NET 9, System.CommandLine, Microsoft.Xrm.Sdk, xUnit, Shouldly, NSubstitute. + +--- + +## Background: why orphans exist + +A `plugintype` row is the registration of one plugin class on a `pluginassembly`. It is a *child* +of the assembly and is what steps bind to (`sdkmessageprocessingstep.plugintypeid`). + +Neither deployment path reconciles types when a class is **removed or renamed**: + +| Mode | Who creates `plugintype` | Who removes a stale one | +|---|---|---| +| `assembly` | dvx itself (`PluginTypeRegistrar`, added in `3509402`) | **nobody** — dvx must | +| `package` | Dataverse, extracting from the `.nupkg` | Dataverse (on package update) — no dvx action | + +So only **assembly** mode needs type pruning. Renaming `Foo` → `Bar` and re-deploying leaves the +old `Foo` type (and possibly its steps) in Dataverse forever. `--delete-orphaned` currently only +prunes orphaned **steps** (`StepRegistrar.cs:158-183`) and orphaned **web resources** +(`WebResourceSyncer.cs:176-196`), not types. + +Terminology: an **orphaned type** = a `plugintype` whose `pluginassemblyid` is the assembly we just +deployed, but whose `typename` is absent from the built DLL. + +--- + +## Locked Decisions + +| # | Decision | +|---|----------| +| T1 | Reuse the existing **`--delete-orphaned`** flag on `sync` **and add it to `deploy`**; extend its meaning to "orphaned registrations" (steps *and* types). Type pruning applies only in **assembly** mode. No new flag. | +| T2 | Pruning is **scoped to the one deployed `pluginassembly` id** — never touch types on any other assembly. | +| T3 | The **desired type set** is always the reflection of the built DLL (`PluginDiscovery.DiscoverPluginTypeNames`), for both modes. | +| T4 | Never delete a type referenced by a **Custom API** (or Custom Action) — mirror the step protection. | +| T5 | Delete an orphan type's **steps first, then the type** (images cascade with the step). | +| T6 | **Skip pruning and warn** when the desired set is empty (guards against a reflection/load failure nuking every type). | +| T7 | Prune **after** ensure-registered and, in `sync`, **before** `StepRegistrar.Sync` — so step reconciliation sees a clean type set. | +| T8 | In `--dry-run`, report "would delete" and perform no writes. | +| T9 | `register` stays steps-only (it does not deploy content); type pruning applies to `deploy`/`sync`. | +| T10 | Default remains off. Destructive — documented as such. | +| T11 | Types are **never** added to a solution (only the assembly and steps are), so pruning leaves no dangling `solutioncomponent`; `AddAssemblyToSolution` keeps `AddRequiredComponents = false`. | + +**Alternative considered:** a separate `--delete-orphaned-types`. Rejected — `--delete-orphaned` +already reads as "remove registrations no longer in code"; two adjacent flags invite confusion. +If reviewers prefer an explicit opt-in, T1 is the one decision to revisit. + +--- + +## Architecture + +``` +PluginDeployRunner.BuildAndDeploy(svc, mode, solution, …, deleteOrphaned) + │ build → PluginDeploymentPlan.Resolve → deployer.Deploy() → assemblyId + │ + ├── if mode == Assembly && !dryRun → PluginTypeRegistrar.EnsureRegistered(assemblyId, desired) + ├── if mode == Assembly && deleteOrphaned → PluginTypeRegistrar.DeleteOrphans(assemblyId, desired, dryRun) + │ └── desired = PluginDiscovery.DiscoverPluginTypeNames(build.DllPath) (assembly mode only) + ▼ +SyncCommand: StepRegistrar.Sync(assemblyId, definitions, …, deleteOrphaned) ← unchanged +``` + +`DeleteOrphans(assemblyId, desired)`: +1. `existing = SdkMetadata.PluginTypeIdByName(assemblyId)` → `{typename → id}` +2. `protectedTypes = CustomApiPluginTypeIds() ∪ CustomActionPluginTypeIds()` +3. `orphans = existing where !desired.Contains(typename) && !protectedTypes.Contains(id)` +4. for each orphan: delete its `sdkmessageprocessingstep` rows, then `svc.Delete("plugintype", id)` + +--- + +## Files + +**Create** +- `src/dvx.Tests/PluginTypeRegistrarTests.cs` — already exists; add the prune cases here. + +**Modify** +- `src/dvx/Services/PluginTypeRegistrar.cs` — add `DeleteOrphans(...)`; factor the shared + "load existing types" query so `EnsureRegistered` and `DeleteOrphans` don't double-query when + both run in the same deploy. +- `src/dvx/Services/SdkMetadata.cs` — add `plugintypeid` to the `CustomActions` `ColumnSet` + (`SdkMetadata.cs:93` currently selects only `workflowid, uniquename`) and add + `CustomActionPluginTypeIds()` beside `CustomActionMessageIds()`; reuse the existing + `CustomApiPluginTypeIds()`. +- `src/dvx/Commands/Shared/PluginDeployRunner.cs` — accept `bool deleteOrphaned`; in **assembly + mode** reflect the DLL once and call `EnsureRegistered` (real runs) then, when `deleteOrphaned`, + `DeleteOrphans`. Package mode is untouched. +- `src/dvx/Commands/DeployCommand.cs` — reuse the existing `--delete-orphaned` option; pass it through. +- `src/dvx/Commands/SyncCommand.cs` — pass the existing `--delete-orphaned` into the runner. +- `src/dvx/Commands/Shared/CommandOptions.cs` — **retitle the existing `DeleteOrphanedSteps()`** + option (help text covers steps **and** types). Do **not** add a new option: `DeleteOrphaned()` + (web resources, `CommandOptions.cs:78`) and `DeleteOrphanedSteps()` (`:83`) already share the + literal `--delete-orphaned` string — a pre-existing duplicate smell worth noting (and, if + desired, a follow-up cleanup), not something to add a third of. +- `README.md` — document the extended `--delete-orphaned` semantics for deploy/sync. + +--- + +## Interfaces + +```csharp +public class PluginTypeRegistrar(IOrganizationService svc) +{ + // existing (assembly-mode create) + public void EnsureRegistered(Guid assemblyId, IReadOnlyList desiredTypeNames, bool verbose = false); + + // new + /// Deletes plugintypes on that are absent from the desired + /// set (and not backing a Custom API/Action), deleting each orphan's steps first. No writes on dry-run. + public void DeleteOrphans(Guid assemblyId, IReadOnlyList desiredTypeNames, + bool dryRun = false, bool verbose = false); +} +``` + +--- + +## Task Breakdown + +Every task is TDD: failing test → implement → green. Command wiring has no unit seam (as with the +existing deploy flow) → covered by runner code review + a manual dev-org check. + +### Task 1 — `PluginTypeRegistrar.DeleteOrphans` +- [x] **Red:** `PluginTypeRegistrarTests` (NSubstitute `IOrganizationService`): + deletes a type not in the desired set; keeps one that is; **never** deletes a Custom-API type; + query is filtered by `pluginassemblyid`; deletes the orphan's `sdkmessageprocessingstep` rows + before the `plugintype`; no `Delete` on dry-run; empty desired set → no deletes + no throw. + Run `dotnet test --filter PluginTypeRegistrarTests` — confirm failure. +- [x] **Green:** implement `DeleteOrphans` (query existing + protected ids, resolve orphans, + delete steps then type). +- [x] Run `dotnet test --filter PluginTypeRegistrarTests` — confirm pass. + +### Task 2 — Custom-Action type protection +- [x] **Red:** add `plugintypeid` to the `CustomActions` `ColumnSet` (`SdkMetadata.cs:93` fetches only + `workflowid, uniquename` today), add `CustomActionPluginTypeIds()`, and extend `PluginTypeRegistrarTests` + with a Custom-Action-backed type that must **not** be deleted. Confirm failure. +- [x] **Green:** fold `CustomActionPluginTypeIds()` into the protected set used by `DeleteOrphans`. +- [x] Confirm pass. (The `workflow` entity does carry `plugintypeid` for Action definitions — this is + real work, not a conditional.) + +### Task 3 — Empty-desired guard (safety rail) +- [x] **Red:** test: `DeleteOrphans(assemblyId, emptyDesired)` with existing types → no deletes. +- [x] **Green:** return early (+ `Out.Warn`) when `desiredTypeNames.Count == 0`. + +### Task 4 — Wire into `PluginDeployRunner` +- [x] Add `bool deleteOrphaned` to `BuildAndDeploy`; in **assembly mode** reflect the DLL once; call + `EnsureRegistered` (real runs) and, when `deleteOrphaned`, `DeleteOrphans`. Package mode is + untouched. Keep dry-run read-only. +- [x] Manual smoke: unit build still green (`dotnet build dvx.sln`, `dotnet test`). + +### Task 5 — CLI surface +- [x] `CommandOptions`: **retitle** the existing `DeleteOrphanedSteps()` option so its help covers + steps and plugin types. Reuse it — no new option (see the pre-existing duplicate note above: + `DeleteOrphaned()` and `DeleteOrphanedSteps()` already share `--delete-orphaned`). +- [x] `DeployCommand`: reuse the `DeleteOrphanedSteps()` option (do not add a third definition); + pass the value into the runner. +- [x] `SyncCommand`: pass the existing flag into the runner. + +### Task 6 — Docs +- [x] README: extend the `--delete-orphaned` rows for `deploy` and `sync`; note types are pruned + scoped to the deployed assembly, Custom-API types are never removed, and dry-run first. + +--- + +## Edge cases & protections (must all hold) + +- **Custom API backing type** — skip (would break the API). Warn. +- **Custom Action backing type** — skip (Task 2: `workflow.plugintypeid`, resolved via `CustomActionPluginTypeIds()`). +- **Renamed class** — new type created (ensure) + old type pruned = clean rename. +- **Ordering in `sync`** — prune before `StepRegistrar.Sync` so `SdkMetadata.PluginTypeIdByName` + no longer returns the removed types; orphan-type steps are already gone, so `StepRegistrar`'s + own orphan-step pass only handles steps whose class still exists. +- **Steps on the orphan type that are Custom-API-protected** — protected by type-protection, so + they survive; consistent with `StepRegistrar`. +- **Empty desired set / reflection failure** — reflection failures throw (fail closed, no deletes); + empty desired set skips with a warning (T6). +- **Prune scope** — only the deployed assembly id is touched; other assemblies' types are never candidates (T2). +- **Types & solutions** — types are never added to a solution (only the assembly and steps are, T11), + so pruning cannot leave a dangling `solutioncomponent`. +- **Dry-run** — no writes; "would delete N orphaned type(s)". + +--- + +## Verification + +- `dotnet build dvx.sln` (0 warnings) +- `dotnet test` (all green, new `PluginTypeRegistrarTests` cases included) +- Manual, against a dev org: + 1. Deploy assembly mode with classes A, B → 2 `plugintype` rows. + 2. Remove B, redeploy with `--delete-orphaned` → B's type gone; A intact. + 3. Rename A→C, redeploy with `--delete-orphaned` → C created, A gone. + 4. Add a `[CustomApi]` class, deploy, remove it, redeploy with `--delete-orphaned` → its type is + **kept** with a warning. + 5. `--dry-run` with `--delete-orphaned` → reports the deletions, writes nothing. + 6. Package mode: bump the package version and confirm Dataverse removes the stale type itself (no dvx action). + diff --git a/src/dvx.Tests/PluginTypeRegistrarTests.cs b/src/dvx.Tests/PluginTypeRegistrarTests.cs index a1b4734..f1c370a 100644 --- a/src/dvx.Tests/PluginTypeRegistrarTests.cs +++ b/src/dvx.Tests/PluginTypeRegistrarTests.cs @@ -9,17 +9,46 @@ namespace dvx.Tests { public class PluginTypeRegistrarTests { - private static IOrganizationService Svc(params string[] existingTypeNames) + // ── Helpers ──────────────────────────────────────────────────────────── + + // A service whose every query returns empty unless a test configures a specific entity. + private static IOrganizationService Svc() { var svc = Substitute.For(); - var entities = existingTypeNames - .Select(t => new Entity("plugintype", Guid.NewGuid()) { ["typename"] = t }) - .ToList(); - svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "plugintype")) - .Returns(new EntityCollection(entities)); + svc.RetrieveMultiple(Arg.Any()).Returns(new EntityCollection()); return svc; } + private static Entity Type(string name, Guid id) => + new("plugintype", id) { ["typename"] = name }; + + private static void HasTypes(IOrganizationService svc, params Entity[] types) => + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "plugintype")) + .Returns(new EntityCollection(types.ToList())); + + private static void HasCustomApi(IOrganizationService svc, Guid plugintypeId) => + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "customapi")) + .Returns(new EntityCollection(new List + { + new("customapi", Guid.NewGuid()) + { ["plugintypeid"] = new EntityReference("plugintype", plugintypeId) } + })); + + private static void HasCustomAction(IOrganizationService svc, Guid plugintypeId) => + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "workflow")) + .Returns(new EntityCollection(new List + { + new("workflow", Guid.NewGuid()) + { ["plugintypeid"] = new EntityReference("plugintype", plugintypeId) } + })); + + private static void HasSteps(IOrganizationService svc, params Guid[] stepIds) => + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "sdkmessageprocessingstep")) + .Returns(new EntityCollection( + stepIds.Select(id => new Entity("sdkmessageprocessingstep", id)).ToList())); + + // ── EnsureRegistered ─────────────────────────────────────────────────── + [Fact] public void EnsureRegistered_CreatesMissingTypes() { @@ -41,8 +70,9 @@ public void EnsureRegistered_CreatesMissingTypes() [Fact] public void EnsureRegistered_SkipsExistingTypes() { - var svc = Svc("NS.A"); + var svc = Svc(); var assemblyId = Guid.NewGuid(); + HasTypes(svc, Type("NS.A", Guid.NewGuid())); new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, new[] { "NS.A", "NS.B" }); @@ -68,7 +98,6 @@ public void EnsureRegistered_EmptyList_DoesNothing() new PluginTypeRegistrar(svc).EnsureRegistered(Guid.NewGuid(), Array.Empty()); svc.DidNotReceive().Create(Arg.Any()); - svc.DidNotReceive().RetrieveMultiple(Arg.Any()); } [Fact] @@ -84,5 +113,111 @@ public void EnsureRegistered_QueriesTypesForTheGivenAssembly() q.Criteria.Conditions.Any(c => c.AttributeName == "pluginassemblyid" && c.Values.Contains(assemblyId)))); } + + // ── DeleteOrphans ────────────────────────────────────────────────────── + + [Fact] + public void DeleteOrphans_DeletesTypeNotInDesiredSet() + { + var svc = Svc(); + var orphanId = Guid.NewGuid(); + HasTypes(svc, Type("NS.Old", orphanId)); + + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + + svc.Received(1).Delete("plugintype", orphanId); + } + + [Fact] + public void DeleteOrphans_KeepsTypesInDesiredSet() + { + var svc = Svc(); + HasTypes(svc, Type("NS.Keep", Guid.NewGuid())); + + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.Keep" }); + + svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } + + [Fact] + public void DeleteOrphans_DeletesOrphanStepsBeforeTheType() + { + var svc = Svc(); + var orphanId = Guid.NewGuid(); + var stepId = Guid.NewGuid(); + HasTypes(svc, Type("NS.Old", orphanId)); + HasSteps(svc, stepId); + + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + + Received.InOrder(() => + { + svc.Delete("sdkmessageprocessingstep", stepId); + svc.Delete("plugintype", orphanId); + }); + } + + [Fact] + public void DeleteOrphans_NeverDeletesCustomApiBackedType() + { + var svc = Svc(); + var typeId = Guid.NewGuid(); + HasTypes(svc, Type("NS.Api", typeId)); + HasCustomApi(svc, typeId); + + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + + svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } + + [Fact] + public void DeleteOrphans_NeverDeletesCustomActionBackedType() + { + var svc = Svc(); + var typeId = Guid.NewGuid(); + HasTypes(svc, Type("NS.Action", typeId)); + HasCustomAction(svc, typeId); + + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + + svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } + + [Fact] + public void DeleteOrphans_EmptyDesiredSet_DeletesNothing() + { + var svc = Svc(); + HasTypes(svc, Type("NS.A", Guid.NewGuid()), Type("NS.B", Guid.NewGuid())); + + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), Array.Empty()); + + svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } + + [Fact] + public void DeleteOrphans_DryRun_DeletesNothing() + { + var svc = Svc(); + HasTypes(svc, Type("NS.Old", Guid.NewGuid())); + + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }, dryRun: true); + + svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } + + [Fact] + public void DeleteOrphans_QueriesTypesForTheGivenAssembly() + { + var svc = Svc(); + var assemblyId = Guid.NewGuid(); + HasTypes(svc, Type("NS.Old", Guid.NewGuid())); + + new PluginTypeRegistrar(svc).DeleteOrphans(assemblyId, new[] { "NS.New" }); + + svc.Received(1).RetrieveMultiple(Arg.Is(q => + q.EntityName == "plugintype" && + q.Criteria.Conditions.Any(c => + c.AttributeName == "pluginassemblyid" && c.Values.Contains(assemblyId)))); + } } } diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 1f2562b..2b5a4eb 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -23,10 +23,11 @@ public static Command Build() var pluginBuildMode = CommandOptions.PluginBuildMode(); var interactiveAuth = CommandOptions.InteractiveAuth(); var dryRun = CommandOptions.DryRun(); + var deleteOrphaned = CommandOptions.DeleteOrphanedSteps(); var verbose = CommandOptions.Verbose(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix, - solutionUniqueName, pluginBuildMode, interactiveAuth, dryRun, verbose); + solutionUniqueName, pluginBuildMode, interactiveAuth, dryRun, deleteOrphaned, verbose); cmd.SetHandler((InvocationContext ctx) => { @@ -41,6 +42,7 @@ public static Command Build() var cliMode = ctx.ParseResult.GetValueForOption(pluginBuildMode); var cliInteractive = ctx.ParseResult.GetValueForOption(interactiveAuth); var isDryRun = ctx.ParseResult.GetValueForOption(dryRun); + var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); try @@ -59,7 +61,7 @@ public static Command Build() if (prefixWarning is not null) Out.Warn(prefixWarning); PluginDeployRunner.BuildAndDeploy(svc, mode, solution, envConfig.Url, - resolvedProject, prefix, isVerbose, isDryRun); + resolvedProject, prefix, isVerbose, isDryRun, delOrphaned); } catch (Exception ex) { diff --git a/src/dvx/Commands/Shared/CommandOptions.cs b/src/dvx/Commands/Shared/CommandOptions.cs index bcd9b4f..38fa253 100644 --- a/src/dvx/Commands/Shared/CommandOptions.cs +++ b/src/dvx/Commands/Shared/CommandOptions.cs @@ -82,9 +82,9 @@ public static class CommandOptions 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."); + "Delete plugin registrations that exist in Dataverse but are no longer present in code: " + + "steps, and (in assembly mode) plugin types. Steps and types backing Custom APIs and " + + "Custom Actions are never removed. Destructive — run with --dry-run first."); public static Option DryRun() => new Option( "--dry-run", diff --git a/src/dvx/Commands/Shared/PluginDeployRunner.cs b/src/dvx/Commands/Shared/PluginDeployRunner.cs index f074163..b60d64a 100644 --- a/src/dvx/Commands/Shared/PluginDeployRunner.cs +++ b/src/dvx/Commands/Shared/PluginDeployRunner.cs @@ -25,7 +25,8 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( string project, string prefix, bool verbose, - bool dryRun) + bool dryRun, + bool deleteOrphaned) { Out.Step("Building", project); var build = new ProjectBuilder().BuildAllowingMissingPackage(project); @@ -50,12 +51,19 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( // A bare pluginassembly upload does not create the plugintype rows (unlike a // pluginpackage, which Dataverse extracts itself), so register them here — the same - // job the Plugin Registration Tool does — before steps are reconciled. - if (mode == PluginBuildMode.Assembly && !dryRun) + // job the Plugin Registration Tool does — before steps are reconciled. On request, + // also remove types whose class is no longer in the build. + if (mode == PluginBuildMode.Assembly) { var typeNames = new PluginDiscovery(NullLogger.Instance) .DiscoverPluginTypeNames(build.DllPath); - new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, typeNames, verbose); + var registrar = new PluginTypeRegistrar(svc); + + if (!dryRun) + registrar.EnsureRegistered(assemblyId, typeNames, verbose); + + if (deleteOrphaned) + registrar.DeleteOrphans(assemblyId, typeNames, dryRun, verbose); } if (PluginDeploymentPlan.ShouldAddAssemblyToSolution(mode, solution, dryRun, assemblyId)) diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index 87a147f..b1d8f8c 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -63,7 +63,7 @@ public static Command Build(ILoggerFactory loggerFactory) // ── Build + deploy ────────────────────────────────────── var (assemblyId, dllPath) = PluginDeployRunner.BuildAndDeploy( - svc, mode, solution, envConfig.Url, resolvedProject, prefix, isVerbose, isDryRun); + svc, mode, solution, envConfig.Url, resolvedProject, prefix, isVerbose, isDryRun, delOrphaned); // ── Register ──────────────────────────────────────────── // A brand-new assembly under a dry run has no id (nothing was created), so there diff --git a/src/dvx/Services/PluginTypeRegistrar.cs b/src/dvx/Services/PluginTypeRegistrar.cs index 0a6ac30..2f63fed 100644 --- a/src/dvx/Services/PluginTypeRegistrar.cs +++ b/src/dvx/Services/PluginTypeRegistrar.cs @@ -5,22 +5,24 @@ namespace dvx.Services { /// - /// Ensures a plugintype row exists for every plugin class in a deployed assembly. - /// Unlike a pluginpackage upload — where Dataverse extracts the types itself — a bare - /// pluginassembly upload does not create the plugin types, so the registering tool must - /// (this mirrors the Plugin Registration Tool and spkl). Existing types are left untouched; - /// only missing ones are created. + /// Reconciles the plugintype rows for a deployed assembly. A bare pluginassembly + /// upload does not create them (unlike a pluginpackage, which Dataverse extracts itself), + /// so dvx registers each plugin class itself — mirroring the Plugin Registration Tool / spkl — + /// and, on request, removes rows whose class is no longer present. /// public class PluginTypeRegistrar(IOrganizationService svc) { - public void EnsureRegistered(Guid assemblyId, IReadOnlyList typeNames, bool verbose = false) + /// + /// Creates a plugintype for every desired type that does not already exist on the assembly. + /// + public void EnsureRegistered(Guid assemblyId, IReadOnlyList desiredTypeNames, bool verbose = false) { - if (typeNames.Count == 0) + if (desiredTypeNames.Count == 0) return; - var existing = ExistingTypeNames(assemblyId); + var existing = ExistingTypes(assemblyId).Select(t => t.TypeName).ToHashSet(StringComparer.Ordinal); - foreach (var typeName in typeNames.Distinct(StringComparer.Ordinal)) + foreach (var typeName in desiredTypeNames.Distinct(StringComparer.Ordinal)) { if (existing.Contains(typeName)) continue; @@ -38,22 +40,102 @@ public void EnsureRegistered(Guid assemblyId, IReadOnlyList typeNames, b } } - private HashSet ExistingTypeNames(Guid assemblyId) + /// + /// Deletes the assembly's plugintype rows whose typename is absent from the + /// desired set, deleting each orphan's steps first. Types backing a Custom API or Custom + /// Action are never removed. No writes on a dry run. + /// + public void DeleteOrphans(Guid assemblyId, IReadOnlyList desiredTypeNames, + bool dryRun = false, bool verbose = false) + { + var existing = ExistingTypes(assemblyId); + if (existing.Count == 0) + return; + + // Safety rail: an empty desired set usually means reflection/load failed, not that the + // assembly genuinely has no plugin classes — refuse to delete everything. + if (desiredTypeNames.Count == 0) + { + Out.Warn("No plugin types were found in the build — skipping orphaned type deletion."); + return; + } + + var desired = new HashSet(desiredTypeNames, StringComparer.Ordinal); + var protectedIds = ProtectedTypeIds(); + + var deletable = new List(); + foreach (var type in existing) + { + if (desired.Contains(type.TypeName)) + continue; + + if (protectedIds.Contains(type.Id)) + { + Out.Warn($"Keeping plugin type '{type.TypeName}' — it backs a Custom API or Custom Action."); + continue; + } + + deletable.Add(type); + } + + foreach (var type in deletable) + { + if (verbose) + Out.Dim($" Deleting orphaned plugin type '{type.TypeName}'"); + + if (dryRun) + continue; + + DeleteStepsFor(type.Id); + svc.Delete("plugintype", type.Id); + } + + if (deletable.Count == 0) + return; + + if (dryRun) + Out.DryRun($"Would prune {deletable.Count} orphaned plugin type(s)."); + else + Out.Success("Pruned", $"{deletable.Count} orphaned plugin type(s)."); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private record ExistingType(Guid Id, string TypeName); + + private List ExistingTypes(Guid assemblyId) { var query = new QueryExpression("plugintype") { - ColumnSet = new ColumnSet("typename"), + ColumnSet = new ColumnSet("plugintypeid", "typename"), Criteria = new FilterExpression(), }; query.Criteria.AddCondition("pluginassemblyid", ConditionOperator.Equal, assemblyId); - var set = new HashSet(StringComparer.Ordinal); - foreach (var e in svc.RetrieveMultiple(query).Entities) - { - var name = e.GetAttributeValue("typename"); - if (name is not null) set.Add(name); - } + return svc.RetrieveMultiple(query).Entities + .Select(e => new ExistingType(e.Id, e.GetAttributeValue("typename") ?? string.Empty)) + .ToList(); + } + + private HashSet ProtectedTypeIds() + { + var meta = new SdkMetadata(svc); + var set = meta.CustomApiPluginTypeIds(); + set.UnionWith(meta.CustomActionPluginTypeIds()); return set; } + + private void DeleteStepsFor(Guid pluginTypeId) + { + var query = new QueryExpression("sdkmessageprocessingstep") + { + ColumnSet = new ColumnSet("sdkmessageprocessingstepid"), + Criteria = new FilterExpression(), + }; + query.Criteria.AddCondition("plugintypeid", ConditionOperator.Equal, pluginTypeId); + + foreach (var e in svc.RetrieveMultiple(query).Entities) + svc.Delete("sdkmessageprocessingstep", e.Id); + } } } diff --git a/src/dvx/Services/SdkMetadata.cs b/src/dvx/Services/SdkMetadata.cs index c4dd625..94b6cbf 100644 --- a/src/dvx/Services/SdkMetadata.cs +++ b/src/dvx/Services/SdkMetadata.cs @@ -93,7 +93,7 @@ public Guid SystemUserId() private IReadOnlyList CustomActions => _customActions ??= svc.RetrieveMultiple(new QueryExpression("workflow") { - ColumnSet = new ColumnSet("workflowid", "uniquename"), + ColumnSet = new ColumnSet("workflowid", "uniquename", "plugintypeid"), Criteria = new FilterExpression { Conditions = @@ -253,6 +253,21 @@ public HashSet CustomActionMessageIds() return set; } + /// + /// Retrieves the set of GUIDs for the plugin types that back Custom Actions. + /// + /// A hash set containing the plugin type IDs referenced by Custom Action definitions. + public HashSet CustomActionPluginTypeIds() + { + var set = new HashSet(); + foreach (var e in CustomActions) + { + var typeRef = e.GetAttributeValue("plugintypeid"); + if (typeRef is not null) set.Add(typeRef.Id); + } + return set; + } + /// /// Retrieves a dictionary mapping plugin type IDs to their respective type names for a given assembly. /// From dc6caf44661ae98abd2d16bf9069a2bd5f65d0a6 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 15:58:22 +0200 Subject: [PATCH 07/14] Reconcile assembly plugin types before the content update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dataverse validates every registered plugintype against the incoming assembly during the pluginassembly content update and rejects it ("PluginType [...] not found in PluginAssembly [...]") when a removed class's type still exists. The previous code pruned orphans AFTER the update, so the update failed first. - Move type reconciliation before the update, unconditionally (spkl-style) — a stale type makes the update impossible, so it is not an optional cleanup. - New PluginDeployRunner.DeployAssembly seam (internal): find existing id -> DeleteOrphans -> deploy content -> EnsureRegistered. Unit-tested (PluginDeployRunnerTests) for the ordering. - AssemblyDeployer.FindExistingId(name) exposed. - deploy no longer takes --delete-orphaned (types reconcile automatically); --delete-orphaned stays steps-only for sync/register. - README + plan updated. Verified red-first: PluginDeployRunnerTests failed to compile before DeployAssembly existed. --- README.md | 12 +- ...026-10-07-orphaned-plugin-type-deletion.md | 114 +++++++++-------- src/dvx.Tests/PluginDeployRunnerTests.cs | 116 ++++++++++++++++++ src/dvx/Commands/DeployCommand.cs | 6 +- src/dvx/Commands/Shared/CommandOptions.cs | 6 +- src/dvx/Commands/Shared/PluginDeployRunner.cs | 53 +++++--- src/dvx/Commands/SyncCommand.cs | 2 +- src/dvx/Services/AssemblyDeployer.cs | 11 +- 8 files changed, 228 insertions(+), 92 deletions(-) create mode 100644 src/dvx.Tests/PluginDeployRunnerTests.cs diff --git a/README.md b/README.md index 71ca590..7136461 100644 --- a/README.md +++ b/README.md @@ -373,9 +373,10 @@ var postImage = context.PostEntityImages["PostImage"]; // UsePostImage = true **Assembly mode registers the plugin types too.** A bare `pluginassembly` upload does **not** make Dataverse create the `plugintype` rows (unlike a `pluginpackage`, which it extracts itself), so in assembly mode dvx creates a `plugintype` for every plugin class in the built `.dll` — the same job -the Plugin Registration Tool performs. This is what lets `sync` then register the steps. With -`--delete-orphaned`, `deploy`/`sync` also remove assembly-mode `plugintype` rows whose class is no -longer in the build (types backing a Custom API/Action are kept). +the Plugin Registration Tool performs. In assembly mode dvx also reconciles these rows on every +deploy — a type whose class is no longer in the build is removed (with its steps) **before** the +content update, because Dataverse rejects the update while a stale type remains (types backing a +Custom API/Action are kept). Both modes still run `dotnet build` (Release). CLI `--plugin-build-mode` wins over `pluginBuildMode` in config. @@ -405,7 +406,7 @@ dvx plugin sync --project [options] | `--client-secret` | | env var / config | Service principal client secret | | `--solution-unique-name` | | from config | Add all registered steps to this Dataverse solution | | `--plugin-build-mode` | | from config | Deploy target: `package` (default) or `assembly`. Falls back to `pluginBuildMode` in config. See [Plugin deployment modes](#plugin-deployment-modes) | -| `--delete-orphaned` | | | Delete plugin registrations no longer present in code: steps, and (assembly mode) plugin types. Steps and types backing Custom APIs and Custom Actions are never removed. Destructive — run with `--dry-run` first | +| `--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 | @@ -415,7 +416,7 @@ dvx plugin sync --project [options] 1. Runs `dotnet build` on the `.csproj` to produce a `.nupkg` and `.dll` 2. In **package** mode: looks up the existing `pluginpackage` by `uniquename` (`{prefix}_{assemblyName}`), updates its `content`, and reads the child `pluginassembly` ID. In **assembly** mode: creates (or updates) the `pluginassembly` by `name` from the built `.dll`, uses its own ID, and registers any missing `plugintype` records for the assembly's plugin classes 3. Reflects the `.dll` for `[PluginStep]` attributes -4. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps; with `--delete-orphaned` it removes orphan steps and (assembly mode) orphan `plugintype` records +4. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps (removed only when `--delete-orphaned` is passed) 5. Syncs `sdkmessageprocessingstepimage` records (pre/post images) for each step > **Note:** in **package** mode, `sync` and `deploy` only support **updating** an existing plugin package. @@ -465,7 +466,6 @@ dvx plugin deploy --project [options] | `--client-secret` | | env var / config | Service principal client secret | | `--solution-unique-name` | | from config | Add the deployed assembly to this Dataverse solution (assembly mode) | | `--plugin-build-mode` | | from config | Deploy target: `package` (default) or `assembly`. Falls back to `pluginBuildMode` in config. See [Plugin deployment modes](#plugin-deployment-modes) | -| `--delete-orphaned` | | | Delete `plugintype` rows in Dataverse no longer present in code (assembly mode). Types backing Custom APIs and Custom Actions are never removed. Destructive — run with `--dry-run` first | | `--dry-run` | | | Print what would happen without writing to Dataverse | | `--config` | | auto-discovered | Path to config file | | `--verbose` | | | Log upload details + inner exception details on error | diff --git a/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md b/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md index c645863..c2833d8 100644 --- a/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md +++ b/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md @@ -1,13 +1,20 @@ # Orphaned Plugin Type Deletion — Implementation Plan **Goal:** When a deployed plugin assembly no longer contains a class that Dataverse still has a -`plugintype` record for, remove that orphaned `plugintype` — and the steps/images hanging off it — -in **assembly** deployment mode (`--plugin-build-mode assembly`), behind the existing -`--delete-orphaned` flag, with full `--dry-run` support. +`plugintype` record for, remove that orphaned `plugintype` (and the steps hanging off it) as part of +the **assembly** deploy — **automatically**, not gated by a flag, because Dataverse rejects the +`pluginassembly` content update while a stale type remains. `--delete-orphaned` continues to govern +orphan **steps**. **Out of scope — package mode:** Dataverse already prunes types when a `pluginpackage` is updated (version bump), so dvx does not own package-mode type cleanup. +> **Resolution update (post-review):** the original design gated this behind `--delete-orphaned` +> and pruned *after* the content update. Manual testing showed the update itself is rejected by +> Dataverse (`PluginType [...] not found in PluginAssembly [...]`), so reconciliation is +> **unconditional** and runs **before** the update — the order spkl uses +> (`UnregisterRemovedPluginTypes` → `Update`). + **Tech Stack:** .NET 9, System.CommandLine, Microsoft.Xrm.Sdk, xUnit, Shouldly, NSubstitute. --- @@ -38,35 +45,38 @@ deployed, but whose `typename` is absent from the built DLL. | # | Decision | |---|----------| -| T1 | Reuse the existing **`--delete-orphaned`** flag on `sync` **and add it to `deploy`**; extend its meaning to "orphaned registrations" (steps *and* types). Type pruning applies only in **assembly** mode. No new flag. | +| T1 | Type reconciliation is **unconditional** in assembly mode (spkl-style) and runs **before** the content update. `--delete-orphaned` still governs orphan **steps** only. No new flag. | | T2 | Pruning is **scoped to the one deployed `pluginassembly` id** — never touch types on any other assembly. | -| T3 | The **desired type set** is always the reflection of the built DLL (`PluginDiscovery.DiscoverPluginTypeNames`), for both modes. | +| T3 | The **desired type set** is the reflection of the built DLL (`PluginDiscovery.DiscoverPluginTypeNames`). | | T4 | Never delete a type referenced by a **Custom API** (or Custom Action) — mirror the step protection. | | T5 | Delete an orphan type's **steps first, then the type** (images cascade with the step). | | T6 | **Skip pruning and warn** when the desired set is empty (guards against a reflection/load failure nuking every type). | -| T7 | Prune **after** ensure-registered and, in `sync`, **before** `StepRegistrar.Sync` — so step reconciliation sees a clean type set. | +| T7 | Order within an assembly deploy: (1) delete orphan types (existing ∖ desired, minus Custom-API/Action types) → (2) update the content → (3) register new types. An orphan type's steps are deleted with it; `StepRegistrar` then handles steps whose class still exists. | | T8 | In `--dry-run`, report "would delete" and perform no writes. | -| T9 | `register` stays steps-only (it does not deploy content); type pruning applies to `deploy`/`sync`. | -| T10 | Default remains off. Destructive — documented as such. | +| T9 | `register` stays steps-only (it does not deploy content). | +| T10 | Reconciliation is automatic; no flag can disable it (the update cannot succeed otherwise). | | T11 | Types are **never** added to a solution (only the assembly and steps are), so pruning leaves no dangling `solutioncomponent`; `AddAssemblyToSolution` keeps `AddRequiredComponents = false`. | -**Alternative considered:** a separate `--delete-orphaned-types`. Rejected — `--delete-orphaned` -already reads as "remove registrations no longer in code"; two adjacent flags invite confusion. -If reviewers prefer an explicit opt-in, T1 is the one decision to revisit. +**Alternative considered:** reusing `--delete-orphaned` to gate type deletion (the original design). +Rejected after manual testing — the `pluginassembly` content update is rejected by Dataverse while a +stale type remains, so reconciliation must be unconditional and happen before the update. --- ## Architecture ``` -PluginDeployRunner.BuildAndDeploy(svc, mode, solution, …, deleteOrphaned) - │ build → PluginDeploymentPlan.Resolve → deployer.Deploy() → assemblyId +PluginDeployRunner.BuildAndDeploy(svc, mode, solution, …) + │ build → PluginDeploymentPlan.Resolve → deploy → assemblyId │ - ├── if mode == Assembly && !dryRun → PluginTypeRegistrar.EnsureRegistered(assemblyId, desired) - ├── if mode == Assembly && deleteOrphaned → PluginTypeRegistrar.DeleteOrphans(assemblyId, desired, dryRun) - │ └── desired = PluginDiscovery.DiscoverPluginTypeNames(build.DllPath) (assembly mode only) + └── mode == Assembly → DeployAssembly(...) (seam; unit-tested in PluginDeployRunnerTests) + desired = PluginDiscovery.DiscoverPluginTypeNames(build.DllPath) + 1. existingId = AssemblyDeployer.FindExistingId(name) + 2. if existingId: PluginTypeRegistrar.DeleteOrphans(existingId, desired, dryRun) ← BEFORE update + 3. AssemblyDeployer.Deploy(artifact) (content update) + 4. if !dryRun: PluginTypeRegistrar.EnsureRegistered(assemblyId, desired) ▼ -SyncCommand: StepRegistrar.Sync(assemblyId, definitions, …, deleteOrphaned) ← unchanged +SyncCommand: StepRegistrar.Sync(assemblyId, definitions, …, deleteOrphaned) ← steps only, unchanged ``` `DeleteOrphans(assemblyId, desired)`: @@ -83,24 +93,17 @@ SyncCommand: StepRegistrar.Sync(assemblyId, definitions, …, deleteOrphaned) - `src/dvx.Tests/PluginTypeRegistrarTests.cs` — already exists; add the prune cases here. **Modify** -- `src/dvx/Services/PluginTypeRegistrar.cs` — add `DeleteOrphans(...)`; factor the shared - "load existing types" query so `EnsureRegistered` and `DeleteOrphans` don't double-query when - both run in the same deploy. +- `src/dvx/Services/PluginTypeRegistrar.cs` — add `DeleteOrphans(...)` (used by the assembly deploy). - `src/dvx/Services/SdkMetadata.cs` — add `plugintypeid` to the `CustomActions` `ColumnSet` - (`SdkMetadata.cs:93` currently selects only `workflowid, uniquename`) and add - `CustomActionPluginTypeIds()` beside `CustomActionMessageIds()`; reuse the existing - `CustomApiPluginTypeIds()`. -- `src/dvx/Commands/Shared/PluginDeployRunner.cs` — accept `bool deleteOrphaned`; in **assembly - mode** reflect the DLL once and call `EnsureRegistered` (real runs) then, when `deleteOrphaned`, - `DeleteOrphans`. Package mode is untouched. -- `src/dvx/Commands/DeployCommand.cs` — reuse the existing `--delete-orphaned` option; pass it through. -- `src/dvx/Commands/SyncCommand.cs` — pass the existing `--delete-orphaned` into the runner. -- `src/dvx/Commands/Shared/CommandOptions.cs` — **retitle the existing `DeleteOrphanedSteps()`** - option (help text covers steps **and** types). Do **not** add a new option: `DeleteOrphaned()` - (web resources, `CommandOptions.cs:78`) and `DeleteOrphanedSteps()` (`:83`) already share the - literal `--delete-orphaned` string — a pre-existing duplicate smell worth noting (and, if - desired, a follow-up cleanup), not something to add a third of. -- `README.md` — document the extended `--delete-orphaned` semantics for deploy/sync. + (`SdkMetadata.cs:93` selects only `workflowid, uniquename` today) and add + `CustomActionPluginTypeIds()` beside `CustomActionMessageIds()`; reuse `CustomApiPluginTypeIds()`. +- `src/dvx/Services/AssemblyDeployer.cs` — expose `FindExistingId(name)` (refactored from `ResolveExistingId`). +- `src/dvx/Commands/Shared/PluginDeployRunner.cs` — assembly mode reflects the DLL, then calls + `DeployAssembly(...)` (the internal, unit-tested seam): delete orphan types **before** the content + update, deploy, register new types after. Package mode is untouched; `deploy` gains no flag. +- `src/dvx/Commands/SyncCommand.cs` — unchanged runner call; its `--delete-orphaned` still governs + orphan steps via `StepRegistrar`. +- `README.md` — describe automatic assembly-mode type reconciliation; keep `--delete-orphaned` steps-only. --- @@ -150,22 +153,18 @@ existing deploy flow) → covered by runner code review + a manual dev-org check - [x] **Green:** return early (+ `Out.Warn`) when `desiredTypeNames.Count == 0`. ### Task 4 — Wire into `PluginDeployRunner` -- [x] Add `bool deleteOrphaned` to `BuildAndDeploy`; in **assembly mode** reflect the DLL once; call - `EnsureRegistered` (real runs) and, when `deleteOrphaned`, `DeleteOrphans`. Package mode is - untouched. Keep dry-run read-only. -- [x] Manual smoke: unit build still green (`dotnet build dvx.sln`, `dotnet test`). +- [x] Assembly mode reflects the DLL, then `DeployAssembly(deployer, registrar, artifact, typeNames, dryRun, verbose)` + — the internal seam that (1) deletes orphan types **before** the content update, (2) deploys, (3) registers new types. +- [x] `PluginDeployRunnerTests` (red first): asserts `Delete(plugintype)` precedes `Update(pluginassembly)`; + a new assembly deletes nothing; dry-run writes nothing. ### Task 5 — CLI surface -- [x] `CommandOptions`: **retitle** the existing `DeleteOrphanedSteps()` option so its help covers - steps and plugin types. Reuse it — no new option (see the pre-existing duplicate note above: - `DeleteOrphaned()` and `DeleteOrphanedSteps()` already share `--delete-orphaned`). -- [x] `DeployCommand`: reuse the `DeleteOrphanedSteps()` option (do not add a third definition); - pass the value into the runner. -- [x] `SyncCommand`: pass the existing flag into the runner. +- [x] None needed: reconciliation is automatic, so `deploy` gains no flag and the steps-only + `DeleteOrphanedSteps()` help is reused unchanged by `sync`/`register`. ### Task 6 — Docs -- [x] README: extend the `--delete-orphaned` rows for `deploy` and `sync`; note types are pruned - scoped to the deployed assembly, Custom-API types are never removed, and dry-run first. +- [x] README: assembly-mode types are reconciled automatically (removed before the content update); + `--delete-orphaned` stays steps-only. Reverted the earlier `deploy --delete-orphaned` row. --- @@ -174,9 +173,9 @@ existing deploy flow) → covered by runner code review + a manual dev-org check - **Custom API backing type** — skip (would break the API). Warn. - **Custom Action backing type** — skip (Task 2: `workflow.plugintypeid`, resolved via `CustomActionPluginTypeIds()`). - **Renamed class** — new type created (ensure) + old type pruned = clean rename. -- **Ordering in `sync`** — prune before `StepRegistrar.Sync` so `SdkMetadata.PluginTypeIdByName` - no longer returns the removed types; orphan-type steps are already gone, so `StepRegistrar`'s - own orphan-step pass only handles steps whose class still exists. +- **Ordering** — in an assembly deploy, orphan types are deleted **before** the content update + (Dataverse rejects the update otherwise); steps whose class still exists are handled by + `StepRegistrar` afterwards. - **Steps on the orphan type that are Custom-API-protected** — protected by type-protection, so they survive; consistent with `StepRegistrar`. - **Empty desired set / reflection failure** — reflection failures throw (fail closed, no deletes); @@ -191,13 +190,12 @@ existing deploy flow) → covered by runner code review + a manual dev-org check ## Verification - `dotnet build dvx.sln` (0 warnings) -- `dotnet test` (all green, new `PluginTypeRegistrarTests` cases included) -- Manual, against a dev org: - 1. Deploy assembly mode with classes A, B → 2 `plugintype` rows. - 2. Remove B, redeploy with `--delete-orphaned` → B's type gone; A intact. - 3. Rename A→C, redeploy with `--delete-orphaned` → C created, A gone. - 4. Add a `[CustomApi]` class, deploy, remove it, redeploy with `--delete-orphaned` → its type is - **kept** with a warning. - 5. `--dry-run` with `--delete-orphaned` → reports the deletions, writes nothing. - 6. Package mode: bump the package version and confirm Dataverse removes the stale type itself (no dvx action). +- `dotnet test` (all green: `PluginTypeRegistrarTests`, `PluginDeployRunnerTests`) +- Manual, against a dev org (assembly mode): + 1. Deploy with classes A, B → 2 `plugintype` rows. + 2. Remove B, redeploy (no flag) → B's type gone; A intact; **the content update succeeds**. + 3. Rename A→C, redeploy → C created, A gone. + 4. Add a `[CustomApi]`/Custom Action class, deploy, remove it, redeploy → its type is kept with a warning. + 5. `--dry-run` → reports would-prune, writes nothing. + 6. Package mode: bump the version → Dataverse removes the stale type itself (no dvx action). diff --git a/src/dvx.Tests/PluginDeployRunnerTests.cs b/src/dvx.Tests/PluginDeployRunnerTests.cs new file mode 100644 index 0000000..9035589 --- /dev/null +++ b/src/dvx.Tests/PluginDeployRunnerTests.cs @@ -0,0 +1,116 @@ +using dvx.Commands.Shared; +using dvx.Services; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class PluginDeployRunnerTests + { + private const string AssemblyName = "MyPlugin"; + + private static IOrganizationService SvcWithExistingAssembly(Guid assemblyId, Guid orphanTypeId) + { + var svc = Substitute.For(); + svc.RetrieveMultiple(Arg.Any()).Returns(new EntityCollection()); + + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "pluginassembly")) + .Returns(new EntityCollection(new List { new Entity("pluginassembly", assemblyId) })); + + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "plugintype")) + .Returns(new EntityCollection(new List + { + new Entity("plugintype", orphanTypeId) { ["typename"] = "NS.Old" } + })); + + return svc; + } + + private static PluginArtifact Artifact(string dllPath) => + new(dllPath, AssemblyName, "prefix_" + AssemblyName, new Version(1, 0, 0, 0)); + + private static string WriteTempDll() + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.dll"); + File.WriteAllBytes(path, new byte[] { 1, 2, 3, 4 }); + return path; + } + + [Fact] + public void DeployAssembly_ExistingAssembly_DeletesOrphanTypeBeforeUpdatingContent() + { + var assemblyId = Guid.NewGuid(); + var orphanTypeId = Guid.NewGuid(); + var svc = SvcWithExistingAssembly(assemblyId, orphanTypeId); + var dll = WriteTempDll(); + + try + { + PluginDeployRunner.DeployAssembly( + new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), + Artifact(dll), new[] { "NS.New" }, dryRun: false, verbose: false); + + // Dataverse rejects the pluginassembly update while a stale plugintype remains, so + // the orphan must be deleted first. + Received.InOrder(() => + { + svc.Delete("plugintype", orphanTypeId); + svc.Update(Arg.Is(e => + e.LogicalName == "pluginassembly" && e.Id == assemblyId)); + }); + } + finally + { + File.Delete(dll); + } + } + + [Fact] + public void DeployAssembly_NewAssembly_DoesNotDeleteAnything() + { + var svc = Substitute.For(); + svc.RetrieveMultiple(Arg.Any()).Returns(new EntityCollection()); + var dll = WriteTempDll(); + + try + { + PluginDeployRunner.DeployAssembly( + new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), + Artifact(dll), new[] { "NS.A" }, dryRun: false, verbose: false); + + svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } + finally + { + File.Delete(dll); + } + } + + [Fact] + public void DeployAssembly_DryRun_WritesNothing() + { + var assemblyId = Guid.NewGuid(); + var orphanTypeId = Guid.NewGuid(); + var svc = SvcWithExistingAssembly(assemblyId, orphanTypeId); + var dll = WriteTempDll(); + + try + { + PluginDeployRunner.DeployAssembly( + new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), + Artifact(dll), new[] { "NS.New" }, dryRun: true, verbose: false); + + svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + svc.DidNotReceive().Update(Arg.Any()); + svc.DidNotReceive().Create(Arg.Any()); + } + finally + { + File.Delete(dll); + } + } + } +} diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 2b5a4eb..1f2562b 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -23,11 +23,10 @@ public static Command Build() var pluginBuildMode = CommandOptions.PluginBuildMode(); var interactiveAuth = CommandOptions.InteractiveAuth(); var dryRun = CommandOptions.DryRun(); - var deleteOrphaned = CommandOptions.DeleteOrphanedSteps(); var verbose = CommandOptions.Verbose(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix, - solutionUniqueName, pluginBuildMode, interactiveAuth, dryRun, deleteOrphaned, verbose); + solutionUniqueName, pluginBuildMode, interactiveAuth, dryRun, verbose); cmd.SetHandler((InvocationContext ctx) => { @@ -42,7 +41,6 @@ public static Command Build() var cliMode = ctx.ParseResult.GetValueForOption(pluginBuildMode); var cliInteractive = ctx.ParseResult.GetValueForOption(interactiveAuth); var isDryRun = ctx.ParseResult.GetValueForOption(dryRun); - var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); try @@ -61,7 +59,7 @@ public static Command Build() if (prefixWarning is not null) Out.Warn(prefixWarning); PluginDeployRunner.BuildAndDeploy(svc, mode, solution, envConfig.Url, - resolvedProject, prefix, isVerbose, isDryRun, delOrphaned); + resolvedProject, prefix, isVerbose, isDryRun); } catch (Exception ex) { diff --git a/src/dvx/Commands/Shared/CommandOptions.cs b/src/dvx/Commands/Shared/CommandOptions.cs index 38fa253..bcd9b4f 100644 --- a/src/dvx/Commands/Shared/CommandOptions.cs +++ b/src/dvx/Commands/Shared/CommandOptions.cs @@ -82,9 +82,9 @@ public static class CommandOptions public static Option DeleteOrphanedSteps() => new Option( "--delete-orphaned", - "Delete plugin registrations that exist in Dataverse but are no longer present in code: " + - "steps, and (in assembly mode) plugin types. Steps and types backing Custom APIs and " + - "Custom Actions are never removed. Destructive — run with --dry-run first."); + "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", diff --git a/src/dvx/Commands/Shared/PluginDeployRunner.cs b/src/dvx/Commands/Shared/PluginDeployRunner.cs index b60d64a..aff54d8 100644 --- a/src/dvx/Commands/Shared/PluginDeployRunner.cs +++ b/src/dvx/Commands/Shared/PluginDeployRunner.cs @@ -25,8 +25,7 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( string project, string prefix, bool verbose, - bool dryRun, - bool deleteOrphaned) + bool dryRun) { Out.Step("Building", project); var build = new ProjectBuilder().BuildAllowingMissingPackage(project); @@ -44,26 +43,18 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( Out.Success("Built", Path.GetFileName(plan.ArtifactPath)); Out.Step("Deploying", $"to {url}"); - IPluginDeployer deployer = mode == PluginBuildMode.Package - ? new PackageDeployer(svc) - : new AssemblyDeployer(svc); - var assemblyId = deployer.Deploy(artifact, verbose, dryRun); - // A bare pluginassembly upload does not create the plugintype rows (unlike a - // pluginpackage, which Dataverse extracts itself), so register them here — the same - // job the Plugin Registration Tool does — before steps are reconciled. On request, - // also remove types whose class is no longer in the build. - if (mode == PluginBuildMode.Assembly) + Guid assemblyId; + if (mode == PluginBuildMode.Package) + { + assemblyId = new PackageDeployer(svc).Deploy(artifact, verbose, dryRun); + } + else { var typeNames = new PluginDiscovery(NullLogger.Instance) .DiscoverPluginTypeNames(build.DllPath); - var registrar = new PluginTypeRegistrar(svc); - - if (!dryRun) - registrar.EnsureRegistered(assemblyId, typeNames, verbose); - - if (deleteOrphaned) - registrar.DeleteOrphans(assemblyId, typeNames, dryRun, verbose); + assemblyId = DeployAssembly( + new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), artifact, typeNames, dryRun, verbose); } if (PluginDeploymentPlan.ShouldAddAssemblyToSolution(mode, solution, dryRun, assemblyId)) @@ -74,5 +65,31 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( return (assemblyId, build.DllPath); } + + /// + /// Deploys a bare assembly: reconciles its plugintype rows around the content update. + /// A type whose class is no longer in the build must be removed before the update — + /// Dataverse validates every registered type against the incoming assembly and rejects the + /// update otherwise — and any new class is registered afterwards. Mirrors spkl / the PRT. + /// + internal static Guid DeployAssembly( + AssemblyDeployer deployer, + PluginTypeRegistrar registrar, + PluginArtifact artifact, + IReadOnlyList typeNames, + bool dryRun, + bool verbose) + { + var existingId = deployer.FindExistingId(artifact.AssemblyName); + if (existingId is not null) + registrar.DeleteOrphans(existingId.Value, typeNames, dryRun, verbose); + + var assemblyId = deployer.Deploy(artifact, verbose, dryRun); + + if (!dryRun) + registrar.EnsureRegistered(assemblyId, typeNames, verbose); + + return assemblyId; + } } } diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index b1d8f8c..87a147f 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -63,7 +63,7 @@ public static Command Build(ILoggerFactory loggerFactory) // ── Build + deploy ────────────────────────────────────── var (assemblyId, dllPath) = PluginDeployRunner.BuildAndDeploy( - svc, mode, solution, envConfig.Url, resolvedProject, prefix, isVerbose, isDryRun, delOrphaned); + svc, mode, solution, envConfig.Url, resolvedProject, prefix, isVerbose, isDryRun); // ── Register ──────────────────────────────────────────── // A brand-new assembly under a dry run has no id (nothing was created), so there diff --git a/src/dvx/Services/AssemblyDeployer.cs b/src/dvx/Services/AssemblyDeployer.cs index 6697a85..e91f0cf 100644 --- a/src/dvx/Services/AssemblyDeployer.cs +++ b/src/dvx/Services/AssemblyDeployer.cs @@ -18,18 +18,25 @@ public AssemblyDeployer(IOrganizationService svc) : base(svc) { } // ── Skeleton hooks ───────────────────────────────────────────────────── protected override Guid? ResolveExistingId(PluginArtifact artifact, bool verbose) + => FindExistingId(artifact.AssemblyName); + + /// + /// Resolves the pluginassembly id by name, or null when none exists. + /// Throws if more than one record shares the name. + /// + public Guid? FindExistingId(string assemblyName) { var query = new QueryExpression("pluginassembly") { ColumnSet = new ColumnSet("pluginassemblyid"), Criteria = new FilterExpression(), }; - query.Criteria.AddCondition("name", ConditionOperator.Equal, artifact.AssemblyName); + query.Criteria.AddCondition("name", ConditionOperator.Equal, assemblyName); var result = Svc.RetrieveMultiple(query); if (result.Entities.Count > 1) throw new InvalidOperationException( - $"Multiple pluginassembly records named '{artifact.AssemblyName}' found in Dataverse. " + + $"Multiple pluginassembly records named '{assemblyName}' found in Dataverse. " + "Remove the duplicates before deploying."); return result.Entities.Count == 1 ? result.Entities[0].Id : null; From 92cbf07aa05c6223e73ec61575383e1b23e260e5 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 16:03:07 +0200 Subject: [PATCH 08/14] Log registered/pruned plugin types during deploy and sync --- src/dvx/Services/PluginTypeRegistrar.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/dvx/Services/PluginTypeRegistrar.cs b/src/dvx/Services/PluginTypeRegistrar.cs index 2f63fed..0bf8d0b 100644 --- a/src/dvx/Services/PluginTypeRegistrar.cs +++ b/src/dvx/Services/PluginTypeRegistrar.cs @@ -22,6 +22,7 @@ public void EnsureRegistered(Guid assemblyId, IReadOnlyList desiredTypeN var existing = ExistingTypes(assemblyId).Select(t => t.TypeName).ToHashSet(StringComparer.Ordinal); + var created = 0; foreach (var typeName in desiredTypeNames.Distinct(StringComparer.Ordinal)) { if (existing.Contains(typeName)) @@ -37,7 +38,11 @@ public void EnsureRegistered(Guid assemblyId, IReadOnlyList desiredTypeN ["friendlyname"] = typeName, ["pluginassemblyid"] = new EntityReference("pluginassembly", assemblyId), }); + created++; } + + if (created > 0) + Out.Success("Registered", $"{created} plugin type(s)."); } /// From 36c2b62125fdfba2cef4138c64490617a57470d3 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 16:23:51 +0200 Subject: [PATCH 09/14] Bump dvx CLI version to 1.11.0 for bare DLL deployment support --- src/dvx/dvx.csproj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index 33aa2df..9758959 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -7,7 +7,7 @@ true dvx dvx.cli - 1.10.0 + 1.11.0 Byron Matus CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources. default @@ -17,8 +17,9 @@ https://github.com/beyro/dvx-cli package-icon.png MIT - 1.10.0: -- Add column to `plugin report` output files that shows the user the plugin runs as + 1.11.0: +- New `--plugin-build-mode assembly` (and `pluginBuildMode` in config) to deploy a bare plugin assembly straight to a `pluginassembly` record — created when absent, updated otherwise — as an alternative to the NuGet package path. Package mode is unchanged and now fails loudly when no `.nupkg` is produced. +- `plugin deploy` gains `--dry-run`. From 77829373823d9d42c46332641cd3feb1f495c966 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 16:28:05 +0200 Subject: [PATCH 10/14] Remove planning docs from the repository --- .../plans/2026-07-05-bare-dll-deployment.md | 170 --------------- ...026-10-07-orphaned-plugin-type-deletion.md | 201 ------------------ 2 files changed, 371 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-05-bare-dll-deployment.md delete mode 100644 docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md diff --git a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md b/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md deleted file mode 100644 index 57ca752..0000000 --- a/docs/superpowers/plans/2026-07-05-bare-dll-deployment.md +++ /dev/null @@ -1,170 +0,0 @@ -# Bare DLL Plugin Deployment — Implementation Plan - -**Goal:** Let `dvx plugin deploy` and `dvx plugin sync` deploy a bare plugin assembly -(`pluginassembly.content`) as an alternative to the NuGet plugin-package path -(`pluginpackage.content`), without breaking the existing package workflow. - -**Tech Stack:** .NET 8, System.CommandLine, Microsoft.Xrm.Sdk, xUnit, Shouldly, NSubstitute. - -## Locked Decisions - -| # | Decision | -|---|----------| -| A1–A3 | Explicit flag `--plugin-build-mode package\|assembly` on `deploy` and `sync`; defaults to `package` | -| A4 | Config field `pluginBuildMode` (project-level) | -| A5 | CLI wins over config | -| B6 | Assembly mode still runs `dotnet build` | -| B8 | Distinct build method for assemblies; share code where it makes sense | -| C9 | Create the `pluginassembly` record when none exists | -| C10 | Resolve by `name`; throw if more than one match | -| C11 | Set only required attributes; none configurable | -| C12 | `version` from the assembly's `AssemblyVersion` | -| C13 | `isolationmode` fixed at Sandbox (2) | -| C14 | Add the assembly to the target solution | -| D15 | `IPluginDeployer` with `PackageDeployer` + `AssemblyDeployer` | -| D16 | Shared "resolve record → upload content → return assembly id" skeleton | -| D17 | Dry-run reports "would create" and skips | -| E18 | Warn when mode and produced artifacts disagree | -| E19 | Default stays `package` (backward compatible) | -| E20 | No client-side assembly validation — let Dataverse reject | -| F21–F22 | NSubstitute-based tests mirroring `PackageDeployerTests`; add `ProjectBuilder` tests | -| F23 | Update README | - -> **Resolution note (implemented):** package mode is **strict** — a missing `.nupkg` is a hard error, -> not a warning, so the deploy never silently switches to the DLL. The E18 warning therefore applies -> only to assembly mode on a project that also emitted a `.nupkg` (deploy the DLL, ignore the package). -> -> **Post-review refinements:** the duplicated command wiring was extracted into `PluginDeployRunner`; -> `ProjectBuilder.BuildAssembly` was renamed `BuildAllowingMissingPackage`; the `Version` is computed -> only in assembly mode (the package path no longer opens the DLL); the solution-add guard is the -> tested `PluginDeploymentPlan.ShouldAddAssemblyToSolution`; and the package-only `Deploy(string,string)` -> overload was removed (the string callers were test-only). -> -> **Live-org finding (fixed):** a bare `pluginassembly` upload does **not** create the child -> `plugintype` rows — unlike a `pluginpackage`, which Dataverse extracts itself — so steps could not be -> registered. Assembly mode now reflects the built DLL and creates the missing `plugintype` records -> (`PluginTypeRegistrar`), mirroring the Plugin Registration Tool / spkl. This makes C9 (create the -> assembly) usable end-to-end, so the create path is retained. - -## Architecture - -``` -DeployCommand / SyncCommand - │ resolve mode (CLI > config), build artifact, pick deployer - ▼ -IPluginDeployer.Deploy(PluginArtifact, verbose, dryRun) → Guid assemblyId - ├── PackageDeployer : pluginpackage.content → child pluginassembly id - └── AssemblyDeployer : pluginassembly.content (create or update) → id - ▼ -StepRegistrar.Sync(assemblyId, …) ← unchanged -``` - -**Shared skeleton** (`PluginDeployerBase`): `ResolveExistingId(...)` → `UploadContent(id, path, verbose)` -→ `ReturnAssemblyId(...)`. `PackageDeployer` keeps its package lookup + child-assembly lookup; -`AssemblyDeployer` overrides create/update and returns the record's own id. - -## Files - -**Create** -- `src/dvx/Models/PluginBuildMode.cs` — `enum PluginBuildMode { Package, Assembly }` -- `src/dvx/Services/IPluginDeployer.cs` — interface + `PluginArtifact` record -- `src/dvx/Services/PluginDeployerBase.cs` — shared skeleton -- `src/dvx/Services/AssemblyDeployer.cs` -- `src/dvx/Commands/Shared/PluginDeploymentPlan.cs` — mode/artifact selection + solution-membership predicate -- `src/dvx/Commands/Shared/PluginDeployRunner.cs` — shared build-and-deploy step for deploy/sync -- `src/dvx/Services/PluginTypeRegistrar.cs` — registers `plugintype` rows for a deployed assembly -- `src/dvx.Tests/AssemblyDeployerTests.cs` -- `src/dvx.Tests/ProjectBuilderTests.cs` -- `src/dvx.Tests/PluginDeploymentPlanTests.cs` -- `src/dvx.Tests/PluginTypeRegistrarTests.cs` - -**Modify** -- `src/dvx/Services/PackageDeployer.cs` — implement `IPluginDeployer`, derive from base -- `src/dvx/Services/ProjectBuilder.cs` — add assembly build path (nullable nupkg / distinct method) -- `src/dvx/Models/AppConfig.cs` — add `PluginBuildMode? PluginBuildMode` -- `src/dvx/Config/ConfigLoader.cs` — add `ResolvePluginBuildMode(config, cliOverride)` -- `src/dvx/Commands/Shared/CommandOptions.cs` — add `PluginBuildMode()` option (`--plugin-build-mode`) -- `src/dvx/Commands/DeployCommand.cs` — wire mode + deployer selection -- `src/dvx/Commands/SyncCommand.cs` — same -- `src/dvx/Services/SolutionService.cs` — add `AddAssemblyToSolution` (component type 91) -- `README.md` - -## Interfaces - -```csharp -public enum PluginBuildMode { Package, Assembly } - -public sealed record PluginArtifact( - string Path, // .nupkg (Package) or .dll (Assembly) - string AssemblyName, // bare assembly name; record name for Assembly mode, stem for Package unique name - string UniqueName, // {prefix}_{assemblyName} — Package lookup key - Version? Version); // from the built DLL's AssemblyVersion (Assembly mode; null for Package) - -public interface IPluginDeployer -{ - Guid Deploy(PluginArtifact artifact, bool verbose = false, bool dryRun = false); -} -``` - -`AssemblyDeployer` sets required `pluginassembly` attributes: -`name = artifact.AssemblyName` (bare), `sourcetype = 0` (Database), `isolationmode = 2` (Sandbox), -`version = artifact.Version.ToString()`, `culture = "neutral"`, `content = base64(dll)`. -`version` via `AssemblyName.GetAssemblyName(dllPath).Version` (metadata-only; no dependency load). - -## Task Breakdown - -All tasks follow **TDD**: write the failing test(s) first, run them to confirm they fail, then -implement until green. Task 3 is a pure refactor, so its "red" step is the existing -`PackageDeployerTests` passing before and after. - -### Task 1 — `PluginBuildMode` enum + config plumbing -- [x] **Red:** Write `ConfigLoaderTests` for `ResolvePluginBuildMode` (CLI wins, config fallback, - default Package) and run `dotnet test --filter ConfigLoaderTests` — confirm failure. -- [x] **Green:** Add enum, `AppConfig.PluginBuildMode`, `ConfigLoader.ResolvePluginBuildMode`, - `CommandOptions.PluginBuildMode()`. -- [x] Run `dotnet test --filter ConfigLoaderTests` — confirm pass. - -### Task 2 — `ProjectBuilder` assembly build path -- [x] **Red:** Write `ProjectBuilderTests`: DLL-only project returns artifact without requiring - `.nupkg`; package project still returns both; missing DLL still throws. Run - `dotnet test --filter ProjectBuilderTests` — confirm failure. -- [x] **Green:** Add a build method that tolerates a missing `.nupkg` (share `RunDotnet`/`FindBuiltDll`). -- [x] Run `dotnet test --filter ProjectBuilderTests` — confirm pass. - -### Task 3 — `IPluginDeployer` + shared base (refactor) -- [x] **Baseline:** Run `dotnet test --filter PackageDeployerTests` — confirm existing tests pass. -- [x] Define interface + `PluginArtifact`. -- [x] Extract shared skeleton; refactor `PackageDeployer` to implement it. -- [x] Re-run `dotnet test --filter PackageDeployerTests` — confirm still pass (no behaviour change). - -### Task 4 — `AssemblyDeployer` -- [x] **Red:** Write `AssemblyDeployerTests` (NSubstitute `IOrganizationService`): - create when absent; update when present; multiple matches throws; dry-run create reports - "would create" and issues no `Create`/`Update`; returns record id. Run - `dotnet test --filter AssemblyDeployerTests` — confirm failure. -- [x] **Green:** Implement resolve-by-name (throw on >1), create/update `content`, return id. -- [x] Run `dotnet test --filter AssemblyDeployerTests` — confirm pass. - -### Task 5 — Solution membership -- [x] **Red:** Add a test asserting the `AddSolutionComponent` request shape for - `AddAssemblyToSolution` (component type 91) — confirm failure. -- [x] **Green:** Add `SolutionService.AddAssemblyToSolution` (component type 91). -- [x] Run the solution-service tests — confirm pass. - -### Task 6 — Command wiring -- [x] **Red:** Add command-level tests for mode selection (via the extracted `PluginDeploymentPlan` - seam) — confirm failure. -- [x] **Green:** `DeployCommand` / `SyncCommand`: resolve mode, build accordingly, select deployer, - warn on artifact/mode mismatch (E18), add assembly to solution when `--solution-unique-name` set. -- [x] Run the command tests — confirm pass (deployer selection + solution-membership predicate are - covered by `PluginDeploymentPlanTests`). - -### Task 7 — Docs -- [x] README: document `--plugin-build-mode`, `pluginBuildMode` config field, assembly-mode - create/update semantics, and solution-membership behaviour. - -## Verification -- `dotnet build dvx.sln` -- `dotnet test` -- Manual: `dvx plugin deploy --project ./MyPlugin.csproj --plugin-build-mode assembly --dry-run` - then without `--dry-run` against a dev org; re-run to confirm update path. diff --git a/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md b/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md deleted file mode 100644 index c2833d8..0000000 --- a/docs/superpowers/plans/2026-10-07-orphaned-plugin-type-deletion.md +++ /dev/null @@ -1,201 +0,0 @@ -# Orphaned Plugin Type Deletion — Implementation Plan - -**Goal:** When a deployed plugin assembly no longer contains a class that Dataverse still has a -`plugintype` record for, remove that orphaned `plugintype` (and the steps hanging off it) as part of -the **assembly** deploy — **automatically**, not gated by a flag, because Dataverse rejects the -`pluginassembly` content update while a stale type remains. `--delete-orphaned` continues to govern -orphan **steps**. - -**Out of scope — package mode:** Dataverse already prunes types when a `pluginpackage` is updated -(version bump), so dvx does not own package-mode type cleanup. - -> **Resolution update (post-review):** the original design gated this behind `--delete-orphaned` -> and pruned *after* the content update. Manual testing showed the update itself is rejected by -> Dataverse (`PluginType [...] not found in PluginAssembly [...]`), so reconciliation is -> **unconditional** and runs **before** the update — the order spkl uses -> (`UnregisterRemovedPluginTypes` → `Update`). - -**Tech Stack:** .NET 9, System.CommandLine, Microsoft.Xrm.Sdk, xUnit, Shouldly, NSubstitute. - ---- - -## Background: why orphans exist - -A `plugintype` row is the registration of one plugin class on a `pluginassembly`. It is a *child* -of the assembly and is what steps bind to (`sdkmessageprocessingstep.plugintypeid`). - -Neither deployment path reconciles types when a class is **removed or renamed**: - -| Mode | Who creates `plugintype` | Who removes a stale one | -|---|---|---| -| `assembly` | dvx itself (`PluginTypeRegistrar`, added in `3509402`) | **nobody** — dvx must | -| `package` | Dataverse, extracting from the `.nupkg` | Dataverse (on package update) — no dvx action | - -So only **assembly** mode needs type pruning. Renaming `Foo` → `Bar` and re-deploying leaves the -old `Foo` type (and possibly its steps) in Dataverse forever. `--delete-orphaned` currently only -prunes orphaned **steps** (`StepRegistrar.cs:158-183`) and orphaned **web resources** -(`WebResourceSyncer.cs:176-196`), not types. - -Terminology: an **orphaned type** = a `plugintype` whose `pluginassemblyid` is the assembly we just -deployed, but whose `typename` is absent from the built DLL. - ---- - -## Locked Decisions - -| # | Decision | -|---|----------| -| T1 | Type reconciliation is **unconditional** in assembly mode (spkl-style) and runs **before** the content update. `--delete-orphaned` still governs orphan **steps** only. No new flag. | -| T2 | Pruning is **scoped to the one deployed `pluginassembly` id** — never touch types on any other assembly. | -| T3 | The **desired type set** is the reflection of the built DLL (`PluginDiscovery.DiscoverPluginTypeNames`). | -| T4 | Never delete a type referenced by a **Custom API** (or Custom Action) — mirror the step protection. | -| T5 | Delete an orphan type's **steps first, then the type** (images cascade with the step). | -| T6 | **Skip pruning and warn** when the desired set is empty (guards against a reflection/load failure nuking every type). | -| T7 | Order within an assembly deploy: (1) delete orphan types (existing ∖ desired, minus Custom-API/Action types) → (2) update the content → (3) register new types. An orphan type's steps are deleted with it; `StepRegistrar` then handles steps whose class still exists. | -| T8 | In `--dry-run`, report "would delete" and perform no writes. | -| T9 | `register` stays steps-only (it does not deploy content). | -| T10 | Reconciliation is automatic; no flag can disable it (the update cannot succeed otherwise). | -| T11 | Types are **never** added to a solution (only the assembly and steps are), so pruning leaves no dangling `solutioncomponent`; `AddAssemblyToSolution` keeps `AddRequiredComponents = false`. | - -**Alternative considered:** reusing `--delete-orphaned` to gate type deletion (the original design). -Rejected after manual testing — the `pluginassembly` content update is rejected by Dataverse while a -stale type remains, so reconciliation must be unconditional and happen before the update. - ---- - -## Architecture - -``` -PluginDeployRunner.BuildAndDeploy(svc, mode, solution, …) - │ build → PluginDeploymentPlan.Resolve → deploy → assemblyId - │ - └── mode == Assembly → DeployAssembly(...) (seam; unit-tested in PluginDeployRunnerTests) - desired = PluginDiscovery.DiscoverPluginTypeNames(build.DllPath) - 1. existingId = AssemblyDeployer.FindExistingId(name) - 2. if existingId: PluginTypeRegistrar.DeleteOrphans(existingId, desired, dryRun) ← BEFORE update - 3. AssemblyDeployer.Deploy(artifact) (content update) - 4. if !dryRun: PluginTypeRegistrar.EnsureRegistered(assemblyId, desired) - ▼ -SyncCommand: StepRegistrar.Sync(assemblyId, definitions, …, deleteOrphaned) ← steps only, unchanged -``` - -`DeleteOrphans(assemblyId, desired)`: -1. `existing = SdkMetadata.PluginTypeIdByName(assemblyId)` → `{typename → id}` -2. `protectedTypes = CustomApiPluginTypeIds() ∪ CustomActionPluginTypeIds()` -3. `orphans = existing where !desired.Contains(typename) && !protectedTypes.Contains(id)` -4. for each orphan: delete its `sdkmessageprocessingstep` rows, then `svc.Delete("plugintype", id)` - ---- - -## Files - -**Create** -- `src/dvx.Tests/PluginTypeRegistrarTests.cs` — already exists; add the prune cases here. - -**Modify** -- `src/dvx/Services/PluginTypeRegistrar.cs` — add `DeleteOrphans(...)` (used by the assembly deploy). -- `src/dvx/Services/SdkMetadata.cs` — add `plugintypeid` to the `CustomActions` `ColumnSet` - (`SdkMetadata.cs:93` selects only `workflowid, uniquename` today) and add - `CustomActionPluginTypeIds()` beside `CustomActionMessageIds()`; reuse `CustomApiPluginTypeIds()`. -- `src/dvx/Services/AssemblyDeployer.cs` — expose `FindExistingId(name)` (refactored from `ResolveExistingId`). -- `src/dvx/Commands/Shared/PluginDeployRunner.cs` — assembly mode reflects the DLL, then calls - `DeployAssembly(...)` (the internal, unit-tested seam): delete orphan types **before** the content - update, deploy, register new types after. Package mode is untouched; `deploy` gains no flag. -- `src/dvx/Commands/SyncCommand.cs` — unchanged runner call; its `--delete-orphaned` still governs - orphan steps via `StepRegistrar`. -- `README.md` — describe automatic assembly-mode type reconciliation; keep `--delete-orphaned` steps-only. - ---- - -## Interfaces - -```csharp -public class PluginTypeRegistrar(IOrganizationService svc) -{ - // existing (assembly-mode create) - public void EnsureRegistered(Guid assemblyId, IReadOnlyList desiredTypeNames, bool verbose = false); - - // new - /// Deletes plugintypes on that are absent from the desired - /// set (and not backing a Custom API/Action), deleting each orphan's steps first. No writes on dry-run. - public void DeleteOrphans(Guid assemblyId, IReadOnlyList desiredTypeNames, - bool dryRun = false, bool verbose = false); -} -``` - ---- - -## Task Breakdown - -Every task is TDD: failing test → implement → green. Command wiring has no unit seam (as with the -existing deploy flow) → covered by runner code review + a manual dev-org check. - -### Task 1 — `PluginTypeRegistrar.DeleteOrphans` -- [x] **Red:** `PluginTypeRegistrarTests` (NSubstitute `IOrganizationService`): - deletes a type not in the desired set; keeps one that is; **never** deletes a Custom-API type; - query is filtered by `pluginassemblyid`; deletes the orphan's `sdkmessageprocessingstep` rows - before the `plugintype`; no `Delete` on dry-run; empty desired set → no deletes + no throw. - Run `dotnet test --filter PluginTypeRegistrarTests` — confirm failure. -- [x] **Green:** implement `DeleteOrphans` (query existing + protected ids, resolve orphans, - delete steps then type). -- [x] Run `dotnet test --filter PluginTypeRegistrarTests` — confirm pass. - -### Task 2 — Custom-Action type protection -- [x] **Red:** add `plugintypeid` to the `CustomActions` `ColumnSet` (`SdkMetadata.cs:93` fetches only - `workflowid, uniquename` today), add `CustomActionPluginTypeIds()`, and extend `PluginTypeRegistrarTests` - with a Custom-Action-backed type that must **not** be deleted. Confirm failure. -- [x] **Green:** fold `CustomActionPluginTypeIds()` into the protected set used by `DeleteOrphans`. -- [x] Confirm pass. (The `workflow` entity does carry `plugintypeid` for Action definitions — this is - real work, not a conditional.) - -### Task 3 — Empty-desired guard (safety rail) -- [x] **Red:** test: `DeleteOrphans(assemblyId, emptyDesired)` with existing types → no deletes. -- [x] **Green:** return early (+ `Out.Warn`) when `desiredTypeNames.Count == 0`. - -### Task 4 — Wire into `PluginDeployRunner` -- [x] Assembly mode reflects the DLL, then `DeployAssembly(deployer, registrar, artifact, typeNames, dryRun, verbose)` - — the internal seam that (1) deletes orphan types **before** the content update, (2) deploys, (3) registers new types. -- [x] `PluginDeployRunnerTests` (red first): asserts `Delete(plugintype)` precedes `Update(pluginassembly)`; - a new assembly deletes nothing; dry-run writes nothing. - -### Task 5 — CLI surface -- [x] None needed: reconciliation is automatic, so `deploy` gains no flag and the steps-only - `DeleteOrphanedSteps()` help is reused unchanged by `sync`/`register`. - -### Task 6 — Docs -- [x] README: assembly-mode types are reconciled automatically (removed before the content update); - `--delete-orphaned` stays steps-only. Reverted the earlier `deploy --delete-orphaned` row. - ---- - -## Edge cases & protections (must all hold) - -- **Custom API backing type** — skip (would break the API). Warn. -- **Custom Action backing type** — skip (Task 2: `workflow.plugintypeid`, resolved via `CustomActionPluginTypeIds()`). -- **Renamed class** — new type created (ensure) + old type pruned = clean rename. -- **Ordering** — in an assembly deploy, orphan types are deleted **before** the content update - (Dataverse rejects the update otherwise); steps whose class still exists are handled by - `StepRegistrar` afterwards. -- **Steps on the orphan type that are Custom-API-protected** — protected by type-protection, so - they survive; consistent with `StepRegistrar`. -- **Empty desired set / reflection failure** — reflection failures throw (fail closed, no deletes); - empty desired set skips with a warning (T6). -- **Prune scope** — only the deployed assembly id is touched; other assemblies' types are never candidates (T2). -- **Types & solutions** — types are never added to a solution (only the assembly and steps are, T11), - so pruning cannot leave a dangling `solutioncomponent`. -- **Dry-run** — no writes; "would delete N orphaned type(s)". - ---- - -## Verification - -- `dotnet build dvx.sln` (0 warnings) -- `dotnet test` (all green: `PluginTypeRegistrarTests`, `PluginDeployRunnerTests`) -- Manual, against a dev org (assembly mode): - 1. Deploy with classes A, B → 2 `plugintype` rows. - 2. Remove B, redeploy (no flag) → B's type gone; A intact; **the content update succeeds**. - 3. Rename A→C, redeploy → C created, A gone. - 4. Add a `[CustomApi]`/Custom Action class, deploy, remove it, redeploy → its type is kept with a warning. - 5. `--dry-run` → reports would-prune, writes nothing. - 6. Package mode: bump the version → Dataverse removes the stale type itself (no dvx action). - From 52070fbfc57306f9011d81fdb0886c4f7a6ddb9b Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 16:32:45 +0200 Subject: [PATCH 11/14] Clean up AI slop in README --- README.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7136461..81ba1a0 100644 --- a/README.md +++ b/README.md @@ -367,18 +367,10 @@ var postImage = context.PostEntityImages["PostImage"]; // UsePostImage = true | Mode | Artifact | Target record | Semantics | |---|---|---|---| -| `package` (default) | `.nupkg` | `pluginpackage` | Updates an existing package's `content`. The initial upload must be done once manually. Backward compatible. | +| `package` (default) | `.nupkg` | `pluginpackage` | Updates an existing package's `content`. The initial upload must be done once manually. | | `assembly` | `.dll` | `pluginassembly` | Creates the assembly record when no assembly with the same `name` exists, otherwise updates its `content`; then registers each plugin class as a `plugintype`. `version` comes from the built DLL, `isolationmode` is fixed at Sandbox. | -**Assembly mode registers the plugin types too.** A bare `pluginassembly` upload does **not** make -Dataverse create the `plugintype` rows (unlike a `pluginpackage`, which it extracts itself), so in -assembly mode dvx creates a `plugintype` for every plugin class in the built `.dll` — the same job -the Plugin Registration Tool performs. In assembly mode dvx also reconciles these rows on every -deploy — a type whose class is no longer in the build is removed (with its steps) **before** the -content update, because Dataverse rejects the update while a stale type remains (types backing a -Custom API/Action are kept). - -Both modes still run `dotnet build` (Release). CLI `--plugin-build-mode` wins over `pluginBuildMode` in config. +Both modes run `dotnet build` (Release). CLI `--plugin-build-mode` wins over `pluginBuildMode` in config. **Package mode is strict:** if `package` mode is selected but the build emits no `.nupkg`, the command **fails** rather than falling back to the DLL — pass `--plugin-build-mode assembly` to @@ -414,7 +406,8 @@ dvx plugin sync --project [options] **What it does:** 1. Runs `dotnet build` on the `.csproj` to produce a `.nupkg` and `.dll` -2. In **package** mode: looks up the existing `pluginpackage` by `uniquename` (`{prefix}_{assemblyName}`), updates its `content`, and reads the child `pluginassembly` ID. In **assembly** mode: creates (or updates) the `pluginassembly` by `name` from the built `.dll`, uses its own ID, and registers any missing `plugintype` records for the assembly's plugin classes +2. In **package** mode: looks up the existing `pluginpackage` by `uniquename` (`{prefix}_{assemblyName}`), updates its `content`, and reads the child `pluginassembly` ID. + - In **assembly** mode: creates (or updates) the `pluginassembly` by `name` from the built `.dll`, uses its own ID, and registers any missing `plugintype` records for the assembly's plugin classes 3. Reflects the `.dll` for `[PluginStep]` attributes 4. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps (removed only when `--delete-orphaned` is passed) 5. Syncs `sdkmessageprocessingstepimage` records (pre/post images) for each step From 0ffbc69a823908aa64ebc11ff5b9dee1525941e5 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 8 Oct 2026 20:36:10 +0200 Subject: [PATCH 12/14] Fix sonarqube scan results and include coverage in build pipeline --- .github/workflows/dotnet.yml | 3 +- .gitignore | 1 + src/dvx.Tests/PluginDeployRunnerTests.cs | 9 ++++-- src/dvx.Tests/PluginDiscoveryTests.cs | 2 +- src/dvx.Tests/PluginTypeRegistrarTests.cs | 28 +++++++++++-------- src/dvx.Tests/ProjectBuilderTests.cs | 2 ++ src/dvx/Commands/DeployCommand.cs | 2 +- src/dvx/Commands/Shared/PluginDeployRunner.cs | 14 ++++++---- src/dvx/Commands/SyncCommand.cs | 3 +- src/dvx/Services/PluginDiscovery.cs | 2 +- src/dvx/Services/PluginTypeRegistrar.cs | 2 +- src/dvx/Services/SdkMetadata.cs | 11 ++++---- 12 files changed, 49 insertions(+), 30 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 253e884..9600fa9 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -59,6 +59,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"beyro_dvx-cli" /o:"beyro" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"beyro_dvx-cli" /o:"beyro" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.opencover.reportsPaths="TestResults/**/coverage.opencover.xml" /d:sonar.coverage.exclusions="src/dvx/Commands/*Command.cs" dotnet build + dotnet test src/dvx.Tests/dvx.Tests.csproj --no-build --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory TestResults ${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/.gitignore b/.gitignore index 42f87af..9e8402f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ Thumbs.db *.stackdump # Test results +TestResults/ test-results/ # NuGet configuration diff --git a/src/dvx.Tests/PluginDeployRunnerTests.cs b/src/dvx.Tests/PluginDeployRunnerTests.cs index 9035589..df136e1 100644 --- a/src/dvx.Tests/PluginDeployRunnerTests.cs +++ b/src/dvx.Tests/PluginDeployRunnerTests.cs @@ -12,6 +12,9 @@ public class PluginDeployRunnerTests { private const string AssemblyName = "MyPlugin"; + private static readonly string[] NewTypeNames = new[] { "NS.New" }; + private static readonly string[] ATypeNames = new[] { "NS.A" }; + private static IOrganizationService SvcWithExistingAssembly(Guid assemblyId, Guid orphanTypeId) { var svc = Substitute.For(); @@ -51,7 +54,7 @@ public void DeployAssembly_ExistingAssembly_DeletesOrphanTypeBeforeUpdatingConte { PluginDeployRunner.DeployAssembly( new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), - Artifact(dll), new[] { "NS.New" }, dryRun: false, verbose: false); + Artifact(dll), NewTypeNames, dryRun: false, verbose: false); // Dataverse rejects the pluginassembly update while a stale plugintype remains, so // the orphan must be deleted first. @@ -79,7 +82,7 @@ public void DeployAssembly_NewAssembly_DoesNotDeleteAnything() { PluginDeployRunner.DeployAssembly( new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), - Artifact(dll), new[] { "NS.A" }, dryRun: false, verbose: false); + Artifact(dll), ATypeNames, dryRun: false, verbose: false); svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); } @@ -101,7 +104,7 @@ public void DeployAssembly_DryRun_WritesNothing() { PluginDeployRunner.DeployAssembly( new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), - Artifact(dll), new[] { "NS.New" }, dryRun: true, verbose: false); + Artifact(dll), NewTypeNames, dryRun: true, verbose: false); svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); svc.DidNotReceive().Update(Arg.Any()); diff --git a/src/dvx.Tests/PluginDiscoveryTests.cs b/src/dvx.Tests/PluginDiscoveryTests.cs index ee50436..a7dd0a8 100644 --- a/src/dvx.Tests/PluginDiscoveryTests.cs +++ b/src/dvx.Tests/PluginDiscoveryTests.cs @@ -174,7 +174,7 @@ private static List Discover() => new PluginDiscovery(NullLogger.Instance).Discover(TestAssemblyPath); private static IReadOnlyList DiscoverTypeNames() => - new PluginDiscovery(NullLogger.Instance).DiscoverPluginTypeNames(TestAssemblyPath); + PluginDiscovery.DiscoverPluginTypeNames(TestAssemblyPath); // ── Plugin type names (for plugintype registration) ───────────────────── diff --git a/src/dvx.Tests/PluginTypeRegistrarTests.cs b/src/dvx.Tests/PluginTypeRegistrarTests.cs index f1c370a..2d0d70e 100644 --- a/src/dvx.Tests/PluginTypeRegistrarTests.cs +++ b/src/dvx.Tests/PluginTypeRegistrarTests.cs @@ -9,6 +9,12 @@ namespace dvx.Tests { public class PluginTypeRegistrarTests { + private static readonly string[] AbTypeNames = new[] { "NS.A", "NS.B" }; + private static readonly string[] DupATypeNames = new[] { "NS.A", "NS.A" }; + private static readonly string[] ATypeNames = new[] { "NS.A" }; + private static readonly string[] NewTypeNames = new[] { "NS.New" }; + private static readonly string[] KeepTypeNames = new[] { "NS.Keep" }; + // ── Helpers ──────────────────────────────────────────────────────────── // A service whose every query returns empty unless a test configures a specific entity. @@ -55,7 +61,7 @@ public void EnsureRegistered_CreatesMissingTypes() var svc = Svc(); var assemblyId = Guid.NewGuid(); - new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, new[] { "NS.A", "NS.B" }); + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, AbTypeNames); svc.Received(1).Create(Arg.Is(e => e.LogicalName == "plugintype" && @@ -74,7 +80,7 @@ public void EnsureRegistered_SkipsExistingTypes() var assemblyId = Guid.NewGuid(); HasTypes(svc, Type("NS.A", Guid.NewGuid())); - new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, new[] { "NS.A", "NS.B" }); + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, AbTypeNames); svc.Received(1).Create(Arg.Is(e => (string)e["typename"] == "NS.B")); svc.DidNotReceive().Create(Arg.Is(e => (string)e["typename"] == "NS.A")); @@ -85,7 +91,7 @@ public void EnsureRegistered_DeduplicatesTypeNames() { var svc = Svc(); - new PluginTypeRegistrar(svc).EnsureRegistered(Guid.NewGuid(), new[] { "NS.A", "NS.A" }); + new PluginTypeRegistrar(svc).EnsureRegistered(Guid.NewGuid(), DupATypeNames); svc.Received(1).Create(Arg.Any()); } @@ -106,7 +112,7 @@ public void EnsureRegistered_QueriesTypesForTheGivenAssembly() var svc = Svc(); var assemblyId = Guid.NewGuid(); - new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, new[] { "NS.A" }); + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, ATypeNames); svc.Received(1).RetrieveMultiple(Arg.Is(q => q.EntityName == "plugintype" && @@ -123,7 +129,7 @@ public void DeleteOrphans_DeletesTypeNotInDesiredSet() var orphanId = Guid.NewGuid(); HasTypes(svc, Type("NS.Old", orphanId)); - new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), NewTypeNames); svc.Received(1).Delete("plugintype", orphanId); } @@ -134,7 +140,7 @@ public void DeleteOrphans_KeepsTypesInDesiredSet() var svc = Svc(); HasTypes(svc, Type("NS.Keep", Guid.NewGuid())); - new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.Keep" }); + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), KeepTypeNames); svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); } @@ -148,7 +154,7 @@ public void DeleteOrphans_DeletesOrphanStepsBeforeTheType() HasTypes(svc, Type("NS.Old", orphanId)); HasSteps(svc, stepId); - new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), NewTypeNames); Received.InOrder(() => { @@ -165,7 +171,7 @@ public void DeleteOrphans_NeverDeletesCustomApiBackedType() HasTypes(svc, Type("NS.Api", typeId)); HasCustomApi(svc, typeId); - new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), NewTypeNames); svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); } @@ -178,7 +184,7 @@ public void DeleteOrphans_NeverDeletesCustomActionBackedType() HasTypes(svc, Type("NS.Action", typeId)); HasCustomAction(svc, typeId); - new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }); + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), NewTypeNames); svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); } @@ -200,7 +206,7 @@ public void DeleteOrphans_DryRun_DeletesNothing() var svc = Svc(); HasTypes(svc, Type("NS.Old", Guid.NewGuid())); - new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), new[] { "NS.New" }, dryRun: true); + new PluginTypeRegistrar(svc).DeleteOrphans(Guid.NewGuid(), NewTypeNames, dryRun: true); svc.DidNotReceive().Delete(Arg.Any(), Arg.Any()); } @@ -212,7 +218,7 @@ public void DeleteOrphans_QueriesTypesForTheGivenAssembly() var assemblyId = Guid.NewGuid(); HasTypes(svc, Type("NS.Old", Guid.NewGuid())); - new PluginTypeRegistrar(svc).DeleteOrphans(assemblyId, new[] { "NS.New" }); + new PluginTypeRegistrar(svc).DeleteOrphans(assemblyId, NewTypeNames); svc.Received(1).RetrieveMultiple(Arg.Is(q => q.EntityName == "plugintype" && diff --git a/src/dvx.Tests/ProjectBuilderTests.cs b/src/dvx.Tests/ProjectBuilderTests.cs index e1e7ea9..191e740 100644 --- a/src/dvx.Tests/ProjectBuilderTests.cs +++ b/src/dvx.Tests/ProjectBuilderTests.cs @@ -20,6 +20,8 @@ public void Dispose() { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); + + GC.SuppressFinalize(this); } private string ReleaseDir => Path.Combine(_tempDir, "bin", "Release"); diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 1f2562b..f8a9bd7 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -59,7 +59,7 @@ public static Command Build() if (prefixWarning is not null) Out.Warn(prefixWarning); PluginDeployRunner.BuildAndDeploy(svc, mode, solution, envConfig.Url, - resolvedProject, prefix, isVerbose, isDryRun); + resolvedProject, prefix, new PluginDeployOptions(isVerbose, isDryRun)); } catch (Exception ex) { diff --git a/src/dvx/Commands/Shared/PluginDeployRunner.cs b/src/dvx/Commands/Shared/PluginDeployRunner.cs index aff54d8..023e3ff 100644 --- a/src/dvx/Commands/Shared/PluginDeployRunner.cs +++ b/src/dvx/Commands/Shared/PluginDeployRunner.cs @@ -2,11 +2,15 @@ using dvx.Models; using dvx.Output; using dvx.Services; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Xrm.Sdk; namespace dvx.Commands.Shared { + /// + /// Execution flags for a plugin build-and-deploy run. + /// + public readonly record struct PluginDeployOptions(bool Verbose, bool DryRun); + /// /// Shared build-and-deploy step for plugin deploy and plugin sync: build the /// project, resolve the artifact for the mode, pick the deployer, deploy it, and add the @@ -24,9 +28,10 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( string url, string project, string prefix, - bool verbose, - bool dryRun) + PluginDeployOptions options) { + var (verbose, dryRun) = options; + Out.Step("Building", project); var build = new ProjectBuilder().BuildAllowingMissingPackage(project); var plan = PluginDeploymentPlan.Resolve(mode, build); @@ -51,8 +56,7 @@ public static (Guid AssemblyId, string DllPath) BuildAndDeploy( } else { - var typeNames = new PluginDiscovery(NullLogger.Instance) - .DiscoverPluginTypeNames(build.DllPath); + var typeNames = PluginDiscovery.DiscoverPluginTypeNames(build.DllPath); assemblyId = DeployAssembly( new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), artifact, typeNames, dryRun, verbose); } diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index 87a147f..063b38c 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -63,7 +63,8 @@ public static Command Build(ILoggerFactory loggerFactory) // ── Build + deploy ────────────────────────────────────── var (assemblyId, dllPath) = PluginDeployRunner.BuildAndDeploy( - svc, mode, solution, envConfig.Url, resolvedProject, prefix, isVerbose, isDryRun); + svc, mode, solution, envConfig.Url, resolvedProject, prefix, + new PluginDeployOptions(isVerbose, isDryRun)); // ── Register ──────────────────────────────────────────── // A brand-new assembly under a dry run has no id (nothing was created), so there diff --git a/src/dvx/Services/PluginDiscovery.cs b/src/dvx/Services/PluginDiscovery.cs index 4f1b0b7..0a5eacd 100644 --- a/src/dvx/Services/PluginDiscovery.cs +++ b/src/dvx/Services/PluginDiscovery.cs @@ -25,7 +25,7 @@ public List Discover(string dllPath, bool verbose = false) /// , this includes types without [PluginStep] and Custom API /// types — mirroring what the Plugin Registration Tool registers in plugintype. /// - public IReadOnlyList DiscoverPluginTypeNames(string dllPath) + public static IReadOnlyList DiscoverPluginTypeNames(string dllPath) { var names = new List(); ForEachPluginType(dllPath, type => diff --git a/src/dvx/Services/PluginTypeRegistrar.cs b/src/dvx/Services/PluginTypeRegistrar.cs index 0bf8d0b..f3dbcc3 100644 --- a/src/dvx/Services/PluginTypeRegistrar.cs +++ b/src/dvx/Services/PluginTypeRegistrar.cs @@ -106,7 +106,7 @@ public void DeleteOrphans(Guid assemblyId, IReadOnlyList desiredTypeName // ── Helpers ──────────────────────────────────────────────────────────── - private record ExistingType(Guid Id, string TypeName); + private sealed record ExistingType(Guid Id, string TypeName); private List ExistingTypes(Guid assemblyId) { diff --git a/src/dvx/Services/SdkMetadata.cs b/src/dvx/Services/SdkMetadata.cs index 94b6cbf..4c4a264 100644 --- a/src/dvx/Services/SdkMetadata.cs +++ b/src/dvx/Services/SdkMetadata.cs @@ -31,6 +31,7 @@ internal class SdkMetadata(IOrganizationService svc) private List? _customApis; private List? _customActions; private Guid? _systemUserId; + private const string PluginTypeIdAttribute = "plugintypeid"; public Guid SystemUserId() { @@ -80,7 +81,7 @@ public Guid SystemUserId() private IReadOnlyList CustomApis => _customApis ??= svc.RetrieveMultiple(new QueryExpression("customapi") { - ColumnSet = new ColumnSet("customapiid", "uniquename", "plugintypeid", "sdkmessageid") + ColumnSet = new ColumnSet("customapiid", "uniquename", PluginTypeIdAttribute, "sdkmessageid") }).Entities.ToList(); /// @@ -93,7 +94,7 @@ public Guid SystemUserId() private IReadOnlyList CustomActions => _customActions ??= svc.RetrieveMultiple(new QueryExpression("workflow") { - ColumnSet = new ColumnSet("workflowid", "uniquename", "plugintypeid"), + ColumnSet = new ColumnSet("workflowid", "uniquename", PluginTypeIdAttribute), Criteria = new FilterExpression { Conditions = @@ -214,7 +215,7 @@ public HashSet CustomApiPluginTypeIds() var set = new HashSet(); foreach (var e in CustomApis) { - var typeRef = e.GetAttributeValue("plugintypeid"); + var typeRef = e.GetAttributeValue(PluginTypeIdAttribute); if (typeRef is not null) set.Add(typeRef.Id); } return set; @@ -262,7 +263,7 @@ public HashSet CustomActionPluginTypeIds() var set = new HashSet(); foreach (var e in CustomActions) { - var typeRef = e.GetAttributeValue("plugintypeid"); + var typeRef = e.GetAttributeValue(PluginTypeIdAttribute); if (typeRef is not null) set.Add(typeRef.Id); } return set; @@ -293,7 +294,7 @@ private List PluginTypes(Guid assemblyId) { var query = new QueryExpression("plugintype") { - ColumnSet = new ColumnSet("plugintypeid", "typename"), + ColumnSet = new ColumnSet(PluginTypeIdAttribute, "typename"), Criteria = new FilterExpression() }; query.Criteria.AddCondition("pluginassemblyid", ConditionOperator.Equal, assemblyId); From 1d2adcb901b0d78063425de31a2388ca0ffed220 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Fri, 9 Oct 2026 08:38:41 +0200 Subject: [PATCH 13/14] Commit coverlet runsettings so CI can emit the OpenCover coverage report --- coverlet.runsettings | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 coverlet.runsettings diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..42389df --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,12 @@ + + + + + + + opencover + + + + + From 27730118527a6a88918841f39c98cf9fb4e59fc4 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Fri, 9 Oct 2026 08:43:51 +0200 Subject: [PATCH 14/14] Fail the Sonar analyze step when build or tests fail --- .github/workflows/dotnet.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9600fa9..c6ca6a2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -59,7 +59,16 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | + $ErrorActionPreference = 'Stop' + ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"beyro_dvx-cli" /o:"beyro" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.opencover.reportsPaths="TestResults/**/coverage.opencover.xml" /d:sonar.coverage.exclusions="src/dvx/Commands/*Command.cs" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test src/dvx.Tests/dvx.Tests.csproj --no-build --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }