dvspec 1.0.3
dotnet tool install --global dvspec --version 1.0.3
dotnet new tool-manifest
dotnet tool install --local dvspec --version 1.0.3
#tool dotnet:?package=dvspec&version=1.0.3
nuke :add-package dvspec --version 1.0.3
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: removals require an explicit prune: block and --allow-destroy.
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.5
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
credentials, secrets, or tokens into project files, and never prints them. Browser and device-code authentication
use DataverseConnection's operating-system token cache, scoped by the normalized environment URL, so subsequent
runs can sign in silently.
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
Run dvspec init to create .dvspec/config.json with the environment, solution, publisher prefix,
and label language. Then create spec.yaml containing the desired tables, columns, choices, views,
forms, and relationships. Write logical names without the publisher prefix — the tool prepends it
(table project → acme_project). Schema names default
to PascalCase (project_task → acme_ProjectTask) and can be overridden with a PascalCase
schemaName. Lean on healthy defaults and supply only the fields that carry intent. Run
dvspec spec-help for the complete field reference and an example.
version: 1
choices:
- name: priority
displayName: Priority
options:
- { value: 100000000, label: Low }
- { value: 100000001, label: High }
tables:
- name: project
displayName: Project
displayCollectionName: Projects
description: Projects managed by the delivery team
ownership: user
columns:
- { name: budget, displayName: Budget, description: Approved project budget, type: money, min: 0, max: 1000000 }
- 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 }
- name: category
displayName: Category
displayCollectionName: Categories
relationships:
- { kind: manyToMany, name: project_categories, tableA: project, tableB: category }
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
header:
controls:
- field: name
- field: status
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
label: Customer contact
- subgrid:
relationship: project_categories
label: Categories
target: category
recordsPerPage: 10
autoExpand: true
enableQuickFind: true
enableChartPicker: false
showChart: false
# viewId: <existing savedquery GUID>
# viewIds: [<allowed savedquery GUID>, ...]
relationshipRoleOrdinal: 1
# Filtered grids can also set filterRelationshipName,
# dependentAttributeName and dependentAttributeType.
# parameters: { MaxRowsBeforeScroll: "50" }
JSON specs with an identical schema are also accepted. Table, primary-name, and column schemaName
overrides must be PascalCase and contain only letters and digits. Form tab, section, field-control, and subgrid technical names are generated from sanitized readable paths:
General becomes tab_general, Details beneath it becomes tab_general_section_details, and a Categories
subgrid in that section becomes subgrid_general_details_categories. Field cells use the column display name
rather than its schema name and can override it with label. The raw: true escape hatch applies only to views
and forms, where it supplies verbatim FetchXML/FormXML. Unsupported properties are rejected instead of ignored.
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 spec-help # show supported spec.yaml fields, defaults, and example
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] [--allow-destroy]
dvspec preview [--port 5787] [--allow-apply]
dvspec apply [--spec spec.yaml] [--yes] [--json] [--dry-run] [--allow-destroy]
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 network-offline: it reads project coordinates from .dvspec/config.json, but does not load
connection credentials 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
(category → categories, box → boxes, 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 1.0.1
./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:
- 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
- repository owner:
- Add a repository Actions secret named
NUGET_USERcontaining the NuGet.org account username. No API-key secret is stored in GitHub;NuGet/login@v1exchanges the GitHub OIDC token for a short-lived key. - 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
Deletion is explicit. Names in prune are authored without the publisher prefix:
prune:
tables: [obsolete_table]
columns: [project.legacy_code]
relationships: [project_categories]
views:
- { table: project, name: Legacy View }
forms:
- { table: project, id: 00000000-0000-0000-0000-000000000000 }
A view/form may be selected by name when unique or by id when duplicate names exist. Relationships can be
pruned directly by their unprefixed schema name.
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 explicit and gated. A prune: block without --allow-destroy is suppressed with a warning
and performs no deletion; with the flag, existing listed tables, columns/lookups, choices/options, relationships, views, and forms
are deleted. Table deletion uses Dataverse's table delete operation, which removes table-owned metadata and fails
with the platform dependency error if an external component still blocks deletion. 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 | Versions 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. |
This package has no dependencies.