dvspec 1.0.1

dotnet tool install --global dvspec --version 1.0.1
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local dvspec --version 1.0.1
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=dvspec&version=1.0.1
                    
nuke :add-package dvspec --version 1.0.1
                    

XrmMetadata (dvspec)

A CLI that applies sparse, declarative schema changes to a Microsoft Dataverse solution. You author a desired-state spec (YAML/JSON); the tool fetches current definitions, computes a minimal diff, previews it, checks nobody else changed the touched objects since planning, and then applies only the necessary changes inside a target unmanaged solution.

It only ever writes the properties in the diff — it never round-trips and re-saves a whole object, so it can't clobber schema it doesn't manage. Re-running an already-applied spec produces an empty plan. See .conducktor/SPEC.md for the full specification.

What it can change

Tables, columns (text/memo/integer/decimal/money/boolean/datetime/choice/lookup), global & local choices, relationships (1:N, N:1, N:N), views (savedquery, authored via a DSL that compiles to FetchXML + LayoutXML), and forms (systemform, authored via a DSL that sparse-merges into the existing FormXML). It does not touch business data, managed solutions, or plugins/workflows/web resources (forms that use scripts round-trip intact but aren't authored by the tool). Destructive changes are never inferred. prune: is parsed but deletion execution is not shipped; --allow-destroy is rejected before an unexecutable plan can be generated.

Requirements

  • .NET 10 SDK (the project targets net10.0).
  • A supported Dataverse credential: interactive browser (default), device code, Azure CLI, client secret, or certificate.
  • Access to a Dataverse environment and an unmanaged solution to target. The signed-in principal needs System Customizer (or System Administrator) with AddSolutionComponent/publish rights.

Build & run

Clone, then build:

dotnet build

During development, run the CLI through dotnet run (everything after -- is passed to dvspec):

dotnet run --project src/XrmMetadata -- <command> [options]
# e.g.
dotnet run --project src/XrmMetadata -- plan --json

To get a plain dvspec binary you can put on your PATH:

dotnet publish src/XrmMetadata -c Release -o ./dist
./dist/dvspec plan --json

The examples below use dvspec … for brevity — substitute dotnet run --project src/XrmMetadata -- … if you haven't published.

Getting started

1. Configure the Dataverse connection

dvspec uses DataverseConnection 1.2.4 with the same IConfiguration + AddDataverseWithOrganizationServices() registration style as XrmSync. It searches upward from the working directory for the nearest appsettings.json, optionally layers appsettings.<env>.json, and finally applies environment variables. Environment variables always win.

{
  "DATAVERSE_URL": "https://org899a6fb0.crm4.dynamics.com",
  "DATAVERSE_CREDENTIAL_TYPE": "browser"
}

Supported credential types are browser (default), deviceCode, azcli, clientSecret, and certificate (also accepted as clientCertificate):

Type Additional configuration
browser none; interactive browser sign-in
deviceCode none; interactive device-code sign-in
azcli an existing az login session
clientSecret AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET
certificate AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_CERTIFICATE_PATH; optional AZURE_CLIENT_CERTIFICATE_PASSWORD

Keep secrets in environment variables or your CI secret provider, not in JSON. dvspec never writes or prints credentials, secrets, or tokens, and disables persistent token caching for its browser and device-code credentials.

For a development override, add appsettings.dev.json and pass --env dev. Without --env, the optional override name is Production. For one transition period, an existing ~/.dvspec/profiles.json entry is used only when no DATAVERSE_URL is found; this emits a deprecation warning to stderr.

2. Initialize the project

Point a project at a solution. This writes .dvspec/config.json and validates that the solution exists, is unmanaged, and that the prefix matches its publisher (warns on mismatch):

dvspec init --env dev --solution acme_core --prefix acme --language 1033

--env selects the optional appsettings.<env>.json override; omit it to use Production. --prefix is your 2–8-char publisher prefix; --language is the default LCID for labels (v1 authors labels in a single base language).

3. Author a spec

Create spec.yaml. Write all names without the publisher prefix — the tool prepends it (table projectacme_project, column nameacme_name). Lean on healthy defaults and supply only the fields that carry intent:

version: 1
target:
  environment: dev
  solution: acme_core
  publisherPrefix: acme
  language: 1033

choices:
  - name: priority
    displayName: Priority
    options:
      - { value: 100000000, label: Low }
      - { value: 100000001, label: High }

tables:
  - name: project
    displayName: Project
    displayCollectionName: Projects
    ownership: user
    columns:
      - { name: budget, displayName: Budget, type: money }
      - name: status
        displayName: Status
        type: choice
        options:
          - { value: 1, label: Planned }
          - { value: 2, label: Active }
          - { value: 3, label: Closed }
      - { name: priority, displayName: Priority, type: choice, choice: priority }
      - { name: owner_contact, displayName: Owner Contact, type: lookup, target: contact }

views:
  - name: Active Projects
    table: project
    type: public
    default: true
    columns:
      - { attribute: name, width: 300 }
      - { attribute: status, width: 120 }
      - { attribute: budget, width: 120 }
    sort: [ { attribute: name, direction: asc } ]
    filter:
      operator: and
      conditions:
        - { attribute: statecode, operator: eq, value: 0 }

forms:
  - name: Project Main
    table: project
    type: main
    default: true
    tabs:
      - label: General
        columns: 2
        sections:
          - label: Details
            column: 1
            controls:
              - field: name
              - field: status
              - field: priority
          - label: Related
            column: 2
            controls:
              - field: budget
              - field: owner_contact

JSON specs with an identical schema are also accepted. A per-object raw: true escape hatch supplies verbatim FetchXML/FormXML when the DSL can't express something. The SPEC covers the full DSL (subgrids, relationships, all column types, defaults, and the prune: block).

Before authoring against an existing table, ground yourself so you reuse real schema names instead of inventing them:

dvspec describe contact --json     # existing columns, choice values, relationship targets

4. Validate, plan, preview

dvspec validate                    # lint the spec offline (no environment access)
dvspec plan                        # compute the diff against live state; persists the plan + baseline
dvspec preview                     # localhost UI of the plan at http://localhost:5787

plan --watch re-plans on every save and auto-launches the preview, which streams updates over SSE with no browser refresh. The preview is read-only by default; add --allow-apply to expose an apply button (the CLI remains the primary apply path). High-risk operations are visually distinguished, and each object shows healthy-default provenance (which values came from defaults vs. your spec).

5. Apply

dvspec apply --yes

apply re-checks drift against the persisted baseline first. On a clean apply it batches operations in topological order (choices → tables → columns → lookups/relationships → views/forms), issues a single publish, records a run journal, and updates the baseline to the freshly-applied state — so an immediate re-plan is empty.

If a teammate changed a touched object since you planned, apply blocks with a drift report (exit 2). Review it, then either accept current live as the new baseline and re-plan, or edit your spec:

dvspec rebase                      # accept current live as new baseline, then re-plan

Metadata ops are not transactional across requests, so on partial failure the tool does not auto-revert — it reports exactly what was applied vs. not; because planning is desired-state, a re-plan/apply converges on the remaining delta.

Command reference

dvspec init      --env dev --solution acme_core --prefix acme [--language 1033]
dvspec describe  <table> [--json]        # dump a table's resolved definition (agent authoring aid)
dvspec fetch     [--tables a,b,...]      # refresh cached definitions + re-capture the baseline
dvspec validate  [--spec spec.yaml]      # lint a spec without touching the environment
dvspec plan      [--spec spec.yaml] [--watch] [--json]
dvspec preview   [--port 5787] [--allow-apply]
dvspec apply     [--spec spec.yaml] [--yes] [--json] [--dry-run]
# --allow-destroy is reserved and currently rejected
dvspec rebase    [--spec spec.yaml]      # accept current live as the new baseline, then re-plan

Global flags: --env, deprecated alias --profile, --verbose, --dry-run.

Exit codes: 0 success · 1 validation error · 2 drift detected (apply blocked) · 3 immutable-property change required · 4 Dataverse API error · 5 auth/connection error.

--json on describe/fetch/plan/apply emits stable, machine-readable output.

The .dvspec/ state store

init and later commands maintain a per-project .dvspec/ directory:

.dvspec/
  config.json         # target solution, publisher prefix, language, appsettings environment
  baseline.json       # metadata version stamp + record ETags used for drift detection
  plan.latest.json    # last computed plan (machine-readable)
  cache/              # optional cached definitions for offline authoring (from `fetch`)

It never contains secrets. config.json is committable and shareable (teams that want to share it can force-add it — it's git-ignored by default); baseline.json, plan.latest.json, and cache/ are local working state.

Authoring with an agent

The grounding commands (describe/fetch), the machine-readable --json plan/result, healthy defaults, and the exit-code contract are designed so an agent (Claude Code) can author, verify, and self-correct a spec against the real environment. The intended loop — and the MCP-shaped JSON contract behind it — is documented in docs/AGENT.md.

Offline resolved-schema JSON contract

spec.yaml is the canonical schema source for offline consumers. Do not duplicate its YAML parsing, publisher-prefix resolution, healthy defaults, lookup/choice resolution, FetchXML compilation, or FormXML compilation. Invoke:

dvspec resolve --spec spec.yaml --json

resolve is entirely offline: it does not read .dvspec/config.json, load connection configuration, or contact Dataverse. With --json, stdout contains only JSON and diagnostics go to stderr. Valid models return 0; invalid specifications return 1. YAML and JSON specs are supported and identical input produces byte-identical output.

The top-level schemaVersion is the compatibility version of this integration contract. Version 1 contains target, choices, tables, relationships, views, forms, externalTables, and structured warnings. Fields may be added compatibly within a version; removals, renames, or semantic changes require a new version. Column type, requirement, ownership, relationship, view, and form values are stable lowercase DSL values—not C# enum or type names. Values supplied by healthy defaults carry { "source": "default" }; authored values carry { "source": "spec" }.

A declared table may set entitySetName. Otherwise resolve and apply share a deterministic pluralization rule (categorycategories, boxboxes, otherwise append s). Set entitySetName explicitly for irregular or organization-specific names.

External/system tables referenced by lookups, plus tables explicitly listed in the authored spec, are emitted under externalTables. For example:

externalTables:
  - account
  - contact

These tables are reference-only: plan/apply never creates or modifies them. Default fetch includes them. Without cache they are marked complete: false and a structured externalTableIncomplete warning is emitted; no columns or relationships are invented. Run dvspec fetch --tables account,contact to populate .dvspec/cache and enrich subsequent offline resolution with real entity-set, primary-key/name, column, choice, lookup, and relationship metadata.

Local .NET tool

dotnet pack src/XrmMetadata/XrmMetadata.csproj -c Release -o artifacts
dotnet tool install dvspec --tool-path ./tools --add-source ./artifacts --version 0.1.0
./tools/dvspec --version
./tools/dvspec --help

The NuGet package ID and tool command are both dvspec; the preview SPA remains embedded in the tool package.

Publishing the tool

The publish dotnet tool GitHub Actions workflow publishes to NuGet.org when a GitHub Release is published. The release tag is the package version and must use SemVer with a leading v, for example v0.2.0 or v0.2.0-rc.1. The workflow checks out that tag, restores, builds, tests, packs, verifies a local tool installation, uploads the .nupkg as a workflow artifact, and then pushes it to NuGet.org.

Repository setup required:

  1. In the NuGet.org account that owns dvspec, create a Trusted Publishing policy for GitHub Actions with:
    • repository owner: context-and
    • repository: XrmMetadata
    • workflow file: publish-tool.yml
    • environment: leave unspecified
  2. Add a repository Actions secret named NUGET_USER containing the NuGet.org account username. No API-key secret is stored in GitHub; NuGet/login@v1 exchanges the GitHub OIDC token for a short-lived key.
  3. Create and publish a GitHub Release whose tag matches the desired package version.

The workflow has only contents: read and id-token: write permissions. NuGet package versions are immutable, so each release tag must use a new version. Draft releases do not publish.

Dry-run safety and destructive changes

dvspec apply --dry-run follows the normal validation, connection, drift-check, live-read, and fresh-plan path, but never sends a write batch, publishes, confirms, or updates the baseline. JSON output is { "status": "dryRun", "plan": { ... } }; human output explicitly says that nothing was written. A preview started with global --dry-run does not expose its optional apply endpoint. Commands without meaningful dry-run semantics reject the flag instead of silently ignoring it.

Destructive apply is intentionally not shipped in this version. A prune: block without --allow-destroy is suppressed with a warning and performs no deletion. Passing --allow-destroy is rejected during planning rather than producing a plan that apply cannot execute. No deletion is ever inferred from absence.

Dependency audit

The Dataverse client currently brings System.Security.Cryptography.Xml through WCF. The project pins the patched .NET 10 package line (10.0.11); clean restore auditing remains enabled and produces no high-severity warnings. The vulnerable 8.0.2 assembly is not present in build or publish output.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
1.0.1 95 8/23/2026
1.0.0 97 8/21/2026