diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..0ffce21
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+root = true
+
+[*.cs]
+# File copyright headers are not required.
+dotnet_diagnostic.SA1633.severity = none
+# Match the project's file-scoped namespaces and modern C# conventions.
+csharp_using_directive_placement = outside_namespace
+# Qualification is unnecessary for unambiguous member access.
+dotnet_diagnostic.SA1101.severity = none
+csharp_preserve_single_line_blocks = false
+csharp_preserve_single_line_statements = false
+
+[tests/**/*.cs]
+# Tests and the SDK test double are not a documented public API.
+dotnet_diagnostic.CS1591.severity = none
+dotnet_diagnostic.SA1600.severity = none
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2ea4856
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: build
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+ - run: dotnet test -c Release
+ - run: dotnet pack src/SolutionExtender.Tool -c Release -o artifacts/packages
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nuget-package
+ path: artifacts/packages/*.nupkg
diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
new file mode 100644
index 0000000..36d532d
--- /dev/null
+++ b/.github/workflows/publish-nuget.yml
@@ -0,0 +1,94 @@
+name: Publish NuGet
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nuget-publish-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - name: Check out release tag
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Set up .NET 10
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Validate release version and publishing configuration
+ id: release
+ shell: bash
+ env:
+ RELEASE_TAG: ${{ github.ref_name }}
+ NUGET_USER: ${{ secrets.NUGET_USER }}
+ run: |
+ set -euo pipefail
+ if [[ -z "$NUGET_USER" ]]; then
+ echo '::error::Configure the NUGET_USER GitHub repository secret.'
+ exit 1
+ fi
+ # Canonical SemVer release or prerelease; reject NuGet normalization ambiguities.
+ version="${RELEASE_TAG#v}"
+ if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
+ echo "::error::Expected a tag such as v0.1.0 or v0.2.0-preview.1, got $RELEASE_TAG"
+ exit 1
+ fi
+ if [[ "$version" == *-* ]]; then
+ IFS='.' read -ra identifiers <<< "${version#*-}"
+ for identifier in "${identifiers[@]}"; do
+ if [[ "$identifier" =~ ^[0-9]+$ && "$identifier" != '0' && "$identifier" == 0* ]]; then
+ echo '::error::Numeric prerelease identifiers must not have leading zeros.'
+ exit 1
+ fi
+ done
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Test release
+ env:
+ RELEASE_VERSION: ${{ steps.release.outputs.version }}
+ run: dotnet test SolutionExtender.slnx -c Release -p:Version="$RELEASE_VERSION"
+
+ - name: Pack release
+ env:
+ RELEASE_VERSION: ${{ steps.release.outputs.version }}
+ run: >-
+ dotnet pack src/SolutionExtender.Tool/SolutionExtender.Tool.csproj
+ -c Release --no-restore -o artifacts/packages
+ -p:Version="$RELEASE_VERSION" -p:ContinuousIntegrationBuild=true
+
+ - name: Preserve release package
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: SolutionExtender-${{ steps.release.outputs.version }}
+ path: artifacts/packages/*.nupkg
+ if-no-files-found: error
+
+ # Exchange GitHub OIDC for a short-lived, package-scoped API key.
+ # No long-lived NUGET_API_KEY secret is needed.
+ - name: Authenticate to NuGet using trusted publishing
+ uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1
+ id: nuget-login
+ with:
+ user: ${{ secrets.NUGET_USER }}
+
+ - name: Publish package
+ env:
+ RELEASE_VERSION: ${{ steps.release.outputs.version }}
+ NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
+ run: >-
+ dotnet nuget push "artifacts/packages/SolutionExtender.$RELEASE_VERSION.nupkg"
+ --api-key "$NUGET_API_KEY"
+ --source https://api.nuget.org/v3/index.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2c96f46
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+**/bin/
+**/obj/
+artifacts/
+TestResults/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..13f99e0
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,18 @@
+
+
+ enable
+ enable
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 1000df9..dd07011 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,230 @@
# SolutionExtender
-Utilities for working with unmanaged solutions
+
+A **.NET 10 CLI and reusable offline library** for migrating Daxif extended-solution handling to a standard Power Platform CLI (`pac`) workflow. Distributed as a NuGet tool, runnable with **`dnx`**.
+
+PAC handles solution export/unpack/pack/import/publish. SolutionExtender handles the extra `ExtendedSolution.xml` manifest and the pre-/post-import reconciliation that PAC does not perform for unmanaged solutions. Connections use the **DataverseConnection** NuGet package; no Daxif runtime dependency is required.
+
+## Build and run
+
+Requires the .NET 10 SDK. To try the unpublished package from this repository:
+
+```bash
+dotnet test
+dotnet pack src/SolutionExtender.Tool -c Release -o artifacts/packages
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- --help
+```
+
+Once published to your NuGet feed, use `dnx SolutionExtender@ -- ` (add `--source ` for a private feed). The tag-triggered publishing workflow must be configured on GitHub and nuget.org before releases can publish. Pin versions in deployment pipelines.
+
+For development without packing:
+
+```bash
+dotnet run --project src/SolutionExtender.Tool -- inspect --input ./out/Magnus_extended.zip
+```
+
+## Connection selection
+
+Every Dataverse command accepts an explicit environment URL and authentication method:
+
+```bash
+az login
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- pre-import \
+ --zip ./out/Magnus_extended.zip \
+ --environment https://target.crm4.dynamics.com --auth azcli
+```
+
+`--environment` is an HTTPS organization URL, **not** a PAC auth-profile name. The tool does not read or change PAC's selected environment; choose the same target explicitly for both tools.
+
+| Option | Meaning |
+| --- | --- |
+| `--auth azcli` | Reuse an authenticated Azure CLI session (`az login`). |
+| `--auth devicecode` | Authenticate using a device code. |
+| `--auth interactive` | Interactive browser authentication; default. `browser` is an alias. |
+| `--tenant-id ` | Optional tenant override for `azcli` only. |
+
+Browser and device-code authentication are managed by DataverseConnection, including the environment-specific persistent token cache, saved authentication record, and persistence fallback. DataverseConnection permits an unencrypted token-cache fallback on Linux when the keychain is unavailable; protect the cache as a secret. `azcli` depends on the Azure CLI's own token cache.
+
+`--client-id` is not supported. `--tenant-id` is supported only with `--auth azcli`; browser and device-code authentication use DataverseConnection's built-in credential configuration.
+
+## 1. Export and capture extended metadata
+
+Start with a fresh PAC export, then capture state, owners, and keep-lists from the **source** environment immediately after exporting. Avoid modifying that environment between export and capture.
+
+```bash
+pac solution export --name Magnus --path ./out/Magnus.zip --managed false
+
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- extend \
+ --zip ./out/Magnus.zip --output ./out/Magnus_extended.zip \
+ --environment https://source.crm4.dynamics.com --auth azcli
+```
+
+The unique solution name is read from `solution.xml`. `extend` requires the source solution to exist, writes a separate output by default, and never modifies Dataverse. A plain ZIP cannot reliably supply live states and workflow owners; the capture step therefore needs a connection.
+
+### Native manifest format
+
+New captures write `ExtendedSolution.xml` using namespace **`urn:solutionextender:manifest`**, with an explicit **`version="1"`**. The contract is independent of Daxif and F# runtime serialization. Components use named attributes instead of tuples; states are a flat list rather than a serialized F# map.
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Workflow components optionally include `owner="user@example.org"`. Every section except `CustomApis` is required, even when empty. Unsupported versions, unknown elements/attributes, and missing or duplicate sections are rejected before reconciliation. Output is deterministic for source-control diffs.
+
+**Only SolutionExtender manifests are supported.** There is no legacy reader, writer, or migration path. Start with a fresh PAC export and run `extend`; use the resulting ZIP with SolutionExtender’s pre-/post-import commands.
+
+## 2. Preserve metadata alongside PAC unpacked files
+
+```bash
+pac solution unpack --zipfile ./out/Magnus_extended.zip --folder ./solutions/Magnus --packagetype Unmanaged
+
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- extract \
+ --zip ./out/Magnus_extended.zip --folder ./solutions/Magnus
+```
+
+`extract` writes a validated, deterministic `ExtendedSolution.xml` sidecar at the unpacked folder root. Commit it with the PAC source. It does **not** patch PAC-generated component XML or depend on PAC's internal folder layout. Add `--overwrite` to replace an existing sidecar.
+
+After PAC packing, explicitly attach the sidecar:
+
+```bash
+pac solution pack --folder ./solutions/Magnus --zipfile ./out/Magnus_packed.zip --packagetype Unmanaged
+
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- attach \
+ --zip ./out/Magnus_packed.zip --manifest ./solutions/Magnus \
+ --output ./out/Magnus_extended.zip --overwrite
+```
+
+`--manifest` accepts a directory or XML file. ZIP entry payloads other than the extended manifest are preserved. Writes use temporary files and atomic replacement; duplicate extended entries are replaced with exactly one. In-place replacement requires `--overwrite`.
+
+**Important:** keep-lists must describe the exact release being deployed. `attach` preserves the sidecar; it does not regenerate it from edited PAC files. After adding/removing components in source, refresh the export/capture (or deliberately update the manifest). Deploying stale keep-lists can delete newly added components. Workflow owners and state values cannot be inferred safely from an ordinary packed ZIP.
+
+## 3. Reconcile and import into the target
+
+First review the pre-import plan. By default, **no mutations occur**:
+
+```bash
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- pre-import \
+ --zip ./out/Magnus_extended.zip \
+ --environment https://target.crm4.dynamics.com --auth azcli
+```
+
+Then execute the deployment, stopping immediately if any command fails:
+
+```bash
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- pre-import \
+ --zip ./out/Magnus_extended.zip --apply \
+ --environment https://target.crm4.dynamics.com --auth azcli
+
+pac solution import --path ./out/Magnus_extended.zip
+
+# Run only after the import has completed successfully.
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- post-import \
+ --zip ./out/Magnus_extended.zip --apply --reassign-workflows \
+ --environment https://target.crm4.dynamics.com --auth azcli
+
+pac solution publish
+```
+
+For asynchronous PAC imports, wait for successful completion before post-import. Make sure PAC is authenticated against the **same target URL**. `--reassign-workflows` is optional and only valid for post-import. Remove `--apply` to preview either phase. `--output plan.json` writes the plan to a file instead of stdout; mutations still require `--apply`.
+
+### Daxif behavior implemented
+
+| Phase | Behavior / matching key |
+| --- | --- |
+| Pre-import | Delete obsolete custom APIs by unique name. |
+| Pre-import | Delete obsolete images and active plugin steps by GUID. |
+| Pre-import | Delete obsolete plugin types and assemblies by name. |
+| Post-import | Delete obsolete unmanaged web resources by name. |
+| Post-import | Deactivate and delete obsolete workflow definitions, including modern flow definitions, by GUID. |
+| Post-import | Optionally assign workflows to target users matched by source owner's domain name, drafting before assignment and restoring state afterward. |
+| Post-import | Restore saved-query and workflow state/status values from the manifest. |
+
+Deletion order is custom APIs → images → steps → types → assemblies. Step enumeration follows Daxif's **active-step-only** behavior (`statecode=0`, `statuscode=1`); inactive standalone steps are not reconciled. Types and images are enumerated under solution assemblies and active solution steps respectively. Queries are paged and filtered to unmanaged records. Direct component queries include a solution ID filter. Standard component types also have a type filter; Custom APIs use the solution-component object-ID join without assuming an environment-specific component type number.
+
+### Safety and limitations
+
+- Intended for **unmanaged** solution reconciliation. Both managed source ZIPs and managed target solutions are rejected by connected commands.
+- This is **destructive synchronization**, not merely removing a component from a solution. Dataverse deletions can affect other solutions referencing the same components, and deleting parent records may cascade to children. Use a dedicated release solution, review plans, and back up first.
+- There is no transaction across pre-import, PAC import, and post-import. Operations run sequentially and fail fast; prior successful operations are not rolled back.
+- A missing target solution is a successful pre-import no-op. Post-import requires it to exist. Authentication/query failures are not interpreted as "solution missing."
+- Missing state records, missing/ambiguous enabled workflow-owner users, and team-owned source workflows fail explicitly before planned changes. Team ownership cannot be represented in Daxif's domain-name format.
+- An omitted `CustomApis` section means **do not reconcile APIs**, while an explicitly empty section means delete all in-scope APIs. Fresh captures include this section.
+- Unknown manifest fields, duplicate IDs, duplicate root manifests, invalid IDs, state-map key mismatches, and DTD/entity expansion are rejected rather than silently losing metadata.
+- No generic entity/attribute deletion, schema synchronization, workflow impersonation, or connection-secret command-line options are included.
+- No HTTP server is included: the offline service is the reusable `SolutionExtender.Core` class library, and the connected orchestration is `DataverseDeployment(IOrganizationService)`.
+
+## Offline inspection and planning
+
+```bash
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- inspect --input ./out/Magnus_extended.zip
+
+dnx SolutionExtender@0.1.0 --source ./artifacts/packages -- plan \
+ --source ./release.zip --target ./target_extended.zip --phase pre-import
+```
+
+Inputs accept ZIPs, XML files, or sidecar directories. Offline plans compare extended snapshots, **not** live environment state. For post-import plans, the target snapshot must include all source state records; take the target snapshot after importing. No saved plan execution command is provided; connected commands query the target again before applying.
+
+## Development / verification
+
+```bash
+dotnet build
+dotnet test
+dotnet pack src/SolutionExtender.Tool -c Release -o artifacts/packages
+```
+
+Tests use a native XML fixture and generated fresh PAC-style ZIPs to exercise XML round-trips, export → attach → extract → reattach workflows, payload preservation, unsupported-format rejection, component matching/deletion order, state restoration, scoped queries, reassignment, and fail-fast execution through a fake organization service. Live Dataverse authentication/import testing requires an environment and is not performed by the unit suite.
+
+Reference implementations: `context-and-oss/Daxif` (`Modules/Solution/Extend.fs`, `Domain.fs`) and `delegateas/DataverseConnection`. Deployment semantics are based on Daxif; the new manifest contract is owned and versioned by SolutionExtender.
+
+
+## NuGet deployment with trusted publishing
+
+`.github/workflows/publish-nuget.yml` tests, packs, and publishes when a tag such as `v0.1.0` or `v0.2.0-preview.1` is pushed. The tag determines the package version, overriding the development version in the project. Actions are pinned to commit SHAs. The publishing job requests GitHub OIDC credentials immediately before pushing; **no long-lived NuGet API-key secret is required**.
+
+One-time setup (requires repository administration and the NuGet package owner's account):
+
+1. Add a GitHub repository **Actions secret** named **`NUGET_USER`**, containing your NuGet profile username (not your email address). No GitHub Actions environment or repository variable is required. Protect release tags against unauthorized creation or movement.
+2. On nuget.org, create a **Trusted Publishing** policy owned by the intended package owner, with:
+
+ | Field | Value |
+ | --- | --- |
+ | Repository owner | `context-and-oss` |
+ | Repository | `SolutionExtender` |
+ | Workflow file | `publish-nuget.yml` (filename only) |
+ | Environment | Leave empty (no GitHub Actions environment) |
+ | Package pattern | `SolutionExtender` |
+
+ Enable the publishing scopes needed for new versions; for the first publication, also permit publishing a new package. The package name must be available or already owned by that account/organization.
+3. Merge the release code, then push the release tag:
+
+ ```bash
+ git tag v0.1.0
+ git push origin v0.1.0
+ ```
+
+The workflow uploads the built package as an artifact before authentication/publishing. Re-publishing an existing NuGet version fails deliberately (no `--skip-duplicate`); use a new release tag/version instead. GitHub/NuGet policy configuration is external to the repository and is not created by the workflow.
+
+## Live read-only verification
+
+On October 2, 2026, device-code authentication through DataverseConnection was tested against `abs-preview.crm.dynamics.com`. The live solution was verified as **Magnus**, unmanaged, publisher **ContextAnd**, prefix **ctx**, version **1.0.0.1**. Metadata capture and both deployment planning phases were exercised without `--apply`. Local captures and plans are under ignored `artifacts/live-test/`, not committed. No live import, deletion, state update, or workflow reassignment was performed.
+
+
+## Static analysis
+
+All projects use the pinned AsyncFixer, Asyncify, Meziantou.Analyzer, SecurityCodeScan.VS2019, StyleCop.Analyzers, and SonarAnalyzer.CSharp packages from `Directory.Build.props`, with `PrivateAssets="All"`. Analyzer warnings fail the build through `TreatWarningsAsErrors`; the analyzer packages are not runtime dependencies of the published tool.
+
+Public production APIs have XML documentation, and types reside in matching files. `stylecop.json` keeps using directives outside file-scoped namespaces. `.editorconfig` disables mandatory copyright file headers, omits mandatory `this.` qualification for unambiguous member access, and exempts test methods/SDK test doubles from API documentation requirements. All other analyzer defaults remain enabled.
+
+Run `dotnet build -c Release` and `dotnet test -c Release` to verify the same checks locally as in CI.
diff --git a/SolutionExtender.slnx b/SolutionExtender.slnx
new file mode 100644
index 0000000..ddf497a
--- /dev/null
+++ b/SolutionExtender.slnx
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/SolutionExtender.Core/Component.cs b/src/SolutionExtender.Core/Component.cs
new file mode 100644
index 0000000..e9e0c6a
--- /dev/null
+++ b/src/SolutionExtender.Core/Component.cs
@@ -0,0 +1,11 @@
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace SolutionExtender;
+
+/// Identifies a component to retain, with an optional workflow owner domain name.
+/// The component or record identifier.
+/// The component name.
+/// The optional workflow owner domain name.
+public sealed record Component(Guid Id, string Name, string? Owner = null);
diff --git a/src/SolutionExtender.Core/DeploymentAction.cs b/src/SolutionExtender.Core/DeploymentAction.cs
new file mode 100644
index 0000000..30be3d2
--- /dev/null
+++ b/src/SolutionExtender.Core/DeploymentAction.cs
@@ -0,0 +1,11 @@
+namespace SolutionExtender;
+
+/// Describes one ordered Dataverse mutation in a deployment plan.
+/// The mutation kind.
+/// The Dataverse table logical name.
+/// The component or record identifier.
+/// The component name.
+/// The desired state code.
+/// The desired status code.
+/// The target user identifier for assignment.
+public sealed record DeploymentAction(string Kind, string LogicalName, Guid Id, string Name, int? StateCode = null, int? StatusCode = null, Guid? OwnerId = null);
diff --git a/src/SolutionExtender.Core/DeploymentPlan.cs b/src/SolutionExtender.Core/DeploymentPlan.cs
new file mode 100644
index 0000000..fa7e8fc
--- /dev/null
+++ b/src/SolutionExtender.Core/DeploymentPlan.cs
@@ -0,0 +1,6 @@
+namespace SolutionExtender;
+
+/// Contains the ordered actions for one deployment phase.
+/// The pre-import or post-import phase.
+/// The ordered deployment actions.
+public sealed record DeploymentPlan(string Phase, IReadOnlyList Actions);
diff --git a/src/SolutionExtender.Core/EntityState.cs b/src/SolutionExtender.Core/EntityState.cs
new file mode 100644
index 0000000..cb1b435
--- /dev/null
+++ b/src/SolutionExtender.Core/EntityState.cs
@@ -0,0 +1,12 @@
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace SolutionExtender;
+
+/// Records the desired state and status of a Dataverse record.
+/// The component or record identifier.
+/// The Dataverse table logical name.
+/// The desired state code.
+/// The desired status code.
+public sealed record EntityState(Guid Id, string LogicalName, int StateCode, int StatusCode);
diff --git a/src/SolutionExtender.Core/ExtendedManifest.cs b/src/SolutionExtender.Core/ExtendedManifest.cs
new file mode 100644
index 0000000..22dcf83
--- /dev/null
+++ b/src/SolutionExtender.Core/ExtendedManifest.cs
@@ -0,0 +1,72 @@
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace SolutionExtender;
+
+/// Contains the component keep-lists and record states for a release.
+public sealed class ExtendedManifest
+{
+ /// Gets the plugin assemblies to retain.
+ public IList Assemblies { get; init; } = [];
+
+ /// Gets the plugin types to retain.
+ public IList PluginTypes { get; init; } = [];
+
+ /// Gets the active plugin steps to retain.
+ public IList PluginSteps { get; init; } = [];
+
+ /// Gets the plugin images to retain.
+ public IList PluginImages { get; init; } = [];
+
+ /// Gets the workflow definitions to retain and their optional owner domain names.
+ public IList Workflows { get; init; } = [];
+
+ /// Gets the web resources to retain.
+ public IList WebResources { get; init; } = [];
+
+ /// Gets the APIs to retain, or null when API reconciliation was not captured.
+ public IList? CustomApis
+ {
+ get; init;
+ }
+
+ /// Gets the desired record states.
+ public IList States { get; init; } = [];
+
+ /// Rejects invalid or duplicate component identities and unsupported state records.
+ public void Validate()
+ {
+ foreach (var group in new[]
+ {
+ Assemblies,
+ PluginTypes,
+ PluginSteps,
+ PluginImages,
+ Workflows,
+ WebResources,
+ CustomApis ?? [],
+ })
+ {
+ if (group.Any(c => c.Id == Guid.Empty || string.IsNullOrWhiteSpace(c.Name)))
+ {
+ throw new InvalidDataException("Components must have a non-empty ID and name.");
+ }
+
+ if (group.GroupBy(c => c.Id).Any(g => g.Count() > 1))
+ {
+ throw new InvalidDataException("Duplicate component IDs in extended manifest.");
+ }
+ }
+
+ if (States.Any(s => s.Id == Guid.Empty || s.LogicalName is not ("savedquery" or "workflow")))
+ {
+ throw new InvalidDataException("States may only refer to savedquery or workflow records with non-empty IDs.");
+ }
+
+ if (States.GroupBy(s => s.Id).Any(g => g.Count() > 1))
+ {
+ throw new InvalidDataException("Duplicate entity states in extended manifest.");
+ }
+ }
+}
diff --git a/src/SolutionExtender.Core/ManifestXml.cs b/src/SolutionExtender.Core/ManifestXml.cs
new file mode 100644
index 0000000..f9f7fbb
--- /dev/null
+++ b/src/SolutionExtender.Core/ManifestXml.cs
@@ -0,0 +1,122 @@
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace SolutionExtender;
+
+/// Reads and writes SolutionExtender's versioned XML contract.
+public static class ManifestXml
+{
+ /// The namespace identifying the native manifest contract.
+ public const string NamespaceUri = "urn:solutionextender:manifest";
+
+ /// The supported native manifest schema version.
+ public const int CurrentVersion = 1;
+ private static readonly XNamespace Ns = NamespaceUri;
+
+ /// Loads XML with DTDs prohibited, external resolution disabled, and a document size limit.
+ /// The readable XML stream.
+ /// The parsed XML document.
+ public static XDocument Load(Stream stream)
+ {
+ using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 64 * 1024 * 1024 });
+ return XDocument.Load(reader);
+ }
+
+ /// Reads and validates native extended metadata.
+ /// The readable XML stream.
+ /// The validated metadata.
+ public static ExtendedManifest Read(Stream stream) => Read(Load(stream));
+
+ /// Reads and validates native extended metadata.
+ /// The parsed XML document.
+ /// The validated metadata.
+ public static ExtendedManifest Read(XDocument document)
+ {
+ var root = document.Root ?? throw new InvalidDataException("Empty extended manifest.");
+ if (root.Name != Ns + "ExtendedSolution")
+ {
+ throw new InvalidDataException("Not a SolutionExtender manifest.");
+ }
+
+ Shape(root, ["Assemblies", "PluginTypes", "PluginSteps", "PluginImages", "Workflows", "WebResources", "CustomApis", "States"], ["version"]);
+ if (!string.Equals(Attribute(root, "version"), CurrentVersion.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
+ {
+ throw new InvalidDataException($"Unsupported extended manifest version '{Attribute(root, "version")}'.");
+ }
+
+ List Components(string name, bool owners = false)
+ {
+ var field = Child(root, name);
+ Shape(field, ["Component"], []);
+ return field.Elements().Select(e =>
+ {
+ Shape(e, [], owners ? ["id", "name", "owner"] : ["id", "name"]);
+ return new Component(Id(Attribute(e, "id")), Attribute(e, "name"), owners ? e.Attribute("owner")?.Value : null);
+ }).ToList();
+ }
+
+ var states = Child(root, "States");
+ Shape(states, ["State"], []);
+ var manifest = new ExtendedManifest
+ {
+ Assemblies = Components("Assemblies"),
+ PluginTypes = Components("PluginTypes"),
+ PluginSteps = Components("PluginSteps"),
+ PluginImages = Components("PluginImages"),
+ WebResources = Components("WebResources"),
+ Workflows = Components("Workflows", true),
+ CustomApis = root.Element(Ns + "CustomApis") is null ? null : Components("CustomApis"),
+ States = states.Elements().Select(e =>
+ {
+ Shape(e, [], ["id", "logicalName", "stateCode", "statusCode"]);
+ return new EntityState(Id(Attribute(e, "id")), Attribute(e, "logicalName"), Number(Attribute(e, "stateCode")), Number(Attribute(e, "statusCode")));
+ }).ToList(),
+ };
+ manifest.Validate();
+ return manifest;
+ }
+
+ /// Serializes validated native metadata deterministically as UTF-8 XML.
+ /// The native extended metadata.
+ /// The UTF-8 manifest bytes.
+ public static byte[] Write(ExtendedManifest manifest)
+ {
+ manifest.Validate();
+ XElement Field(string name, IEnumerable components, bool owners = false) => new(Ns + name, components.OrderBy(c => c.Id).Select(c => new XElement(Ns + "Component", new XAttribute("id", c.Id), new XAttribute("name", c.Name), owners && c.Owner is not null ? new XAttribute("owner", c.Owner) : null)));
+ var root = new XElement(Ns + "ExtendedSolution", new XAttribute("xmlns", NamespaceUri), new XAttribute("version", CurrentVersion), Field("Assemblies", manifest.Assemblies), Field("PluginTypes", manifest.PluginTypes), Field("PluginSteps", manifest.PluginSteps), Field("PluginImages", manifest.PluginImages), Field("Workflows", manifest.Workflows, true), Field("WebResources", manifest.WebResources));
+ if (manifest.CustomApis is not null)
+ {
+ root.Add(Field("CustomApis", manifest.CustomApis));
+ }
+
+ root.Add(new XElement(Ns + "States", manifest.States.OrderBy(s => s.Id).Select(s => new XElement(Ns + "State", new XAttribute("id", s.Id), new XAttribute("logicalName", s.LogicalName), new XAttribute("stateCode", s.StateCode), new XAttribute("statusCode", s.StatusCode)))));
+ using var stream = new MemoryStream();
+ using (var writer = XmlWriter.Create(stream, new XmlWriterSettings { Encoding = new System.Text.UTF8Encoding(false), Indent = true, OmitXmlDeclaration = true }))
+ {
+ root.Save(writer);
+ }
+
+ return stream.ToArray();
+ }
+
+ private static XElement Child(XElement e, string name)
+ {
+ var children = e.Elements(Ns + name).ToArray();
+ return children.Length == 1 ? children[0] : throw new InvalidDataException($"Expected exactly one '{name}' field.");
+ }
+
+ private static string Attribute(XElement e, string name) => e.Attribute(name)?.Value ?? throw new InvalidDataException($"Missing '{name}' on '{e.Name.LocalName}'.");
+
+ private static Guid Id(string value) => Guid.TryParse(value, out var id) && id != Guid.Empty ? id : throw new InvalidDataException($"Invalid GUID '{value}' in extended manifest.");
+
+ private static int Number(string value) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) ? number : throw new InvalidDataException($"Invalid state/status code '{value}' in extended manifest.");
+
+ private static void Shape(XElement element, string[] children, string[] attributes)
+ {
+ if (element.Elements().Any(e => e.Name.Namespace != Ns || !children.Contains(e.Name.LocalName)) || element.Attributes().Any(a => !a.IsNamespaceDeclaration && (a.Name.Namespace != XNamespace.None || !attributes.Contains(a.Name.LocalName))) || element.Nodes().OfType().Any(t => !string.IsNullOrWhiteSpace(t.Value)))
+ {
+ throw new InvalidDataException($"Unsupported content in '{element.Name.LocalName}'; refusing to discard metadata.");
+ }
+ }
+}
diff --git a/src/SolutionExtender.Core/Reconciliation.cs b/src/SolutionExtender.Core/Reconciliation.cs
new file mode 100644
index 0000000..6e56042
--- /dev/null
+++ b/src/SolutionExtender.Core/Reconciliation.cs
@@ -0,0 +1,68 @@
+namespace SolutionExtender;
+
+/// Compares release metadata with a target snapshot without mutating Dataverse.
+public static class Reconciliation
+{
+ /// Builds an ordered reconciliation plan from source and target snapshots.
+ /// The release metadata.
+ /// The target environment snapshot.
+ /// The pre-import or post-import phase.
+ /// The ordered deployment plan.
+ public static DeploymentPlan Compare(ExtendedManifest source, ExtendedManifest target, string phase)
+ {
+ source.Validate();
+ target.Validate();
+ var actions = new List();
+ void Delete(string logicalName, IEnumerable keep, IEnumerable existing, bool byName)
+ {
+ var names = keep.Select(c => c.Name).ToHashSet(StringComparer.Ordinal);
+ var ids = keep.Select(c => c.Id).ToHashSet();
+ foreach (var component in existing.Where(c => byName ? !names.Contains(c.Name) : !ids.Contains(c.Id)).OrderBy(c => c.Id))
+ {
+ if (string.Equals(logicalName, "workflow", StringComparison.Ordinal))
+ {
+ actions.Add(new("set-state", logicalName, component.Id, component.Name, 0, 1));
+ }
+
+ actions.Add(new("delete", logicalName, component.Id, component.Name));
+ }
+ }
+
+ if (string.Equals(phase, "pre-import", StringComparison.Ordinal))
+ {
+ if (source.CustomApis is not null && target.CustomApis is not null)
+ {
+ Delete("customapi", source.CustomApis, target.CustomApis, true);
+ }
+
+ Delete("sdkmessageprocessingstepimage", source.PluginImages, target.PluginImages, false);
+ Delete("sdkmessageprocessingstep", source.PluginSteps, target.PluginSteps, false);
+ Delete("plugintype", source.PluginTypes, target.PluginTypes, true);
+ Delete("pluginassembly", source.Assemblies, target.Assemblies, true);
+ }
+ else if (string.Equals(phase, "post-import", StringComparison.Ordinal))
+ {
+ Delete("webresource", source.WebResources, target.WebResources, true);
+ Delete("workflow", source.Workflows, target.Workflows, false);
+ var states = target.States.ToDictionary(s => s.Id);
+ foreach (var state in source.States.OrderBy(s => s.Id))
+ {
+ if (!states.TryGetValue(state.Id, out var current) || !string.Equals(current.LogicalName, state.LogicalName, StringComparison.Ordinal))
+ {
+ throw new InvalidDataException($"Cannot restore state: {state.LogicalName} {state.Id} is missing from target snapshot.");
+ }
+
+ if (state.StateCode != current.StateCode || state.StatusCode != current.StatusCode)
+ {
+ actions.Add(new("set-state", state.LogicalName, state.Id, state.Id.ToString(), state.StateCode, state.StatusCode));
+ }
+ }
+ }
+ else
+ {
+ throw new ArgumentException("Phase must be pre-import or post-import.", nameof(phase));
+ }
+
+ return new(phase, actions);
+ }
+}
diff --git a/src/SolutionExtender.Core/SolutionExtender.Core.csproj b/src/SolutionExtender.Core/SolutionExtender.Core.csproj
new file mode 100644
index 0000000..23bd5b9
--- /dev/null
+++ b/src/SolutionExtender.Core/SolutionExtender.Core.csproj
@@ -0,0 +1,3 @@
+
+ net10.0
+
diff --git a/src/SolutionExtender.Core/SolutionIdentity.cs b/src/SolutionExtender.Core/SolutionIdentity.cs
new file mode 100644
index 0000000..ab44a9d
--- /dev/null
+++ b/src/SolutionExtender.Core/SolutionIdentity.cs
@@ -0,0 +1,9 @@
+using System.IO.Compression;
+using System.Xml.Linq;
+
+namespace SolutionExtender;
+
+/// Identifies the solution and its managed status from the package manifest.
+/// The solution unique name.
+/// Whether the solution is managed.
+public sealed record SolutionIdentity(string UniqueName, bool Managed);
diff --git a/src/SolutionExtender.Core/SolutionPackage.cs b/src/SolutionExtender.Core/SolutionPackage.cs
new file mode 100644
index 0000000..0744654
--- /dev/null
+++ b/src/SolutionExtender.Core/SolutionPackage.cs
@@ -0,0 +1,144 @@
+using System.IO.Compression;
+using System.Xml.Linq;
+
+namespace SolutionExtender;
+
+/// Preserves native extended metadata across solution ZIP and sidecar operations.
+public static class SolutionPackage
+{
+ /// The root ZIP entry and sidecar filename for extended metadata.
+ public const string ManifestFileName = "ExtendedSolution.xml";
+
+ /// Reads a solution unique name and managed flag from its ZIP.
+ /// The solution ZIP path.
+ /// The package's solution identity.
+ public static SolutionIdentity Identity(string zip)
+ {
+ using var archive = ZipFile.OpenRead(zip);
+ using var stream = Required(archive, "solution.xml").Open();
+ var root = ManifestXml.Load(stream);
+ var manifest = root.Root?.Element("SolutionManifest") ?? throw new InvalidDataException("Missing SolutionManifest.");
+ var name = manifest.Element("UniqueName")?.Value;
+ var managed = manifest.Element("Managed")?.Value;
+ if (string.IsNullOrWhiteSpace(name) || managed is not ("0" or "1"))
+ {
+ throw new InvalidDataException("Invalid solution name or Managed flag.");
+ }
+
+ return new(name, string.Equals(managed, "1", StringComparison.Ordinal));
+ }
+
+ /// Reads and validates native extended metadata.
+ /// The ZIP, XML file, or sidecar directory path.
+ /// The validated metadata.
+ public static ExtendedManifest Read(string path)
+ {
+ if (Directory.Exists(path))
+ {
+ path = Path.Combine(path, ManifestFileName);
+ }
+
+ if (!path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
+ {
+ using var file = File.OpenRead(path);
+ return ManifestXml.Read(file);
+ }
+
+ using var archive = ZipFile.OpenRead(path);
+ using var stream = Required(archive, ManifestFileName).Open();
+ return ManifestXml.Read(stream);
+ }
+
+ /// Writes the package's native metadata to a sidecar directory.
+ /// The solution ZIP path.
+ /// The destination sidecar directory.
+ /// Whether an existing output may be replaced.
+ public static void Extract(string zip, string folder, bool overwrite)
+ {
+ var bytes = ManifestXml.Write(Read(zip));
+ Directory.CreateDirectory(folder);
+ AtomicWrite(Path.Combine(folder, ManifestFileName), bytes, overwrite);
+ }
+
+ /// Attaches validated metadata to a solution ZIP using atomic output replacement.
+ /// The solution ZIP path.
+ /// The native manifest file or sidecar directory.
+ /// The destination ZIP path.
+ /// Whether an existing output may be replaced.
+ public static void Attach(string zip, string manifestPath, string output, bool overwrite) => Attach(zip, Read(manifestPath), output, overwrite);
+
+ /// Attaches validated metadata to a solution ZIP using atomic output replacement.
+ /// The solution ZIP path.
+ /// The native extended metadata.
+ /// The destination ZIP path.
+ /// Whether an existing output may be replaced.
+ public static void Attach(string zip, ExtendedManifest manifest, string output, bool overwrite)
+ {
+ _ = Identity(zip);
+ var bytes = ManifestXml.Write(manifest);
+ output = Path.GetFullPath(output);
+ if (File.Exists(output) && !overwrite)
+ {
+ throw new IOException($"Output exists: {output}. Use --overwrite.");
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(output)!);
+ var temp = output + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.Copy(zip, temp);
+ using (var archive = ZipFile.Open(temp, ZipArchiveMode.Update))
+ {
+ foreach (var entry in archive.Entries.Where(e => string.Equals(e.FullName, ManifestFileName, StringComparison.OrdinalIgnoreCase)).ToArray())
+ {
+ entry.Delete();
+ }
+
+ using var stream = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal).Open();
+ stream.Write(bytes);
+ }
+
+ File.Move(temp, output, overwrite);
+ }
+ finally
+ {
+ if (File.Exists(temp))
+ {
+ File.Delete(temp);
+ }
+ }
+ }
+
+ /// Reads the distinct non-private, non-default saved-query identifiers from an export.
+ /// The solution ZIP path.
+ /// The distinct view identifiers.
+ public static IEnumerable ViewIds(string zip)
+ {
+ using var archive = ZipFile.OpenRead(zip);
+ using var stream = Required(archive, "customizations.xml").Open();
+ return ManifestXml.Load(stream).Descendants("savedquery").Where(e => string.Equals(e.Element("isprivate")?.Value, "0", StringComparison.Ordinal) && string.Equals(e.Element("isdefault")?.Value, "0", StringComparison.Ordinal)).Select(e => Guid.Parse(e.Element("savedqueryid")?.Value ?? throw new InvalidDataException("Missing savedqueryid."))).Distinct().ToArray();
+ }
+
+ private static ZipArchiveEntry Required(ZipArchive archive, string name)
+ {
+ var matches = archive.Entries.Where(e => string.Equals(e.FullName, name, StringComparison.OrdinalIgnoreCase)).ToArray();
+ return matches.Length == 1 ? matches[0] : throw new InvalidDataException($"Expected exactly one root '{name}' in ZIP; found {matches.Length}.");
+ }
+
+ private static void AtomicWrite(string path, byte[] bytes, bool overwrite)
+ {
+ var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllBytes(temp, bytes);
+ File.Move(temp, path, overwrite);
+ }
+ finally
+ {
+ if (File.Exists(temp))
+ {
+ File.Delete(temp);
+ }
+ }
+ }
+}
diff --git a/src/SolutionExtender.Tool/Cli.cs b/src/SolutionExtender.Tool/Cli.cs
new file mode 100644
index 0000000..327d597
--- /dev/null
+++ b/src/SolutionExtender.Tool/Cli.cs
@@ -0,0 +1,271 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using DataverseConnection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.PowerPlatform.Dataverse.Client;
+using SolutionExtender;
+using SolutionExtender.Tool;
+
+namespace SolutionExtender.Tool;
+
+/// Parses command-line options and orchestrates solution deployment commands.
+public static class Cli
+{
+ private const string Help = """
+ SolutionExtender — extended solution deployment for PAC / .NET 10
+
+ Offline commands (no authentication):
+ inspect --input
+ extract --zip --folder [--overwrite]
+ attach --zip --manifest --output [--overwrite]
+ plan --source --target --phase [--output ]
+
+ Dataverse commands:
+ extend --zip --output [--overwrite]
+ pre-import --zip [--apply] [--output ]
+ post-import --zip [--apply] [--reassign-workflows] [--output ]
+
+ Connection options:
+ --environment
+ --auth Default: interactive
+ --tenant-id Optional; azcli only
+ Browser/device-code credentials and token caches are managed by DataverseConnection.
+
+ Pre/post default to DRY RUN; --apply permits actual mutations.
+ PAC remains responsible for export, unpack, pack, import and publish.
+ Never use extended reconciliation against managed solutions.
+ """;
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ WriteIndented = true,
+ Converters =
+ {
+ new JsonStringEnumConverter(),
+ },
+ };
+
+ /// Executes a CLI command and returns a process exit code.
+ /// The command-line arguments.
+ /// Zero on success, or one on failure.
+ public static int Run(string[] args)
+ {
+ try
+ {
+ if (args.Length == 0 || args[0] is "--help" or "-h" or "help")
+ {
+ Console.WriteLine(Help);
+ return 0;
+ }
+
+ return Execute(args);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Error: {ex.Message}");
+ return 1;
+ }
+ }
+
+ /// Validates command options and rejects unknown, missing, or duplicate arguments.
+ /// The command-line arguments.
+ /// The permitted option names.
+ /// The validated option values.
+ public static IReadOnlyDictionary Parse(string[] args, string[] allowed)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ var flags = new HashSet(StringComparer.Ordinal)
+ {
+ "apply",
+ "overwrite",
+ "reassign-workflows",
+ };
+ for (var i = 0; i < args.Length; i++)
+ {
+ if (!args[i].StartsWith("--", StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"Unexpected argument '{args[i]}'.", nameof(args));
+ }
+
+ var key = args[i][2..];
+ if (!allowed.Contains(key))
+ {
+ throw new ArgumentException($"Unknown option '--{key}'.", nameof(args));
+ }
+
+ if (result.ContainsKey(key))
+ {
+ throw new ArgumentException($"Duplicate option '--{key}'.", nameof(args));
+ }
+
+ if (flags.Contains(key))
+ {
+ result.Add(key, null);
+ }
+ else
+ {
+ if (++i == args.Length || args[i].StartsWith("--", StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"Missing value for '--{key}'.", nameof(args));
+ }
+
+ result.Add(key, args[i]);
+ }
+ }
+
+ return result;
+ }
+
+ private static string Required(IReadOnlyDictionary options, string key) =>
+ options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
+ ? value : throw new ArgumentException($"Missing --{key}.", nameof(options));
+
+ private static void Print(IReadOnlyDictionary options, object value)
+ {
+ var json = JsonSerializer.Serialize(value, Json);
+ if (options.TryGetValue("output", out var output))
+ {
+ File.WriteAllText(output!, json + Environment.NewLine);
+ }
+ else
+ {
+ Console.WriteLine(json);
+ }
+ }
+
+ private static int Execute(string[] args)
+ {
+ var command = args[0];
+ var allowed = command switch
+ {
+ "inspect" => new[]
+ {
+ "input",
+ },
+ "extract" => ["zip", "folder", "overwrite"],
+ "attach" => ["zip", "manifest", "output", "overwrite"],
+ "plan" => ["source", "target", "phase", "output"],
+ "extend" => ["zip", "output", "overwrite", "environment", "auth", "tenant-id", "client-id"],
+ "pre-import" or "post-import" => ["zip", "environment", "auth", "tenant-id", "client-id", "apply", "reassign-workflows", "output"],
+ _ => throw new ArgumentException($"Unknown command '{command}'. Use --help.", nameof(args)),
+ };
+ var options = Parse(args.Skip(1).ToArray(), allowed);
+
+ switch (command)
+ {
+ case "inspect":
+ Print(options, SolutionPackage.Read(Required(options, "input")));
+ return 0;
+ case "extract":
+ SolutionPackage.Extract(Required(options, "zip"), Required(options, "folder"), options.ContainsKey("overwrite"));
+ return 0;
+ case "attach":
+ SolutionPackage.Attach(Required(options, "zip"), Required(options, "manifest"), Required(options, "output"), options.ContainsKey("overwrite"));
+ return 0;
+ case "plan":
+ Print(options, Reconciliation.Compare(SolutionPackage.Read(Required(options, "source")), SolutionPackage.Read(Required(options, "target")), Required(options, "phase")));
+ return 0;
+ }
+
+ return ExecuteConnected(command, options);
+ }
+
+ private static int ExecuteConnected(string command, IReadOnlyDictionary options)
+ {
+ var zip = Required(options, "zip");
+ var identity = SolutionPackage.Identity(zip);
+ if (identity.Managed)
+ {
+ throw new InvalidOperationException("Extended reconciliation only supports unmanaged solution ZIPs.");
+ }
+
+ // Validate source metadata and arguments before starting authentication.
+ var source = string.Equals(command, "extend", StringComparison.Ordinal) ? null : SolutionPackage.Read(zip);
+ if (string.Equals(command, "extend", StringComparison.Ordinal))
+ {
+ var output = Required(options, "output");
+ if (File.Exists(output) && !options.ContainsKey("overwrite"))
+ {
+ throw new IOException("Output exists. Use --overwrite.");
+ }
+ }
+
+ if (options.ContainsKey("reassign-workflows") && !string.Equals(command, "post-import", StringComparison.Ordinal))
+ {
+ throw new ArgumentException("--reassign-workflows is only valid for post-import.", nameof(options));
+ }
+
+ var environment = Required(options, "environment");
+ using var provider = CreateConnection(options);
+ var connection = provider.GetRequiredService();
+ if (!connection.IsReady)
+ {
+ throw new InvalidOperationException($"Dataverse connection failed: {connection.LastError}");
+ }
+
+ var deployment = new DataverseDeployment(connection);
+ var solutionId = deployment.FindSolution(identity.UniqueName);
+ if (solutionId is null)
+ {
+ if (string.Equals(command, "pre-import", StringComparison.Ordinal))
+ {
+ Console.WriteLine("Solution does not exist in target; no pre-import actions needed.");
+ return 0;
+ }
+
+ throw new InvalidOperationException($"Solution '{identity.UniqueName}' does not exist in the selected environment.");
+ }
+
+ if (string.Equals(command, "extend", StringComparison.Ordinal))
+ {
+ var captured = deployment.Capture(zip, solutionId.Value);
+ SolutionPackage.Attach(zip, captured, Required(options, "output"), options.ContainsKey("overwrite"));
+ Console.WriteLine($"Extended '{identity.UniqueName}' into {Required(options, "output")}.");
+ return 0;
+ }
+
+ return ExecutePlan(command, options, source!, identity, environment, deployment, solutionId.Value);
+ }
+
+ private static int ExecutePlan(string command, IReadOnlyDictionary options, ExtendedManifest source, SolutionIdentity identity, string environment, DataverseDeployment deployment, Guid solutionId)
+ {
+ var plan = deployment.Plan(command, source, solutionId, options.ContainsKey("reassign-workflows"));
+ Print(options, new
+ {
+ Environment = environment,
+ Solution = identity.UniqueName,
+ Mode = options.ContainsKey("apply") ? "apply" : "dry-run",
+ plan.Phase,
+ plan.Actions,
+ });
+ if (options.ContainsKey("apply"))
+ {
+ deployment.Apply(plan, a => Console.Error.WriteLine($"{a.Kind}: {a.LogicalName} {a.Id} ({a.Name})"));
+ }
+ else
+ {
+ Console.Error.WriteLine("Dry run only. Re-run with --apply to change Dataverse.");
+ }
+
+ return 0;
+ }
+
+ private static ServiceProvider CreateConnection(IReadOnlyDictionary options)
+ {
+ var environment = Required(options, "environment");
+ if (!Uri.TryCreate(environment, UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, "https", StringComparison.Ordinal) || !string.Equals(uri.AbsolutePath, "/", StringComparison.Ordinal) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
+ {
+ throw new ArgumentException("--environment must be an HTTPS Dataverse environment URL (not a PAC profile name).", nameof(options));
+ }
+
+ var connectionOptions = ConnectionOptions.Create(options, environment);
+ var services = new ServiceCollection();
+ services.AddDataverse(o =>
+ {
+ o.DataverseUrl = connectionOptions.DataverseUrl;
+ o.CredentialType = connectionOptions.CredentialType;
+ o.AzureCliCredentialOptions = connectionOptions.AzureCliCredentialOptions;
+ });
+ return services.BuildServiceProvider();
+ }
+}
diff --git a/src/SolutionExtender.Tool/ConnectionOptions.cs b/src/SolutionExtender.Tool/ConnectionOptions.cs
new file mode 100644
index 0000000..6ed5c64
--- /dev/null
+++ b/src/SolutionExtender.Tool/ConnectionOptions.cs
@@ -0,0 +1,42 @@
+using Azure.Identity;
+using DataverseConnection;
+
+namespace SolutionExtender.Tool;
+
+/// Maps CLI authentication selection without replacing library-managed interactive caching.
+internal static class ConnectionOptions
+{
+ /// Builds connection options and rejects overrides that bypass library caching.
+ /// The validated command-line options.
+ /// The Dataverse environment URL.
+ /// Connection options with interactive credential options left unset.
+ public static DataverseOptions Create(IReadOnlyDictionary options, string environment)
+ {
+ var credential = (options.GetValueOrDefault("auth") ?? "interactive") switch
+ {
+ "azcli" => DataverseCredentialType.AzureCliCredential,
+ "devicecode" => DataverseCredentialType.DeviceCodeCredential,
+ "interactive" or "browser" => DataverseCredentialType.InteractiveBrowserCredential,
+ _ => throw new ArgumentException("--auth must be azcli, devicecode, or interactive.", nameof(options)),
+ };
+ if (options.ContainsKey("client-id"))
+ {
+ throw new ArgumentException("--client-id is not supported. DataverseConnection manages the interactive credential and its environment-specific cache.", nameof(options));
+ }
+
+ options.TryGetValue("tenant-id", out var tenant);
+ if (tenant is not null && credential != DataverseCredentialType.AzureCliCredential)
+ {
+ throw new ArgumentException("--tenant-id is only supported with --auth azcli. Browser/device-code authentication uses DataverseConnection's credential and cache defaults.", nameof(options));
+ }
+
+ // Supplying interactive options bypasses DataverseConnection's persistent credential wrapper,
+ // environment-specific cache, authentication record and persistence fallback. Leave them null.
+ return new DataverseOptions
+ {
+ DataverseUrl = environment,
+ CredentialType = credential,
+ AzureCliCredentialOptions = tenant is null ? null : new AzureCliCredentialOptions { TenantId = tenant },
+ };
+ }
+}
diff --git a/src/SolutionExtender.Tool/DataverseDeployment.cs b/src/SolutionExtender.Tool/DataverseDeployment.cs
new file mode 100644
index 0000000..47fe802
--- /dev/null
+++ b/src/SolutionExtender.Tool/DataverseDeployment.cs
@@ -0,0 +1,239 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Messages;
+using Microsoft.Xrm.Sdk.Query;
+using SolutionExtender;
+
+namespace SolutionExtender.Tool;
+
+/// Solution-scoped, paged queries and ordered deployment actions. Authentication lives in DataverseConnection.
+public sealed class DataverseDeployment(IOrganizationService service)
+{
+ /// Finds an unmanaged target solution, or returns null when it does not exist.
+ /// The solution unique name.
+ /// The solution identifier, or null if absent.
+ public Guid? FindSolution(string name)
+ {
+ var q = new QueryExpression("solution")
+ {
+ ColumnSet = new("solutionid", "ismanaged"),
+ };
+ q.Criteria.AddCondition("uniquename", ConditionOperator.Equal, name);
+ var found = All(q);
+ if (found.Count == 0)
+ {
+ return null;
+ }
+
+ if (found.Count != 1)
+ {
+ throw new InvalidOperationException("Solution name was not unique.");
+ }
+
+ if (found[0].GetAttributeValue("ismanaged"))
+ {
+ throw new InvalidOperationException("Extended reconciliation is only supported for unmanaged solutions.");
+ }
+
+ return found[0].Id;
+ }
+
+ /// Reads solution-scoped component and state metadata without modifying Dataverse.
+ /// The solution identifier in the selected environment.
+ /// The records whose live states should be read.
+ /// Whether workflow owner domain names should be captured.
+ /// Whether plugin and API keep-lists should be captured.
+ /// Whether workflow and web-resource keep-lists should be captured.
+ /// The validated metadata.
+ public ExtendedManifest Snapshot(Guid solutionId, IEnumerable? states = null, bool owners = false, bool plugins = true, bool post = true)
+ {
+ var assemblies = plugins ? InSolution("pluginassembly", 91, solutionId) : [];
+ var types = assemblies.SelectMany(a => Related("plugintype", "pluginassemblyid", a.Id)).ToList();
+ var steps = plugins ? InSolution("sdkmessageprocessingstep", 92, solutionId).Where(e => Code(e, "statecode") == 0 && Code(e, "statuscode") == 1).ToList() : [];
+ var images = steps.SelectMany(s => Related("sdkmessageprocessingstepimage", "sdkmessageprocessingstepid", s.Id).Select(i => new Component(i.Id, Name(s) + " " + Name(i)))).ToList();
+ var workflows = post ? InSolution("workflow", 29, solutionId).Where(e => e.GetAttributeValue("type")?.Value == 1).ToList() : [];
+ return new ExtendedManifest
+ {
+ Assemblies = Components(assemblies),
+ PluginTypes = Components(types),
+ PluginSteps = Components(steps),
+ PluginImages = images,
+ CustomApis = plugins ? Components(InSolution("customapi", null, solutionId), "uniquename") : [],
+ WebResources = post ? Components(InSolution("webresource", 61, solutionId)) : [],
+ Workflows = workflows.Select(w => new Component(w.Id, Name(w), owners ? Owner(w) : null)).ToList(),
+ States = states is null ? workflows.Where(HasState).Select(State).ToList() : states.Select(s => State(service.Retrieve(s.LogicalName, s.Id, new ColumnSet("statecode", "statuscode")))).ToList(),
+ };
+ }
+
+ /// Captures live extended metadata for a freshly exported solution.
+ /// The solution ZIP path.
+ /// The solution identifier in the selected environment.
+ /// The validated metadata.
+ public ExtendedManifest Capture(string zip, Guid solutionId)
+ {
+ var snapshot = Snapshot(solutionId, owners: true);
+ foreach (var state in SolutionPackage.ViewIds(zip).Select(id => service.Retrieve("savedquery", id, new ColumnSet("statecode", "statuscode"))).Where(HasState).Select(State))
+ {
+ snapshot.States.Add(state);
+ }
+
+ snapshot.Validate();
+ return snapshot;
+ }
+
+ /// Builds a live deployment plan, optionally including workflow owner reassignment.
+ /// The pre-import or post-import phase.
+ /// The release metadata.
+ /// The solution identifier in the selected environment.
+ /// Whether workflow owner assignments should be planned.
+ /// The ordered deployment plan.
+ public DeploymentPlan Plan(string phase, ExtendedManifest source, Guid solutionId, bool reassign)
+ {
+ var target = Snapshot(solutionId, string.Equals(phase, "post-import", StringComparison.Ordinal) ? source.States : [], plugins: string.Equals(phase, "pre-import", StringComparison.Ordinal), post: string.Equals(phase, "post-import", StringComparison.Ordinal));
+ var plan = Reconciliation.Compare(source, target, phase);
+ if (!reassign || !string.Equals(phase, "post-import", StringComparison.Ordinal))
+ {
+ return plan;
+ }
+
+ var actions = plan.Actions.ToList();
+
+ // Match by domain name, not source environment user GUID. Missing/ambiguous owners fail before any mutation.
+ var assignments = new List();
+ foreach (var workflow in source.Workflows.Where(w => !string.IsNullOrWhiteSpace(w.Owner)).OrderBy(w => w.Id))
+ {
+ var users = Related("systemuser", "domainname", workflow.Owner!).Where(u => !u.GetAttributeValue("isdisabled")).ToList();
+ if (users.Count != 1)
+ {
+ throw new InvalidOperationException($"Workflow owner '{workflow.Owner}' must match exactly one enabled target user.");
+ }
+
+ var current = service.Retrieve("workflow", workflow.Id, new ColumnSet("ownerid", "statecode", "statuscode"));
+ if (current.GetAttributeValue("ownerid")?.Id == users[0].Id)
+ {
+ continue;
+ }
+
+ var desired = source.States.SingleOrDefault(s => string.Equals(s.LogicalName, "workflow", StringComparison.Ordinal) && s.Id == workflow.Id) ?? State(current);
+ actions.RemoveAll(a => string.Equals(a.Kind, "set-state", StringComparison.Ordinal) && string.Equals(a.LogicalName, "workflow", StringComparison.Ordinal) && a.Id == workflow.Id);
+ assignments.Add(new("set-state", "workflow", workflow.Id, workflow.Name, 0, 1));
+ assignments.Add(new("assign", "workflow", workflow.Id, workflow.Name, OwnerId: users[0].Id));
+ assignments.Add(new("set-state", "workflow", workflow.Id, workflow.Name, desired.StateCode, desired.StatusCode));
+ }
+
+ // Reassign workflows before restoring their final states.
+ var firstState = actions.FindIndex(a => string.Equals(a.Kind, "set-state", StringComparison.Ordinal) && source.States.Any(s => s.Id == a.Id));
+ actions.InsertRange(firstState < 0 ? actions.Count : firstState, assignments);
+ return new(phase, actions);
+ }
+
+ /// Executes a plan in order and stops at the first failed mutation.
+ /// The ordered plan to execute.
+ /// An optional callback before each action.
+ public void Apply(DeploymentPlan plan, Action? progress = null)
+ {
+ // Fail fast: never continue deleting parents after a failed child deletion.
+ foreach (var action in plan.Actions)
+ {
+ progress?.Invoke(action);
+ switch (action.Kind)
+ {
+ case "delete":
+ service.Delete(action.LogicalName, action.Id);
+ break;
+ case "set-state":
+ var request = new OrganizationRequest("SetState");
+ request["EntityMoniker"] = new EntityReference(action.LogicalName, action.Id);
+ request["State"] = new OptionSetValue(action.StateCode!.Value);
+ request["Status"] = new OptionSetValue(action.StatusCode!.Value);
+ service.Execute(request);
+ break;
+ case "assign":
+ service.Execute(new AssignRequest { Target = new(action.LogicalName, action.Id), Assignee = new("systemuser", action.OwnerId!.Value) });
+ break;
+ default:
+ throw new InvalidOperationException($"Unknown action {action.Kind}.");
+ }
+ }
+ }
+
+ private static string Name(Entity e) => e.GetAttributeValue("name") ?? throw new InvalidDataException($"Missing name on {e.LogicalName} {e.Id}.");
+
+ private static List Components(IEnumerable entities, string name = "name") => entities.Select(e => new Component(e.Id, e.GetAttributeValue(name) ?? throw new InvalidDataException($"Missing {name} on {e.LogicalName}."))).ToList();
+
+ private static bool HasState(Entity e) => e.Contains("statecode") && e.Contains("statuscode");
+
+ private static int Code(Entity e, string attribute) => e.GetAttributeValue(attribute)?.Value ?? throw new InvalidDataException($"Missing {attribute} on {e.LogicalName}.");
+
+ private static EntityState State(Entity e) => new(e.Id, e.LogicalName, Code(e, "statecode"), Code(e, "statuscode"));
+
+ private List InSolution(string table, int? componentType, Guid solutionId)
+ {
+ var q = new QueryExpression(table)
+ {
+ ColumnSet = new(true),
+ Distinct = true,
+ };
+ q.Criteria.AddCondition("ismanaged", ConditionOperator.Equal, false);
+ var link = q.AddLink("solutioncomponent", table + "id", "objectid");
+ link.LinkCriteria.AddCondition("solutionid", ConditionOperator.Equal, solutionId);
+
+ // Custom API component type is environment-assigned (10038 in the live test).
+ // Its objectid join already scopes to customapi records; never assume a fixed type.
+ if (componentType.HasValue)
+ {
+ link.LinkCriteria.AddCondition("componenttype", ConditionOperator.Equal, componentType.Value);
+ }
+
+ return All(q);
+ }
+
+ private List Related(string table, string column, object value)
+ {
+ var q = new QueryExpression(table)
+ {
+ ColumnSet = new(true),
+ };
+ q.Criteria.AddCondition(column, ConditionOperator.Equal, value);
+ if (table is "plugintype" or "sdkmessageprocessingstepimage")
+ {
+ q.Criteria.AddCondition("ismanaged", ConditionOperator.Equal, false);
+ }
+
+ return All(q);
+ }
+
+ private List All(QueryExpression query)
+ {
+ var result = new List();
+ query.AddOrder(query.EntityName + "id", OrderType.Ascending);
+ query.PageInfo = new PagingInfo
+ {
+ Count = 5000,
+ PageNumber = 1,
+ };
+ while (true)
+ {
+ var page = service.RetrieveMultiple(query);
+ result.AddRange(page.Entities);
+ if (!page.MoreRecords)
+ {
+ return result;
+ }
+
+ query.PageInfo.PageNumber++;
+ query.PageInfo.PagingCookie = page.PagingCookie;
+ }
+ }
+
+ private string Owner(Entity workflow)
+ {
+ var owner = workflow.GetAttributeValue("ownerid") ?? throw new InvalidDataException("Workflow has no owner.");
+ if (!string.Equals(owner.LogicalName, "systemuser", StringComparison.Ordinal))
+ {
+ throw new InvalidDataException("Team-owned workflows cannot be represented by the manifest's domain-name owner format.");
+ }
+
+ return service.Retrieve("systemuser", owner.Id, new ColumnSet("domainname")).GetAttributeValue("domainname") ?? throw new InvalidDataException("Workflow owner has no domainname.");
+ }
+}
diff --git a/src/SolutionExtender.Tool/Program.cs b/src/SolutionExtender.Tool/Program.cs
new file mode 100644
index 0000000..f9862c9
--- /dev/null
+++ b/src/SolutionExtender.Tool/Program.cs
@@ -0,0 +1,10 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Azure.Identity;
+using DataverseConnection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.PowerPlatform.Dataverse.Client;
+using SolutionExtender;
+using SolutionExtender.Tool;
+
+return Cli.Run(args);
diff --git a/src/SolutionExtender.Tool/SolutionExtender.Tool.csproj b/src/SolutionExtender.Tool/SolutionExtender.Tool.csproj
new file mode 100644
index 0000000..ab19824
--- /dev/null
+++ b/src/SolutionExtender.Tool/SolutionExtender.Tool.csproj
@@ -0,0 +1,22 @@
+
+
+ Exe
+ net10.0
+ true
+ solutionextender
+ SolutionExtender
+ 0.1.0
+ SolutionExtender contributors
+ Capture versioned deployment metadata for PAC solutions and reconcile unmanaged Dataverse deployments.
+ https://github.com/context-and-oss/SolutionExtender
+ git
+ true
+ README.md
+
+
+
+
+
+
+
+
diff --git a/stylecop.json b/stylecop.json
new file mode 100644
index 0000000..04cc7a0
--- /dev/null
+++ b/stylecop.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
+ "settings": {
+ "orderingRules": {
+ "usingDirectivesPlacement": "outsideNamespace"
+ }
+ }
+}
diff --git a/tests/SolutionExtender.Tests/ConnectionOptionsTests.cs b/tests/SolutionExtender.Tests/ConnectionOptionsTests.cs
new file mode 100644
index 0000000..f5d386c
--- /dev/null
+++ b/tests/SolutionExtender.Tests/ConnectionOptionsTests.cs
@@ -0,0 +1,62 @@
+using DataverseConnection;
+using SolutionExtender.Tool;
+using Xunit;
+
+namespace SolutionExtender.Tests;
+
+public sealed class ConnectionOptionsTests
+{
+ [Theory]
+ [InlineData("interactive", DataverseCredentialType.InteractiveBrowserCredential)]
+ [InlineData("browser", DataverseCredentialType.InteractiveBrowserCredential)]
+ [InlineData("devicecode", DataverseCredentialType.DeviceCodeCredential)]
+ [InlineData("azcli", DataverseCredentialType.AzureCliCredential)]
+ public void PreservesLibraryCredentialAndCacheDefaults(string auth, DataverseCredentialType expected)
+ {
+ var options = ConnectionOptions.Create(new Dictionary(StringComparer.Ordinal) { ["auth"] = auth }, "https://example.crm.dynamics.com");
+ Assert.Equal(expected, options.CredentialType);
+ Assert.Equal("https://example.crm.dynamics.com", options.DataverseUrl);
+ Assert.Null(options.TokenCredential);
+ Assert.Null(options.DeviceCodeCredentialOptions);
+ Assert.Null(options.InteractiveBrowserCredentialOptions);
+ Assert.Null(options.AzureCliCredentialOptions);
+ }
+
+ [Fact]
+ public void DefaultsToLibraryManagedBrowserAuthentication()
+ {
+ var options = ConnectionOptions.Create(new Dictionary(StringComparer.Ordinal), "https://example.crm.dynamics.com");
+ Assert.Equal(DataverseCredentialType.InteractiveBrowserCredential, options.CredentialType);
+ Assert.Null(options.InteractiveBrowserCredentialOptions);
+ }
+
+ [Fact]
+ public void AzureCliTenantDoesNotConfigureInteractiveCredentials()
+ {
+ var arguments = new Dictionary(StringComparer.Ordinal)
+ {
+ ["auth"] = "azcli",
+ ["tenant-id"] = "example-tenant",
+ };
+ var options = ConnectionOptions.Create(arguments, "https://example.crm.dynamics.com");
+ Assert.Equal("example-tenant", options.AzureCliCredentialOptions!.TenantId);
+ Assert.Null(options.DeviceCodeCredentialOptions);
+ Assert.Null(options.InteractiveBrowserCredentialOptions);
+ }
+
+ [Theory]
+ [InlineData("interactive", "tenant-id")]
+ [InlineData("devicecode", "tenant-id")]
+ [InlineData("interactive", "client-id")]
+ [InlineData("devicecode", "client-id")]
+ [InlineData("azcli", "client-id")]
+ public void RejectsOverridesRatherThanBypassingLibraryCaching(string auth, string option)
+ {
+ var options = new Dictionary(StringComparer.Ordinal)
+ {
+ ["auth"] = auth,
+ [option] = "override",
+ };
+ Assert.Throws(() => ConnectionOptions.Create(options, "https://example.crm.dynamics.com"));
+ }
+}
diff --git a/tests/SolutionExtender.Tests/DeploymentTests.cs b/tests/SolutionExtender.Tests/DeploymentTests.cs
new file mode 100644
index 0000000..0fb4977
--- /dev/null
+++ b/tests/SolutionExtender.Tests/DeploymentTests.cs
@@ -0,0 +1,182 @@
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using SolutionExtender;
+using SolutionExtender.Tool;
+using Xunit;
+
+namespace SolutionExtender.Tests;
+
+public sealed class DeploymentTests
+{
+ [Fact]
+ public void AppliesInOrderAndStopsOnFailure()
+ {
+ var fake = new FakeService();
+ var deployment = new DataverseDeployment(fake);
+ var id = Guid.NewGuid();
+ var plan = new DeploymentPlan("post-import", [new("set-state", "workflow", id, "flow", 0, 1), new("delete", "workflow", id, "flow"), new("delete", "webresource", Guid.NewGuid(), "resource")]);
+ fake.FailDelete = true;
+ Assert.Throws(() => deployment.Apply(plan));
+ Assert.Equal(new[] { "SetState", "delete:workflow" }, fake.Calls);
+ }
+
+ [Fact]
+ public void FindsMissingSolutionWithoutCreatingIt()
+ {
+ var fake = new FakeService();
+ Assert.Null(new DataverseDeployment(fake).FindSolution("new"));
+ Assert.Empty(fake.Calls);
+ }
+
+ [Fact]
+ public void RefusesManagedTarget()
+ {
+ var fake = new FakeService
+ {
+ Query = q => new EntityCollection([new Entity("solution", Guid.NewGuid()) { ["ismanaged"] = true }]),
+ };
+ Assert.Throws(() => new DataverseDeployment(fake).FindSolution("managed"));
+ }
+
+ [Fact]
+ public void QueriesArePagedAndScopedToSolutionAndUnmanagedComponents()
+ {
+ var solution = Guid.NewGuid();
+ var queries = new List();
+ var fake = new FakeService
+ {
+ Query = q =>
+ {
+ queries.Add(q);
+ return new();
+ },
+ };
+ _ = new DataverseDeployment(fake).Snapshot(solution, []);
+ Assert.Equal(5, queries.Count);
+ foreach (var q in queries)
+ {
+ Assert.Equal(5000, q.PageInfo.Count);
+ Assert.Contains(q.Criteria.Conditions, c => string.Equals(c.AttributeName, "ismanaged", StringComparison.Ordinal) && Equals(c.Values[0], false));
+ var link = Assert.Single(q.LinkEntities);
+ Assert.Contains(link.LinkCriteria.Conditions, c => string.Equals(c.AttributeName, "solutionid", StringComparison.Ordinal) && Equals(c.Values[0], solution));
+ if (!string.Equals(q.EntityName, "customapi", StringComparison.Ordinal))
+ {
+ Assert.Contains(link.LinkCriteria.Conditions, c => string.Equals(c.AttributeName, "componenttype", StringComparison.Ordinal));
+ }
+ }
+ }
+
+ [Fact]
+ public void CustomApisDoNotAssumeAUniversalComponentType()
+ {
+ var id = Guid.NewGuid();
+ var fake = new FakeService
+ {
+ Query = q =>
+ {
+ if (!string.Equals(q.EntityName, "customapi", StringComparison.Ordinal))
+ {
+ return new();
+ }
+
+ var link = Assert.Single(q.LinkEntities);
+ Assert.DoesNotContain(link.LinkCriteria.Conditions, c => string.Equals(c.AttributeName, "componenttype", StringComparison.Ordinal));
+ Assert.Equal("objectid", link.LinkToAttributeName);
+ return new EntityCollection([new Entity("customapi", id) { ["uniquename"] = "ctx_BedrockEcho" }]);
+ },
+ };
+ var snapshot = new DataverseDeployment(fake).Snapshot(Guid.NewGuid(), []);
+ Assert.Equal(new Component(id, "ctx_BedrockEcho"), Assert.Single(snapshot.CustomApis!));
+ }
+
+ [Fact]
+ public void FollowsPagingCookieWhenFindingSolution()
+ {
+ var id = Guid.NewGuid();
+ var pages = new List<(int Page, string? Cookie)>();
+ var fake = new FakeService
+ {
+ Query = q =>
+ {
+ pages.Add((q.PageInfo.PageNumber, q.PageInfo.PagingCookie));
+ return q.PageInfo.PageNumber == 1 ? new EntityCollection
+ {
+ MoreRecords = true,
+ PagingCookie = "next-page",
+ }
+
+ : new EntityCollection([new Entity("solution", id) { ["ismanaged"] = false }]);
+ },
+ };
+ Assert.Equal(id, new DataverseDeployment(fake).FindSolution("test"));
+ Assert.Equal(new[] { (1, (string?)null), (2, (string?)"next-page") }, pages);
+ }
+
+ [Fact]
+ public void CaptureIncludesViewStatesFromFreshExport()
+ {
+ var fake = new FakeService
+ {
+ RetrieveHandler = (table, id) => new Entity(table, id)
+ {
+ ["statecode"] = new OptionSetValue(0),
+ ["statuscode"] = new OptionSetValue(1),
+ },
+ };
+ using var temp = new TemporaryDirectory();
+ var snapshot = new DataverseDeployment(fake).Capture(NativeFixture.CreatePackage(temp.Path), Guid.NewGuid());
+ Assert.Equal(7, snapshot.States.Count);
+ Assert.All(snapshot.States, s => Assert.Equal("savedquery", s.LogicalName));
+ }
+
+ [Fact]
+ public void ReassignmentDraftsAssignsAndRestoresEvenWhenOriginalStateWasAlreadyCorrect()
+ {
+ var id = Guid.NewGuid();
+ var user = Guid.NewGuid();
+ var workflow = new Entity("workflow", id)
+ {
+ ["name"] = "flow",
+ ["type"] = new OptionSetValue(1),
+ ["statecode"] = new OptionSetValue(1),
+ ["statuscode"] = new OptionSetValue(2),
+ ["ownerid"] = new EntityReference("systemuser", Guid.NewGuid()),
+ };
+ var fake = new FakeService
+ {
+ RetrieveEntity = workflow,
+ Query = q => q.EntityName switch
+ {
+ "workflow" => new EntityCollection([workflow]),
+ "systemuser" => new EntityCollection([new Entity("systemuser", user) { ["isdisabled"] = false }]),
+ _ => new(),
+ },
+ };
+ var source = new ExtendedManifest
+ {
+ Workflows = [new(id, "flow", "user@org")],
+ States = [new(id, "workflow", 1, 2)],
+ };
+ var deployment = new DataverseDeployment(fake);
+ var plan = deployment.Plan("post-import", source, Guid.NewGuid(), true);
+ Assert.Equal(new[] { "set-state", "assign", "set-state" }, plan.Actions.Select(a => a.Kind));
+ Assert.Equal(0, plan.Actions[0].StateCode);
+ Assert.Equal(user, plan.Actions[1].OwnerId);
+ Assert.Equal(1, plan.Actions[2].StateCode);
+ Assert.Empty(fake.Calls); // Planning never mutates.
+ deployment.Apply(plan);
+ Assert.Equal(new[] { "SetState", "Assign", "SetState" }, fake.Calls);
+ }
+
+ [Fact]
+ public void MissingOwnerFailsBeforeMutations()
+ {
+ var fake = new FakeService();
+ var source = new ExtendedManifest
+ {
+ Workflows = [new(Guid.NewGuid(), "flow", "missing@org")],
+ };
+ Assert.Throws(() => new DataverseDeployment(fake).Plan("post-import", source, Guid.NewGuid(), true));
+ Assert.Empty(fake.Calls);
+ }
+}
diff --git a/tests/SolutionExtender.Tests/FakeService.cs b/tests/SolutionExtender.Tests/FakeService.cs
new file mode 100644
index 0000000..273349c
--- /dev/null
+++ b/tests/SolutionExtender.Tests/FakeService.cs
@@ -0,0 +1,53 @@
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using SolutionExtender;
+using SolutionExtender.Tool;
+using Xunit;
+
+namespace SolutionExtender.Tests;
+
+internal sealed class FakeService : IOrganizationService
+{
+ public List Calls { get; } = [];
+
+ public bool FailDelete
+ {
+ get; set;
+ }
+
+ public Func Query { get; init; } = _ => new();
+
+ public Func? RetrieveHandler
+ {
+ get; init;
+ }
+
+ public Entity RetrieveEntity { get; init; } = new("workflow", Guid.NewGuid());
+
+ public EntityCollection RetrieveMultiple(QueryBase query) => Query((QueryExpression)query);
+
+ public Entity Retrieve(string entityName, Guid id, ColumnSet columnSet) => RetrieveHandler?.Invoke(entityName, id) ?? RetrieveEntity;
+
+ public void Delete(string entityName, Guid id)
+ {
+ Calls.Add("delete:" + entityName);
+ if (FailDelete)
+ {
+ throw new InvalidOperationException("Simulated dependency failure.");
+ }
+ }
+
+ public OrganizationResponse Execute(OrganizationRequest request)
+ {
+ Calls.Add(request.RequestName);
+ return new();
+ }
+
+ public Guid Create(Entity entity) => throw new NotSupportedException();
+
+ public void Update(Entity entity) => throw new NotSupportedException();
+
+ public void Associate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotSupportedException();
+
+ public void Disassociate(string entityName, Guid entityId, Relationship relationship, EntityReferenceCollection relatedEntities) => throw new NotSupportedException();
+}
diff --git a/tests/SolutionExtender.Tests/Fixtures/ExtendedSolution.xml b/tests/SolutionExtender.Tests/Fixtures/ExtendedSolution.xml
new file mode 100644
index 0000000..64d0bab
--- /dev/null
+++ b/tests/SolutionExtender.Tests/Fixtures/ExtendedSolution.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SolutionExtender.Tests/ManifestTests.cs b/tests/SolutionExtender.Tests/ManifestTests.cs
new file mode 100644
index 0000000..6a26c22
--- /dev/null
+++ b/tests/SolutionExtender.Tests/ManifestTests.cs
@@ -0,0 +1,254 @@
+using System.IO.Compression;
+using System.Text;
+using System.Xml.Linq;
+using SolutionExtender;
+using SolutionExtender.Tool;
+using Xunit;
+
+namespace SolutionExtender.Tests;
+
+public sealed class ManifestTests : IDisposable
+{
+ private readonly TemporaryDirectory fixture = new();
+
+ public ManifestTests() => Package = NativeFixture.CreatePackage(fixture.Path, extended: true);
+
+ private string Package
+ {
+ get;
+ }
+
+ public void Dispose() => fixture.Dispose();
+
+ [Fact]
+ public void ReadsNativeFixture()
+ {
+ var m = SolutionPackage.Read(Package);
+ Assert.Single(m.Assemblies);
+ Assert.Equal(3, m.PluginTypes.Count);
+ Assert.Equal(2, m.PluginSteps.Count);
+ Assert.Empty(m.PluginImages);
+ Assert.Equal(7, m.WebResources.Count);
+ Assert.Empty(m.Workflows);
+ Assert.Single(m.CustomApis!);
+ Assert.Equal(7, m.States.Count);
+ Assert.All(m.States, s =>
+ {
+ Assert.Equal("savedquery", s.LogicalName);
+ Assert.Equal(0, s.StateCode);
+ Assert.Equal(1, s.StatusCode);
+ });
+ Assert.Equal(new SolutionIdentity("Example", false), SolutionPackage.Identity(Package));
+ }
+
+ [Fact]
+ public void XmlRoundTripIsStableAndPreservesOwners()
+ {
+ var m = SolutionPackage.Read(Package);
+ m.Workflows.Add(new(Guid.NewGuid(), "Flow & ", "user@example.org"));
+ m.States.Add(new(m.Workflows[0].Id, "workflow", 1, 2));
+ var bytes = ManifestXml.Write(m);
+ using var stream = new MemoryStream(bytes);
+ var read = ManifestXml.Read(stream);
+ Assert.Equal(m.Workflows, read.Workflows);
+ Assert.Equal(bytes, ManifestXml.Write(read));
+ }
+
+ [Fact]
+ public void NewExportsUseOurVersionedContract()
+ {
+ var id = Guid.NewGuid();
+ var m = new ExtendedManifest
+ {
+ Assemblies = [new(id, "Plugins")],
+ CustomApis = [],
+ States = [new(Guid.NewGuid(), "savedquery", 0, 1)],
+ };
+ var bytes = ManifestXml.Write(m);
+ var text = Encoding.UTF8.GetString(bytes);
+ Assert.DoesNotContain("Daxif", text, StringComparison.Ordinal);
+ Assert.DoesNotContain("FSharp", text, StringComparison.Ordinal);
+ Assert.DoesNotContain("m_Item", text, StringComparison.Ordinal);
+ using var stream = new MemoryStream(bytes);
+ var doc = ManifestXml.Load(stream);
+ Assert.Equal(XName.Get("ExtendedSolution", ManifestXml.NamespaceUri), doc.Root!.Name);
+ Assert.Equal("1", doc.Root.Attribute("version")!.Value);
+ var read = ManifestXml.Read(doc);
+ Assert.Equal(m.Assemblies, read.Assemblies);
+ Assert.Empty(read.CustomApis!);
+ }
+
+ [Fact]
+ public void ExtractPreservesNativeManifest()
+ {
+ using var temp = new TemporaryDirectory();
+ SolutionPackage.Extract(Package, temp.Path, false);
+ var text = File.ReadAllText(Path.Combine(temp.Path, SolutionPackage.ManifestFileName));
+ Assert.Contains(ManifestXml.NamespaceUri, text, StringComparison.Ordinal);
+ Assert.DoesNotContain("Daxif", text, StringComparison.Ordinal);
+ Assert.Single(SolutionPackage.Read(temp.Path).CustomApis!);
+ }
+
+ [Theory]
+ [InlineData("version", "2")]
+ [InlineData("unknown", "value")]
+ public void RejectsUnsupportedVersionAndAttributes(string attribute, string value)
+ {
+ using var stream = new MemoryStream(ManifestXml.Write(new ExtendedManifest()));
+ var doc = ManifestXml.Load(stream);
+ doc.Root!.SetAttributeValue(attribute, value);
+ Assert.Throws(() => ManifestXml.Read(doc));
+ }
+
+ [Fact]
+ public void RejectsMissingOrDuplicateNativeSections()
+ {
+ using var stream = new MemoryStream(ManifestXml.Write(new ExtendedManifest()));
+ var doc = ManifestXml.Load(stream);
+ var section = doc.Root!.Element(XName.Get("Assemblies", ManifestXml.NamespaceUri))!;
+ doc.Root.Add(new XElement(section));
+ Assert.Throws(() => ManifestXml.Read(doc));
+ doc.Root.Elements(section.Name).Remove();
+ Assert.Throws(() => ManifestXml.Read(doc));
+ }
+
+ [Fact]
+ public void NativeManifestPreservesUncapturedApiScope()
+ {
+ using var stream = new MemoryStream(ManifestXml.Write(new ExtendedManifest()));
+ Assert.Null(ManifestXml.Read(stream).CustomApis);
+ }
+
+ [Fact]
+ public void PacSidecarAttachKeepsEveryOtherZipEntry()
+ {
+ using var temp = new TemporaryDirectory();
+ SolutionPackage.Extract(Package, temp.Path, false);
+ var output = Path.Combine(temp.Path, "packed.zip");
+ SolutionPackage.Attach(Package, temp.Path, output, false);
+ using var original = ZipFile.OpenRead(Package);
+ using var attached = ZipFile.OpenRead(output);
+ foreach (var entry in original.Entries.Where(e => !string.Equals(e.FullName, SolutionPackage.ManifestFileName, StringComparison.Ordinal)))
+ {
+ using var a = entry.Open();
+ using var b = attached.GetEntry(entry.FullName)!.Open();
+ using var aa = new MemoryStream();
+ using var bb = new MemoryStream();
+ a.CopyTo(aa);
+ b.CopyTo(bb);
+ Assert.Equal(aa.ToArray(), bb.ToArray());
+ }
+
+ Assert.Single(attached.Entries, e => string.Equals(e.FullName, SolutionPackage.ManifestFileName, StringComparison.Ordinal));
+ Assert.Equal(ManifestXml.Write(SolutionPackage.Read(Package)), ManifestXml.Write(SolutionPackage.Read(output)));
+ Assert.Throws(() => SolutionPackage.Attach(Package, temp.Path, output, false));
+ }
+
+ [Fact]
+ public void FreshExportCanBeExtendedUnpackedAndReattachedUsingOnlyOurFormat()
+ {
+ using var temp = new TemporaryDirectory();
+ var fresh = NativeFixture.CreatePackage(temp.Path);
+ Assert.Throws(() => SolutionPackage.Read(fresh));
+ var extended = Path.Combine(temp.Path, "extended.zip");
+ SolutionPackage.Attach(fresh, NativeFixture.Manifest(), extended, false);
+ var sidecar = Path.Combine(temp.Path, "unpacked");
+ SolutionPackage.Extract(extended, sidecar, false);
+ var repacked = Path.Combine(temp.Path, "repacked.zip");
+ SolutionPackage.Attach(fresh, sidecar, repacked, false);
+ Assert.Equal(ManifestXml.Write(NativeFixture.Manifest()), ManifestXml.Write(SolutionPackage.Read(repacked)));
+ Assert.Empty(Reconciliation.Compare(SolutionPackage.Read(extended), SolutionPackage.Read(repacked), "pre-import").Actions);
+ Assert.Empty(Reconciliation.Compare(SolutionPackage.Read(extended), SolutionPackage.Read(repacked), "post-import").Actions);
+ }
+
+ [Fact]
+ public void CanReplaceZipInPlaceExplicitly()
+ {
+ using var temp = new TemporaryDirectory();
+ var zip = Path.Combine(temp.Path, "test.zip");
+ File.Copy(Package, zip);
+ var manifest = SolutionPackage.Read(zip);
+ SolutionPackage.Attach(zip, manifest, zip, true);
+ Assert.Single(SolutionPackage.Read(zip).Assemblies);
+ }
+
+ [Fact]
+ public void UncapturedApiScopeDoesNotDeleteCustomApis()
+ {
+ using var archive = ZipFile.OpenRead(Package);
+ using var original = archive.GetEntry(SolutionPackage.ManifestFileName)!.Open();
+ var doc = ManifestXml.Load(original);
+ doc.Root!.Elements().Single(e => string.Equals(e.Name.LocalName, "CustomApis", StringComparison.Ordinal)).Remove();
+ var source = ManifestXml.Read(doc);
+ Assert.Null(source.CustomApis);
+ var target = new ExtendedManifest
+ {
+ CustomApis = [new(Guid.NewGuid(), "api")],
+ };
+ Assert.Empty(Reconciliation.Compare(source, target, "pre-import").Actions);
+ using var written = new MemoryStream(ManifestXml.Write(source));
+ Assert.Null(ManifestXml.Read(written).CustomApis);
+ }
+
+ [Theory]
+ [InlineData("]>&y;")]
+ [InlineData("")]
+ [InlineData("")]
+ public void RejectsUnsafeOrInvalidXml(string xml)
+ {
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml));
+ Assert.ThrowsAny(() => ManifestXml.Read(stream));
+ }
+
+ [Fact]
+ public void RejectsUnknownFieldsAndDuplicates()
+ {
+ using var stream = new MemoryStream(ManifestXml.Write(SolutionPackage.Read(Package)));
+ var doc = ManifestXml.Load(stream);
+ doc.Root!.Add(new XElement("unknown", "value"));
+ Assert.Throws(() => ManifestXml.Read(doc));
+ var m = SolutionPackage.Read(Package);
+ m.Assemblies.Add(m.Assemblies[0]);
+ Assert.Throws(() => m.Validate());
+ }
+
+ [Fact]
+ public void RejectsDuplicateZipManifestEntries()
+ {
+ using var temp = new TemporaryDirectory();
+ var zip = Path.Combine(temp.Path, "test.zip");
+ File.Copy(Package, zip);
+ using (var archive = ZipFile.Open(zip, ZipArchiveMode.Update))
+ {
+ archive.CreateEntry("ExtendedSolution.xml");
+ }
+
+ Assert.Throws(() => SolutionPackage.Read(zip));
+ }
+
+ [Fact]
+ public void CliOfflineCommandsRoundTripNativeMetadata()
+ {
+ using var temp = new TemporaryDirectory();
+ var fresh = NativeFixture.CreatePackage(temp.Path);
+ var manifest = Path.Combine(temp.Path, SolutionPackage.ManifestFileName);
+ File.WriteAllBytes(manifest, ManifestXml.Write(NativeFixture.Manifest()));
+ var extended = Path.Combine(temp.Path, "extended.zip");
+ Assert.Equal(0, Cli.Run(["attach", "--zip", fresh, "--manifest", manifest, "--output", extended]));
+ var sidecar = Path.Combine(temp.Path, "sidecar");
+ Assert.Equal(0, Cli.Run(["extract", "--zip", extended, "--folder", sidecar]));
+ var plan = Path.Combine(temp.Path, "plan.json");
+ Assert.Equal(0, Cli.Run(["plan", "--source", extended, "--target", sidecar, "--phase", "post-import", "--output", plan]));
+ Assert.Contains("\"Actions\": []", File.ReadAllText(plan), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ParserRejectsTyposDuplicatesAndMissingValues()
+ {
+ Assert.Throws(() => Cli.Parse(["--environment"], ["environment"]));
+ Assert.Throws(() => Cli.Parse(["--aply"], ["apply"]));
+ Assert.Throws(() => Cli.Parse(["--apply", "--apply"], ["apply"]));
+ Assert.Throws(() => Cli.Parse(["extra"], []));
+ Assert.Null(Cli.Parse(["--apply"], ["apply"])["apply"]);
+ }
+}
diff --git a/tests/SolutionExtender.Tests/NativeFixture.cs b/tests/SolutionExtender.Tests/NativeFixture.cs
new file mode 100644
index 0000000..d48b729
--- /dev/null
+++ b/tests/SolutionExtender.Tests/NativeFixture.cs
@@ -0,0 +1,39 @@
+using System.IO.Compression;
+using System.Xml.Linq;
+using SolutionExtender;
+
+namespace SolutionExtender.Tests;
+
+internal static class NativeFixture
+{
+ private static string ManifestPath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "ExtendedSolution.xml");
+
+ public static ExtendedManifest Manifest() => SolutionPackage.Read(ManifestPath);
+
+ // A fresh PAC-style export, with no extended metadata unless explicitly attached by our tool.
+ public static string CreatePackage(string folder, bool extended = false)
+ {
+ var zip = Path.Combine(folder, "export.zip");
+ using (var archive = ZipFile.Open(zip, ZipArchiveMode.Create))
+ {
+ Write("solution.xml", "Example0");
+ var customizations = new XElement("ImportExportXml", new XElement("Entities", new XElement("Entity", new XElement("SavedQueries", Manifest().States.Select(s => new XElement("savedquery", new XElement("isprivate", 0), new XElement("isdefault", 0), new XElement("savedqueryid", s.Id)))))));
+ Write("customizations.xml", customizations.ToString());
+ Write("WebResources/ctx_example.js", "console.log('example');");
+ using var binary = archive.CreateEntry("PluginAssemblies/Example.dll").Open();
+ binary.Write([0, 1, 2, 255]);
+ void Write(string name, string text)
+ {
+ using var writer = new StreamWriter(archive.CreateEntry(name).Open());
+ writer.Write(text);
+ }
+ }
+
+ if (extended)
+ {
+ SolutionPackage.Attach(zip, ManifestPath, zip, overwrite: true);
+ }
+
+ return zip;
+ }
+}
diff --git a/tests/SolutionExtender.Tests/ReconciliationTests.cs b/tests/SolutionExtender.Tests/ReconciliationTests.cs
new file mode 100644
index 0000000..ff6a5e7
--- /dev/null
+++ b/tests/SolutionExtender.Tests/ReconciliationTests.cs
@@ -0,0 +1,95 @@
+using SolutionExtender;
+using Xunit;
+
+namespace SolutionExtender.Tests;
+
+public sealed class ReconciliationTests
+{
+ [Fact]
+ public void UsesComponentIdentityRulesAndChildFirstOrder()
+ {
+ var source = new ExtendedManifest
+ {
+ Assemblies = [C("assembly")],
+ PluginTypes = [C("type")],
+ PluginSteps = [C("step")],
+ PluginImages = [C("image")],
+ CustomApis = [C("api")],
+ };
+ var oldStep = C("step");
+ var oldImage = C("image");
+ var target = new ExtendedManifest
+ {
+ Assemblies = [C("assembly"), C("old assembly")],
+ PluginTypes = [C("type"), C("old type")],
+ PluginSteps = [oldStep],
+ PluginImages = [oldImage],
+ CustomApis = [C("api"), C("old api")],
+ };
+ var plan = Reconciliation.Compare(source, target, "pre-import");
+ Assert.Equal(new[] { "customapi", "sdkmessageprocessingstepimage", "sdkmessageprocessingstep", "plugintype", "pluginassembly" }, plan.Actions.Select(a => a.LogicalName));
+ Assert.Equal(oldStep.Id, plan.Actions[2].Id);
+ Assert.Equal(oldImage.Id, plan.Actions[1].Id);
+ }
+
+ [Fact]
+ public void PostDeactivatesObsoleteWorkflowsAndRestoresStates()
+ {
+ var kept = C("flow");
+ var obsolete = C("flow");
+ var view = Guid.NewGuid();
+ var source = new ExtendedManifest
+ {
+ Workflows = [kept],
+ WebResources = [C("resource")],
+ States = [new(view, "savedquery", 1, 2)],
+ };
+ var target = new ExtendedManifest
+ {
+ Workflows = [kept, obsolete],
+ WebResources = [C("resource"), C("old resource")],
+ States = [new(view, "savedquery", 0, 1)],
+ };
+ var actions = Reconciliation.Compare(source, target, "post-import").Actions;
+ Assert.Equal(new[] { "delete", "set-state", "delete", "set-state" }, actions.Select(a => a.Kind));
+ Assert.Equal(obsolete.Id, actions[1].Id);
+ Assert.Equal(0, actions[1].StateCode);
+ Assert.Equal(1, actions[1].StatusCode);
+ Assert.Equal(view, actions[3].Id);
+ Assert.Equal(1, actions[3].StateCode);
+ }
+
+ [Fact]
+ public void IdenticalSnapshotsProduceNoActions()
+ {
+ var m = NativeFixture.Manifest();
+ Assert.Empty(Reconciliation.Compare(m, m, "pre-import").Actions);
+ Assert.Empty(Reconciliation.Compare(m, m, "post-import").Actions);
+ }
+
+ [Fact]
+ public void MissingStateTargetFailsPlanning()
+ {
+ var source = new ExtendedManifest
+ {
+ States = [new(Guid.NewGuid(), "workflow", 1, 2)],
+ };
+ Assert.Throws(() => Reconciliation.Compare(source, new(), "post-import"));
+ }
+
+ [Fact]
+ public void ExplicitlyEmptyCustomApisMeansDeleteAll()
+ {
+ var target = new ExtendedManifest
+ {
+ CustomApis = [C("obsolete")],
+ };
+ var source = new ExtendedManifest
+ {
+ CustomApis = [],
+ };
+ Assert.Single(Reconciliation.Compare(source, target, "pre-import").Actions);
+ }
+
+ private static Component C(string name) => new(Guid.NewGuid(), name);
+}
diff --git a/tests/SolutionExtender.Tests/SolutionExtender.Tests.csproj b/tests/SolutionExtender.Tests/SolutionExtender.Tests.csproj
new file mode 100644
index 0000000..96acf00
--- /dev/null
+++ b/tests/SolutionExtender.Tests/SolutionExtender.Tests.csproj
@@ -0,0 +1,10 @@
+
+ net10.0falsetrue
+
+
+
+ all
+
+
+
+
diff --git a/tests/SolutionExtender.Tests/TemporaryDirectory.cs b/tests/SolutionExtender.Tests/TemporaryDirectory.cs
new file mode 100644
index 0000000..46049f0
--- /dev/null
+++ b/tests/SolutionExtender.Tests/TemporaryDirectory.cs
@@ -0,0 +1,17 @@
+using System.IO.Compression;
+using System.Text;
+using System.Xml.Linq;
+using SolutionExtender;
+using SolutionExtender.Tool;
+using Xunit;
+
+namespace SolutionExtender.Tests;
+
+internal sealed class TemporaryDirectory : IDisposable
+{
+ public TemporaryDirectory() => Directory.CreateDirectory(Path);
+
+ public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "solutionextender-" + Guid.NewGuid());
+
+ public void Dispose() => Directory.Delete(Path, true);
+}