diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 253e884..c6ca6a2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -59,6 +59,16 @@ 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 }}" + $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 } 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/README.md b/README.md index b4ea743..81ba1a0 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,26 @@ 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. | +| `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. | + +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 +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. + ### plugin sync > Build, deploy, and register steps in a single operation. **This is the plugin command you'll use most.** @@ -375,6 +397,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 +406,17 @@ 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`, 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 + +> **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:** @@ -419,7 +443,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] @@ -433,6 +457,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 +472,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 @@ -969,8 +1002,8 @@ 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. | -| `plugintype` | One record per plugin class. Queried to resolve class names to GUIDs for step registration. | +| `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. 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/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..42389df --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,12 @@ + + + + + + + opencover + + + + + diff --git a/src/dvx.Tests/AssemblyDeployerTests.cs b/src/dvx.Tests/AssemblyDeployerTests.cs new file mode 100644 index 0000000..abecd9e --- /dev/null +++ b/src/dvx.Tests/AssemblyDeployerTests.cs @@ -0,0 +1,127 @@ +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)); + var bytes = File.ReadAllBytes(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" && + (string)e["content"] == Convert.ToBase64String(bytes))); + 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)); + var bytes = File.ReadAllBytes(dll); + + result.ShouldBe(existingId); + svc.Received(1).Update(Arg.Is(e => + e.LogicalName == "pluginassembly" && + e.Id == existingId && + (string)e["version"] == "1.2.3.4" && + (string)e["content"] == Convert.ToBase64String(bytes))); + 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_WritesNothing_ReturnsEmpty() + { + 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/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/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/PluginDeployRunnerTests.cs b/src/dvx.Tests/PluginDeployRunnerTests.cs new file mode 100644 index 0000000..df136e1 --- /dev/null +++ b/src/dvx.Tests/PluginDeployRunnerTests.cs @@ -0,0 +1,119 @@ +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 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(); + 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), NewTypeNames, 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), ATypeNames, 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), NewTypeNames, 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.Tests/PluginDeploymentPlanTests.cs b/src/dvx.Tests/PluginDeploymentPlanTests.cs new file mode 100644 index 0000000..61adc0e --- /dev/null +++ b/src/dvx.Tests/PluginDeploymentPlanTests.cs @@ -0,0 +1,83 @@ +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.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.1.0.0.nupkg"); + plan.Warning.ShouldBeNull(); + } + + [Fact] + public void PackageMode_WithoutNupkg_Throws() + { + var ex = Should.Throw(() => + PluginDeploymentPlan.Resolve(PluginBuildMode.Package, Build(null))); + + ex.Message.ShouldContain(".nupkg"); + ex.Message.ShouldContain("assembly"); + } + + [Fact] + public void AssemblyMode_WithoutNupkg_DeploysDll_NoWarning() + { + var plan = PluginDeploymentPlan.Resolve(PluginBuildMode.Assembly, Build(null)); + + 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.ArtifactPath.ShouldBe(@"C:\out\MyPlugin.dll"); + 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/PluginDiscoveryTests.cs b/src/dvx.Tests/PluginDiscoveryTests.cs index 96da6b9..a7dd0a8 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() => + PluginDiscovery.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..2d0d70e --- /dev/null +++ b/src/dvx.Tests/PluginTypeRegistrarTests.cs @@ -0,0 +1,229 @@ +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 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. + private static IOrganizationService Svc() + { + var svc = Substitute.For(); + 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() + { + var svc = Svc(); + var assemblyId = Guid.NewGuid(); + + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, AbTypeNames); + + 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(); + var assemblyId = Guid.NewGuid(); + HasTypes(svc, Type("NS.A", Guid.NewGuid())); + + 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")); + } + + [Fact] + public void EnsureRegistered_DeduplicatesTypeNames() + { + var svc = Svc(); + + new PluginTypeRegistrar(svc).EnsureRegistered(Guid.NewGuid(), DupATypeNames); + + 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()); + } + + [Fact] + public void EnsureRegistered_QueriesTypesForTheGivenAssembly() + { + var svc = Svc(); + var assemblyId = Guid.NewGuid(); + + new PluginTypeRegistrar(svc).EnsureRegistered(assemblyId, ATypeNames); + + svc.Received(1).RetrieveMultiple(Arg.Is(q => + q.EntityName == "plugintype" && + 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(), NewTypeNames); + + 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(), KeepTypeNames); + + 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(), NewTypeNames); + + 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(), NewTypeNames); + + 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(), NewTypeNames); + + 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(), NewTypeNames, 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, NewTypeNames); + + 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.Tests/ProjectBuilderTests.cs b/src/dvx.Tests/ProjectBuilderTests.cs new file mode 100644 index 0000000..191e740 --- /dev/null +++ b/src/dvx.Tests/ProjectBuilderTests.cs @@ -0,0 +1,86 @@ +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); + + GC.SuppressFinalize(this); + } + + 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 BuildAllowingMissingPackage_DllOnly_ReturnsArtifactWithoutRequiringNupkg() + { + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.dll"), "dll"); + + var result = new NoBuildProjectBuilder().BuildAllowingMissingPackage(_projectPath); + + result.DllPath.ShouldBe(Path.Combine(ReleaseDir, "MyPlugin.dll")); + result.NupkgPath.ShouldBeNull(); + } + + [Fact] + 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().BuildAllowingMissingPackage(_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 BuildAllowingMissingPackage_MissingDll_Throws() + { + File.WriteAllText(Path.Combine(ReleaseDir, "MyPlugin.1.0.0.nupkg"), "pkg"); + + Should.Throw(() => new NoBuildProjectBuilder().BuildAllowingMissingPackage(_projectPath)); + } + } +} diff --git a/src/dvx.Tests/SolutionServiceTests.cs b/src/dvx.Tests/SolutionServiceTests.cs index 3f6da58..bfd2668 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 ─────────────────────────────────────────── + // ── AddAssemblyToSolution ────────────────────────────────────────────── + + [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 399db46..f8a9bd7 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -11,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(); @@ -20,11 +20,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 +38,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,23 +51,15 @@ 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( configured, solution, new SolutionPublisherResolver(svc).GetCustomizationPrefix); if (prefixWarning is not null) Out.Warn(prefixWarning); - Out.Step("Building", resolvedProject); - var build = new ProjectBuilder().Build(resolvedProject); - var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - 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); - - Out.Success("Deployed.", $"Assembly ID: {assemblyId}"); + PluginDeployRunner.BuildAndDeploy(svc, mode, solution, envConfig.Url, + resolvedProject, prefix, new PluginDeployOptions(isVerbose, isDryRun)); } catch (Exception ex) { 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/Shared/PluginDeployRunner.cs b/src/dvx/Commands/Shared/PluginDeployRunner.cs new file mode 100644 index 0000000..023e3ff --- /dev/null +++ b/src/dvx/Commands/Shared/PluginDeployRunner.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using dvx.Models; +using dvx.Output; +using dvx.Services; +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 + /// 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, + PluginDeployOptions options) + { + var (verbose, dryRun) = options; + + 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}"); + + Guid assemblyId; + if (mode == PluginBuildMode.Package) + { + assemblyId = new PackageDeployer(svc).Deploy(artifact, verbose, dryRun); + } + else + { + var typeNames = PluginDiscovery.DiscoverPluginTypeNames(build.DllPath); + assemblyId = DeployAssembly( + new AssemblyDeployer(svc), new PluginTypeRegistrar(svc), artifact, typeNames, dryRun, verbose); + } + + 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); + } + + /// + /// 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/Shared/PluginDeploymentPlan.cs b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs new file mode 100644 index 0000000..13f0d71 --- /dev/null +++ b/src/dvx/Commands/Shared/PluginDeploymentPlan.cs @@ -0,0 +1,43 @@ +using dvx.Models; +using dvx.Services; + +namespace dvx.Commands.Shared +{ + /// + /// 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(string ArtifactPath, string? Warning) + { + public static PluginDeploymentPlan Resolve(PluginBuildMode mode, BuildResult build) + { + 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."); + + return new(build.NupkgPath, 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); + } + + /// + /// 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 533a6f0..063b38c 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -20,6 +20,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 +28,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 +39,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,30 +54,30 @@ 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( configured, solution, new SolutionPublisherResolver(svc).GetCustomizationPrefix); if (prefixWarning is not null) Out.Warn(prefixWarning); - // ── Build ─────────────────────────────────────────────── - Out.Step("Building", resolvedProject); - var build = new ProjectBuilder().Build(resolvedProject); - var assemblyName = Path.GetFileNameWithoutExtension(build.DllPath); - 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); - 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, + new PluginDeployOptions(isVerbose, isDryRun)); // ── 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); + var definitions = discovery.Discover(dllPath, isVerbose); Out.Info($"Found {definitions.Count} step definition(s)."); if (isDryRun) 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/AssemblyDeployer.cs b/src/dvx/Services/AssemblyDeployer.cs new file mode 100644 index 0000000..e91f0cf --- /dev/null +++ b/src/dvx/Services/AssemblyDeployer.cs @@ -0,0 +1,92 @@ +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) + => 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, assemblyName); + var result = Svc.RetrieveMultiple(query); + + if (result.Entities.Count > 1) + throw new InvalidOperationException( + $"Multiple pluginassembly records named '{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/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..6331ea2 100644 --- a/src/dvx/Services/PackageDeployer.cs +++ b/src/dvx/Services/PackageDeployer.cs @@ -9,72 +9,46 @@ 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) : base(svc) { } - public PackageDeployer(IOrganizationService svc) => _svc = svc; + // ── Skeleton hooks ───────────────────────────────────────────────────── - // ── Public API ───────────────────────────────────────────────────────── - - /// - /// 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. - /// - 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. " + + 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."); - if (!dryRun) - { - UploadContent(nupkgPath, packageId, verbose); - } - - return FindAssemblyInPackage(packageId, packageUniqueName); - } - - // ── Upload ───────────────────────────────────────────────────────────── - - 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 +58,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/PluginDiscovery.cs b/src/dvx/Services/PluginDiscovery.cs index c2542da..0a5eacd 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 static 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..f3dbcc3 --- /dev/null +++ b/src/dvx/Services/PluginTypeRegistrar.cs @@ -0,0 +1,146 @@ +using dvx.Output; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace dvx.Services +{ + /// + /// 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) + { + /// + /// 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 (desiredTypeNames.Count == 0) + return; + + 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)) + 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), + }); + created++; + } + + if (created > 0) + Out.Success("Registered", $"{created} plugin type(s)."); + } + + /// + /// 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 sealed record ExistingType(Guid Id, string TypeName); + + private List ExistingTypes(Guid assemblyId) + { + var query = new QueryExpression("plugintype") + { + ColumnSet = new ColumnSet("plugintypeid", "typename"), + Criteria = new FilterExpression(), + }; + query.Criteria.AddCondition("pluginassemblyid", ConditionOperator.Equal, assemblyId); + + 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/ProjectBuilder.cs b/src/dvx/Services/ProjectBuilder.cs index a8f312a..8a7dfdc 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 BuildAllowingMissingPackage(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) diff --git a/src/dvx/Services/SdkMetadata.cs b/src/dvx/Services/SdkMetadata.cs index c4dd625..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"), + 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; @@ -253,6 +254,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(PluginTypeIdAttribute); + 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. /// @@ -278,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); 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); 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`.