XrmFramework.Cli
3.1.121
dotnet tool install --global XrmFramework.Cli --version 3.1.121
dotnet new tool-manifest
dotnet tool install --local XrmFramework.Cli --version 3.1.121
#tool dotnet:?package=XrmFramework.Cli&version=3.1.121
nuke :add-package XrmFramework.Cli --version 3.1.121
XrmFramework.Cli
The XrmFramework CLI, distributed as a .NET tool. It brings together the Dynamics 365 /
Dataverse development and deployment utilities behind a single command:
xrmframework.
The business logic lives in the XrmFramework.DeployUtils library;
this project is only its command-line front end (based on
Spectre.Console.Cli).
⚠️ Coming from XrmFramework 2.*? Run
migrate sync-tables(andmigrate sync-modelsif the project has hand-written binding models) once, before upgrading to 3.1+ — the project will not compile otherwise.
Installation
As a global tool
dotnet tool install --global XrmFramework.Cli
xrmframework --help
As a local tool (recommended per repository/solution)
# at the root of the consuming repository
dotnet new tool-manifest # if .config/dotnet-tools.json doesn't exist yet
dotnet tool install XrmFramework.Cli
dotnet xrmframework --help # or: dotnet tool run xrmframework -- --help
The local tool is pinned in .config/dotnet-tools.json (checked into version control),
which ensures that the whole team and CI use the same version.
From source (development)
dotnet run --project src/XrmFramework.Cli -- <command> [options]
Environment configuration
The connected commands (deploy, tables list, tables pull) target the environment
selected in the project configuration, via two files (an existing XrmFramework mechanism)
read from the Config/ folder at the project root:
| File | Role |
|---|---|
Config/xrmFramework.config |
Declares the projects and the active connection (selectedConnection). |
Config/connectionStrings.config |
Defines the named connection strings (Dataverse / On-Premises). |
selectedConnection points to an entry in connectionStrings.config: this is
the target environment. migrate sync-tables, tables columns and tables optionsets, on
the other hand, do not need a connection — they work solely from local files (and, for
migrate sync-tables, an assembly).
Automatic configuration discovery
tables list, tables pull, tables columns and tables optionsets walk up the directory
tree from the current folder until they find a Config/xrmFramework.config: the CLI can
therefore be launched from any subdirectory of the solution (including a bin/Debug).
--project-root bypasses this search. tables columns and tables optionsets never connect to
the environment, but still need this discovery to locate the .table files.
At the root thus found, the CLI reads Directory.Build.props to extract
XrmFrameworkCoreProjectName, which gives it the default .table directory:
<root>/<CoreProject>/Definitions. This is the same resolution that MSBuild injects into the
DefinitionManager. Failing that, --tables-dir becomes mandatory.
Discovery only checks for
xrmFramework.config:connectionStrings.configcarries secrets and is gitignored in generated solutions, so it is absent from a fresh clone. Its absence is reported precisely at connection time, rather than disguised as "configuration not found".
The CLI loads these two files explicitly (without relying on an application
App.config) — seeConfigHelper.UseProjectConfigandProjectConfigLocator.
Commands
xrmframework tables list ✅ (available)
Lists the tables of the selected environment. The .table column indicates which ones are
already tracked in the project — the information most needed when deciding what to fetch.
xrmframework tables list [--prefix <prefix>] [--filter <text>] [--custom-only] [--project-root <dir>]
| Option | Required | Description |
|---|---|---|
--prefix <PREFIX> |
❌ | Only keeps tables whose logical name starts with this prefix (e.g. ftp_). |
--filter <TEXT> |
❌ | Only keeps tables whose logical name or display name contains this text. |
--custom-only |
❌ | Only keeps custom tables. |
--project-root <DIR> |
❌ | Root containing Config/ (default: search upward from the current folder). |
Example
xrmframework tables list --prefix ftp_
Metadata is retrieved without attributes (EntityFilters.Entity), which makes the command
noticeably faster than a full retrieval.
xrmframework tables pull ✅ (available)
Generates or updates .table files from the environment's metadata: types, localized labels,
capabilities, bounds, relationships, alternate keys, and option sets. This is the headless
equivalent of the DefinitionManager (WinForms net462), usable in CI.
xrmframework tables pull [--table <names>] [--prefix <prefix>] [--tables-dir <dir>] [--project-root <dir>] [-n]
| Option | Required | Description |
|---|---|---|
-t, --table <NAME> |
❌ | Logical name of a table. Repeatable option that also accepts a comma-separated list. |
--prefix <PREFIX> |
❌ | Additionally fetches all tables whose logical name starts with this prefix. |
--tables-dir <DIRECTORY> |
❌ | Target directory (default: the Core project's Definitions folder, inferred from the configuration). |
--project-root <DIR> |
❌ | Root containing Config/ (default: search upward from the current folder). |
-n, --noprompt, -NoPrompt |
❌ | Silent mode: skips the confirmation (CI/CD). |
Default selection: already-tracked tables
Without --table or --prefix, pull refreshes all the tables already described by a
.table file in the target directory — a bulk update after a model change, without having
to re-enumerate the project's tables.
- The selection is read from the files, by their
LogName: a renamed.tablefile remains tracked. OptionSets.table(global option sets) is excluded — it doesn't correspond to any CRM entity, but is still populated by the tables fetched.- A
.tablefile whose entity no longer exists in the environment is reported and skipped, without interrupting the others; the file is not deleted. - If the directory contains no
.tablefiles, the command stops with code1before connecting: there's nothing to fetch, so there's no point authenticating for nothing.
Examples
xrmframework tables pull --noprompt
xrmframework tables pull --table account,ftp_contrat --noprompt
Column selection
When a .table is created, only the directly usable columns are activated
(Select: true):
- the primary key, the name column, and the image column (
PrimaryType); - columns participating in an alternate key;
createdon,modifiedon,statecode,statuscode.
All other columns are indeed written with their full metadata, but remain inactive — this
avoids generating thousands of useless constants. Activating one is a deliberate act: set
Select: true in the .table (via the DefinitionManager, by hand, or with the upcoming
tables columns). On a project coming from 2.*, the initial activation is done in bulk by the
migrate sync-tables migration, which reads it from the existing code.
Merge rules for an existing file
What becomes a C# identifier belongs to the file; what describes the table belongs to the CRM.
| Element | Source of truth |
|---|---|
Name (table, column, key, option set and its members) |
the file — manually renamed, compiled code depends on it |
Select |
the file — never downgraded |
Locked |
the file — local marker, absent from the CRM |
Type, PrimaryType, Capa, Labels, StrLen, MinRange, MaxRange, DatBehav, IsMultiSelect, EnumName, relationships |
the CRM |
Other guarantees:
- A column already selected stays selected.
pullnever downgrades aSelect: true, and never re-activates a column that was deliberately deactivated — includingcreatedonand the like, even though they are activated by default on creation. This guarantee is verified end-to-end (metadata → merge → write → re-read) byTablePullPersistenceTests. - The target file is located by its
LogName, not by its file name: a table whoseNamewas manually renamed (Contract.table→ContractLocation.table) is correctly updated instead of being duplicated — the selection also survives this renaming. - A column present in the file but absent from the environment is kept and reported.
pullrefreshes, it does not destroy; deselecting orphaned columns is a separate decision (migrate sync-tables --cleandoes it during a 2.* migration). - Global option sets are merged in a purely additive way in
OptionSets.table: fetching a single table never removes the ones referenced by others. - The operation is idempotent: a second
pullon the same table produces an empty diff.
Exit codes (common to list and pull)
| Code | Meaning |
|---|---|
0 |
Success (including cancellation at the confirmation prompt). |
1 |
No table matches the criteria. |
2 |
Configuration or directory not found. |
3 |
Unexpected error, or at least one table failed. |
-1 |
Argument validation error (Spectre). |
Implementation: CrmTableHelper
→ ProjectConfigLocator
xrmframework tables columns ✅ (available) — local edits to .table files
Activates or adjusts columns already present in a .table file without going through either
an assembly (migrate sync-tables) or the environment (tables pull) — entirely offline,
reading and writing only via TableFileStore.
Three verbs:
xrmframework tables columns list [--table <names>] [--prefix <prefix>] [--filter <text>] [--unselected-only]
xrmframework tables columns add --table <names> | --prefix <prefix> --column <names> | --all [-n]
xrmframework tables columns set --table <name> --column <name> [--name <newname>] [--select | --deselect]
All three also accept --tables-dir <DIRECTORY> and --project-root <DIR>, same meaning as on
tables pull.
list — see what's already tracked
Prints, per table, every column the .table file already knows about (tables pull writes
the full metadata for all columns, selected or not — see
Column selection). This is how you find the logical names to pass to add.
| Option | Required | Description |
|---|---|---|
-t, --table <NAME> |
❌ | Table to inspect. Repeatable, comma-separated. Default: every table already tracked (having a .table file). |
--prefix <PREFIX> |
❌ | Also inspects every tracked table whose logical name starts with this prefix. |
--filter <TEXT> |
❌ | Only keeps columns whose logical name or C# name contains this text. |
--unselected-only |
❌ | Only keeps columns not yet activated — the candidates for add. |
xrmframework tables columns list --table ftp_contrat --unselected-only
add — activate columns
Sets Select: true on the requested columns. Unlike list, it mutates files, so it never
defaults to the whole project: --table or --prefix is required, and so is --column or
--all.
| Option | Required | Description |
|---|---|---|
-t, --table <NAME> |
⚠️ | Table(s) to edit. Repeatable, comma-separated. Required unless --prefix is given. |
--prefix <PREFIX> |
⚠️ | Also edits every tracked table whose logical name starts with this prefix. |
-c, --column <NAME> |
⚠️ | Column(s) to activate. Repeatable, comma-separated. Required unless --all is given. |
--all |
⚠️ | Activates every column not yet selected, instead of an explicit --column list. |
-n, --noprompt |
❌ | Silent mode: skips the confirmation (CI/CD). |
A column already selected is left untouched (no-op, not an error); a requested column absent from the file is reported and the others still proceed.
xrmframework tables columns add --table ftp_contrat --column ftp_datedebut,ftp_datefin --noprompt
# Activate the same audit column across every ftp_-prefixed table already tracked.
xrmframework tables columns add --prefix ftp_ --column createdby
set — rename or toggle a single column
Renames a column's C# Name and/or flips its Select flag. --table accepts either the
logical name or the file's C# Name, for convenience.
| Option | Required | Description |
|---|---|---|
-t, --table <NAME> |
✅ | Table to edit (logical name or C# name). |
-c, --column <NAME> |
✅ | Logical name of the column to edit. |
--name <NEWNAME> |
❌ | Renames the column's C# name. Rejected if another column in the same table already has that name. |
--select |
❌ | Activates the column. Mutually exclusive with --deselect. |
--deselect |
❌ | Deactivates the column. Mutually exclusive with --select. |
At least one of --name, --select or --deselect is required.
xrmframework tables columns set --table ftp_contrat --column ftp_datefin --name DateFinContrat
Exit codes (all three verbs)
| Code | Meaning |
|---|---|
0 |
Success (including "nothing to do": already in the requested state). |
1 |
No table or column matches the criteria. |
2 |
Configuration or .table directory not found. |
3 |
Unexpected error, or set --name collides with another column's C# name. |
-1 / 255 |
Argument validation error (Spectre). |
Implementation: ColumnHelper
→ TableFileStore
xrmframework tables edit ✅ (available) — full-screen interactive editor
The interactive counterpart of tables columns add/set/pull: a full-screen, keyboard-driven
console UI (Terminal.Gui) over the locally tracked
.table files, for when you'd rather browse than remember exact table/column logical names.
Editing is entirely offline, same TableFileStore
as every other tables command underneath; pulling (P) is the one thing that talks to the
environment, and does so exactly like tables pull on the command line.
xrmframework tables edit [--tables-dir <DIRECTORY>] [--project-root <DIR>]
Tables tracked locally on the left, the columns of whichever one is selected on the right — the
left pane starts empty on a brand-new project, P is all it takes to populate it:
| Key | Action |
|---|---|
↑ / ↓, Tab |
Navigate / switch pane |
Space, Enter |
Toggle the selected column's Select flag |
R |
Rename the selected column's C# name |
O |
Edit the option set the selected column is tied to (Picklist, State, Status...) |
P |
Pull from the environment — update tracked tables, or import new ones |
/ |
Filter — tables from the Tables pane, columns from the Columns pane |
Esc, Q |
Quit |
P asks which of the two: update tracked re-pulls every table already tracked (tables pull
with no criteria), import new shows every table in the environment (flagging what's already
tracked, like tables list) and prompts for the logical name(s) to add. Either way, the screen
exits for the duration of the pull — it is a network call with its own confirmation prompt and
progress output, already built on the normal scrolling console in
CrmTableHelper — then reopens
afterward over whatever landed on disk.
/ narrows whichever pane it was pressed from to logical names or C# names containing the text
typed (same substring match as tables list --filter / tables columns list --filter); an empty
answer clears it. A column filter stays applied across a table switch — handy for scanning several
tables for, say, every "email"-ish column — and the frame title always shows how many rows the
filter is hiding relative to the full count. A filter never hides a real duplicate-name conflict:
R still checks every column in the table, filtered out or not.
Every toggle or rename is validated (same duplicate-name rule as columns set --name) and saved
to disk immediately — there is no separate save step.
O on a column with no option set, or one never pulled locally (no matching entry under any
tracked .table's Enums), reports why instead of opening anything. Otherwise it opens a second
screen — the interactive counterpart of tables optionsets set — over that option set's members:
| Key | Action |
|---|---|
↑ / ↓ |
Navigate members |
Enter, R |
Rename the selected member's C# name |
N |
Rename the option set's own C# name |
Esc, Q |
Close, back to the columns screen |
A global option set can be declared in several .table files at once; a rename here reaches
every copy the same way tables optionsets set does, skipping (and reporting) any copy marked
Locked — its name belongs to the framework package's own generated code.
Requires a real terminal (not redirected output); on an unsupported terminal, prefer the
non-interactive tables columns/tables optionsets commands.
Implementation: TableEditorApp / TableEditorWindow / OptionSetEditorWindow
→ TableFileStore
xrmframework tables optionsets ✅ (available) — rename option sets and their members
The companion of tables columns for option sets: renames an option set's C# name and/or one
of its member's name in a .table file — entirely offline, same as tables columns. Two verbs:
xrmframework tables optionsets list [--option <logicalname>] [--filter <text>] [--global-only]
xrmframework tables optionsets set --option <logicalname> [--name <newname>] [--value <n> --value-name <newname>]
Both also accept --tables-dir <DIRECTORY> and --project-root <DIR>, same meaning as on
tables pull.
Why a rename must reach every copy
An option set's logical name is unique, but its declaration is not: the historical
DefinitionManager kept in a table's own Enums every option set one of its columns
referenced — global ones included — while also writing the globals to OptionSets.table (see
Merge rules for an existing file). A global option set
shared by several tables is therefore typically declared several times over. set looks it
up by logical name across every local .table file (OptionSets.table included) and renames
every copy it finds in one pass — the same reconciliation migrate sync-tables performs
when recovering names from a 2.* assembly (see
TableFileSyncer.ApplyOptionSetName).
Renaming only the first copy found would leave the others to disagree, and tables optionsets list's overview flags exactly that drift as a (mismatch).
A copy marked "Locked": true — the framework package's own option sets — is left untouched and
reported instead: its name belongs to the package's generated code.
list — see what's tracked
Without --option: one row per distinct option set found locally (logical name, C# name,
whether it's global, whether it's locked, member count, and which .table file(s) declare it).
With --option <logicalname>: the members of that one option set (value, C# name, external
value), plus the C# name as recorded by each declaring file.
| Option | Required | Description |
|---|---|---|
-o, --option <LOGICALNAME> |
❌ | Drills into that option set's members instead of the overview. |
--filter <TEXT> |
❌ | Overview only: keeps option sets whose logical name or C# name contains this text. |
--global-only |
❌ | Overview only: keeps global option sets. |
xrmframework tables optionsets list --option ftp_contrat_statut
set — rename the option set and/or one member
| Option | Required | Description |
|---|---|---|
-o, --option <LOGICALNAME> |
✅ | Option set to edit. |
--name <NEWNAME> |
❌ | Renames the option set's C# name, in every declaring file. |
--value <NUMBER> |
⚠️ | Numeric value of the member to rename. Requires --value-name. |
--value-name <NEWNAME> |
⚠️ | New C# name for the member designated by --value. |
At least one of --name or the --value/--value-name pair is required.
xrmframework tables optionsets set --option ftp_contrat_statut --name StatutContrat
xrmframework tables optionsets set --option ftp_contrat_statut --value 1 --value-name EnCours
Exit codes (both verbs)
| Code | Meaning |
|---|---|
0 |
Success (including "nothing to do": already in the requested state, or a member not found — reported, not fatal). |
1 |
The option set is not declared in any local .table file. |
2 |
Configuration or .table directory not found. |
3 |
Unexpected error. |
-1 / 255 |
Argument validation error (Spectre). |
Implementation: OptionSetHelper
→ TableFileStore
ColumnHelper(shared local file resolution).
xrmframework deploy plugins ✅ (available)
Deploys an XrmFramework assembly — plugins, custom APIs, and workflows — to the
environment selected in Config/xrmFramework.config.
xrmframework deploy plugins --dll <path.dll> --project <name> [--project-root <dir>] [--on-premise] [--noprompt]
| Option | Required | Description |
|---|---|---|
--dll <PATH> |
✅ | Plugin project assembly (net462, the one registered in Dataverse). |
--project <NAME> |
✅ | Project name as declared in xrmFramework.config (e.g. Plugins). |
--project-root <DIR> |
❌ | Root containing the Config/ folder (default: current folder). |
--on-premise |
❌ | Targets an On-Premises CRM (default: Dataverse Online). |
-n, --noprompt, -NoPrompt |
❌ | Silent mode: skips the connection confirmation (CI/CD). -NoPrompt (any casing) is kept for backward compatibility with the deployment scripts written against that spelling. |
How it works — inventory via actual code execution. A plugin is
net462, this tool isnet10.0: it therefore cannot instantiate the plugin's types itself. It delegates to theXrmFramework.PluginInventorytool (anet462executable, embedded underinventory/), which loads the assembly, executes the constructors (AddSteps), and reflects over the types, then returns the JSON manifest (plugins / steps / workflows / custom APIs) on its standard output.Consequences:
- Step registration is entirely free-form: loops, conditions, computed values, configuration… since the real code runs (no static analysis constraints).
- Deployment requires the .NET Framework runtime (Windows). For cross-platform development, a launcher can be provided via the
XRMFRAMEWORK_INVENTORY_LAUNCHERenvironment variable (e.g.mono);XRMFRAMEWORK_INVENTORY_EXEallows pointing to an alternative inventory executable.
Example
xrmframework deploy plugins --dll bin/Release/net462/MyProject.Plugins.dll \
--project MyProject.Plugins \
--noprompt
Exit codes
| Code | Meaning |
|---|---|
0 |
Success (or cancellation at the confirmation prompt). |
1 |
Project missing from xrmFramework.config. |
3 |
Unexpected error (inventory, connection, deployment…). |
255 |
Argument validation error (Spectre). |
Implementation: RegistrationHelper.RegisterPluginsAndWorkflows
→ inventory XrmFramework.PluginInventory
→ PluginInventoryReader
xrmframework migrate sync-tables ✅ (available) — migration from 2.* to 3.1+
This is a migration tool, meant to be run once, when upgrading a project from XrmFramework
2.* to 3.1 or above. It is not a routine command: afterwards, tables pull and the source
generator take over.
What changed between 2.* and 3.1
Under 2.*, the DefinitionManager wrote two files per table into the Core project's
Definitions folder: the .table and its *Definition.cs. Both were checked in, and the .cs
was a real compiled source file.
From 3.1 on, the .table is the single source of truth: the
TableSourceFileGenerator
Roslyn generator emits the *Definition class at compile time from the .table alone. The
checked-in .cs is no longer a source — it is a duplicate of generated code, and the project
does not build until it is dealt with.
migrate sync-tables performs that hand-over in one pass:
- it reflects over the assembly last compiled under 2.*, whose
*Definitionclasses record which columns the project's code actually uses, and under which name each option set is compiled; - it brings the
.tablefiles in line — creating what is missing, settingSelect: trueon every column the code references, and naming the option sets; - it cleans up the
*Definition.csfiles sitting next to them.
What travels in step 2 is precisely what the CRM cannot tell you: C# identifiers the compiled
code depends on. Everything else is metadata tables pull can fetch back at any time.
xrmframework migrate sync-tables --dll <path.dll> --tables-dir <directory> [--clean]
| Option | Required | Description |
|---|---|---|
--dll <PATH> |
✅ | Assembly compiled under 2.* (contains *Definition classes decorated with [EntityDefinition] that expose a static EntityName field). |
--tables-dir <DIRECTORY> |
✅ | Directory holding the .table and *Definition.cs files — usually <CoreProject>/Definitions. |
--clean |
❌ | Sets Select=false on orphaned columns and deletes .table files entirely generated by the tool with no CRM data. |
Example
xrmframework migrate sync-tables --dll bin/Release/net8.0/MyProject.Plugins.dll \
--tables-dir ../MyProject.Core/Definitions \
--clean
⚠️ The command deletes and renames source files in
--tables-dir(see below). Run it on a clean working tree so the whole migration shows up as a single reviewable diff.
Naming the option sets and their members
A .table records an option set's logical name, which comes from the CRM; the Name under which
it is compiled is a project decision — teams rename workflow_runas into RunAsUser
and their code depends on it. The same holds one level down: the generator derives each member's
name from its CRM label and strips the diacritics (Modèle becomes Modele), but those get
renamed too, and every MyEnum.EnCours in the project compiles against the result. Under 2.*
both lived in the generated .cs; from 3.1 on the generator reads them from the .table.
The migration recovers them from [OptionSet(typeof(SomeEnum))] carried by the column constants —
the enum's name, and its members read off the type itself — and applies them to the option set the
column points at (matched on the column's EnumName) — in every file that records it:
- in the table's own
Enums; - and in
OptionSets.table, where shared option sets live — that file is loaded once and rewritten only if a name actually changed.
Both, not the first one found. The 2.* DefinitionManager kept in a table's Enums every option
set one of its columns referenced, globals included, while also writing the globals to
OptionSets.table. The generator unions the two, so a rename applied to only one copy would be
contradicted by the other.
Members are matched on their numeric value, which is the stable CRM key — never on their
position. Labels, ExtVal, logical name and the IsGlobal flag are untouched: only Name moves.
Four cases are deliberately left alone:
| Case | Why |
|---|---|
Option set marked "Locked": true |
Shipped by the framework — its names belong to the package's generated code, members included. |
Column whose .table entry carries no EnumName |
Nothing links it to an option set. Happens for a column the migration itself just created; a tables pull fills the metadata in. |
| A member the assembly declares twice for one value | C# allows aliases, so there is no way to tell which name the .table should carry. |
| A value the assembly declares no member for | The code never referenced it; the .table keeps the name it already had. |
One subtlety the migration handles for you: when an option set allows an empty value
(HasNullValue), the generator prepends a synthetic Null = 0 member. It mirrors the flag rather
than any CRM option, so it is skipped — the real option numbered 0, if there is one, keeps its
own name.
Cleaning up the *Definition.cs files
Once the .table files are up to date, every *Definition.cs in the directory is stripped of
what the generator now emits:
- the
EntityNameandEntityCollectionNameconstants; - the nested
Columns,AlternateKeyNames,ManyToManyRelationships,ManyToOneRelationshipsandOneToManyRelationshipsclasses, together with their attributes; - the namespace-level option set
enums — only those the generator will actually re-emit, i.e. declared in a.tableand referenced by a selected column. An option set no column uses is not regenerated, so itsenumis kept.
Then, depending on what is left:
| What remains in the Definition class | Outcome |
|---|---|
| Nothing (and nothing else in the file) | the file is deleted — the generated part covers it entirely |
| Members added by hand (constants, nested classes, properties, methods) | the file becomes *Definition.partial.cs, holding only those members |
A file whose Definition class ends up empty but which still carries hand-written enums keeps
its .partial.cs, minus the now-pointless class declaration.
Realigning the surviving partial
A file that survives is also realigned on what the generator emits, otherwise it would collide with it instead of merging:
- the
partialmodifier is added if missing (without it, C# sees two distinct types); - the namespace becomes
XrmFramework— the only namespace the generator emits into; [GeneratedCode],[EntityDefinition]and[ExcludeFromCodeCoverage]are dropped from the class, since none of them allows multiple use and the generated part already carries them (CS0579). Any other attribute, such as[DefinitionManagerIgnore], is kept.
The namespace change may leave a hand-written member referring to a type that used to be found in the project's own namespace. Those are reported by the compiler as unresolved names — add the missing
usingand move on.
What the migration refuses to touch
Deleting source files calls for a conservative tool. A *Definition.cs is left exactly as it
is, and reported, when:
- no
.tablein the directory declares the matching table. The generator would produce no replacement, so removing the file would drop the definition altogether. Matching is done on theNamedeclared inside the.table, not on its file name (Systemuser.tabledeclaresSystemUser). - the file cannot be read reliably — unbalanced braces, a construct the scanner does not
bracket confidently, or no class matching the file name. That last match ignores casing
(
contactdefinition.csfindsContactDefinition), an exact match winning whenever one exists; the declaration itself is never re-cased, C# being case-sensitive. - a
*Definition.partial.csalready exists next to it. Rather than clobbering it, the tool steps back and asks for a manual merge.
An already-migrated *Definition.partial.cs is never taken as input, so re-running the command
is harmless. The exit code stays 0 when files are skipped, but the summary line says how many —
review them by hand.
OptionSetDefinitions.cs
The 2.* DefinitionManager gathered every option set enum into a single file of its own. It holds
no Definition class, so it goes through a pass of its own, on the same rule as the enums found
inside a *Definition.cs: an enum the generator re-emits is dropped, one that no selected
column references is kept.
| What remains in the file | Outcome |
|---|---|
| Nothing — every enum is regenerated | the file is deleted |
| Enums the generator does not emit | the file is trimmed in place, keeping only those |
A trimmed file stays in the project's own namespace: what survives is precisely what the
generator does not emit, so moving it to XrmFramework would only break the references to it.
The file is left alone, and reported, when none of its enums is regenerated — the signature of
a wrong --tables-dir, or of .table files declaring no selected option set column.
An enum only counts as regenerated once the .table files name the option set behind it: a
nameless option set produces no enum. This is why the .table synchronization runs first — on a
directory it has not been through, this pass keeps enums the generator will later emit, and the
project ends up with the same type declared twice.
What the generator emits is not decided here: both read
OptionSetSelection, so this pass cannot delete an
enum the generator then declines to emit.
Implementation: DefinitionFileMigrator
Tables shipped by the framework
The .table files from the XrmFramework package (SystemUser, Role, Team, SdkMessage, …)
are compiled into the consuming project: their *Definition classes therefore appear in the
analyzed DLL, just like those of the project itself. The command does not create them in
the target directory — that would duplicate a file already provided by the package — and
simply reports how many were skipped.
However, if the project already tracks its own copy of one of these tables (a file present
in the target directory, typically to declare additional columns alongside the framework's
own, marked "Locked": true), it is synchronized like any other: missing columns are added,
columns referenced by the code are activated, and orphaned columns are deselected under
--clean. The Locked marker is never modified.
The inventory lives in
FrameworkTableCatalog; a
test verifies that it matches exactly the .table files in src/XrmFramework/Definitions.
Both copies reach the generator —
XrmFramework.propsdeclares the package's.tablefiles asAdditionalFilesbefore the project's own — and it folds them into a single*Definitionclass. That merge is additive: it takes the union of the columns and of the option sets, so a column selected only in the project's copy keeps theenumit references. On a conflict the file loaded first wins, which is the package's; renaming an option set both files declare therefore has to be done in both.
OptionSets.table is a case of its own: it describes no entity, so no *Definition class ever
claims it, and it holds no column. Under --clean both orphan heuristics used to condemn it — it
is now recognized by its globalEnums logical name and left alone. A genuine table that happens
to be named OptionSet is still processed like any other.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success — including "no definition found", and including files left untouched (they are reported in the summary). |
2 |
DLL or directory not found. |
3 |
Unexpected error (the stack trace is displayed). |
1 / -1 |
Argument parsing / validation error (Spectre). |
Implementation: TableSyncHelper.Sync
→ DefinitionAnalyzer
xrmframework migrate sync-models ✅ (available) — .model files from hand-written binding models
Unlike sync-tables, not a one-time upgrade — a .model file has no local edits or pulled CRM
metadata to reconcile with, so each run simply (re)writes the file for every class still found. Run
it once to bulk-convert a project's binding models, or repeatedly while a handful migrate at a time.
What it does
A .model file is a declarative stand-in for a hand-written IBindingModel class:
ModelSourceFileGenerator
reads it, together with the project's .table files, and emits the exact same shape of class —
[CrmMapping] / [CrmLookup] properties, ToBindingModel / ToEntity — at compile time. This
command performs the reverse step: it reflects over an assembly's classes decorated with
[CrmEntity] that implement IBindingModel, and for each one writes the .model file that
reproduces it — column mappings, lookup projections and disambiguation, LookupTargetModel
(detected from a property's own type, no [CrmLookup] needed), [ExtendBindingModel],
[ChildRelationship], and [JsonProperty] / [JsonIgnore] renames.
Like sync-tables, attribute types are matched by simple name, never resolved and
instantiated — the assembly being analyzed references its own copy of XrmFramework, whose version
need not match the one this tool was built against.
A class is left alone — reported as Skipped, with a reason, rather than written — in three cases:
- it already carries
[GeneratedCode("XrmFramework", ...)]:ModelSourceFileGeneratororTableSourceFileGeneratoralready produced it, so there is nothing hand-written left to recover; - it extends something other than
BindingModelBaseby hand (a project's own shared base class, say):ModelSourceFileGeneratoralways emitspartial class X : BindingModelBase, so converting it would break the build (CS0263) rather than migrate it — it stays hand-written, whichMappingSourceGeneratoralready supports directly, without needing a.modelfile; - it lives in the one namespace the framework itself ships a hand-written binding model under
(
XrmFramework.Model—EnvironmentVariable, at the time of writing):XrmFramework's own NuGet package ships its.csfiles ascontentFiles, compiled directly into the consuming project's own assembly, so such a class shows up in the DLL exactly like a project-authored one — writing a.modelfile for it would regenerate an incomplete shadow of a class the framework already provides in full.
xrmframework migrate sync-models --dll <path.dll> --models-dir <directory> [--source-dir <directory>]
| Option | Required | Description |
|---|---|---|
--dll <PATH> |
✅ | Assembly containing the hand-written binding model classes. |
--models-dir <DIRECTORY> |
✅ | Directory the .model files are written into — created if it does not exist yet. |
--source-dir <DIRECTORY> |
❌ | Directory holding the hand-written .cs files (searched recursively). Strips the now-redundant properties from each class a .model file was written for — see below. |
Example
xrmframework migrate sync-models --dll bin/Release/net8.0/MyProject.Core.dll \
--models-dir ../MyProject.Core/Model \
--source-dir ../MyProject.Core/Model
--source-dir: stripping the hand-written classes
Once a .model file exists for a class, ModelSourceFileGenerator emits a partial class of the
same name carrying the same [CrmMapping] / [ChildRelationship] / [ExtendBindingModel]
properties. Left in the hand-written .cs file, they become duplicate members — the project no
longer compiles. --source-dir performs the other half of the migration: for each class a .model
file was written for, it scans the directory (recursively — a .model names a class, not a file, so
there is no fixed naming convention to look the source up by) for a .cs file declaring it, and
strips:
- the mapped properties themselves;
- the class-level
[CrmEntity]attribute, and, if present,[GeneratedCode]/[ExcludeFromCodeCoverage]/[JsonObject]— none of them allows multiple use, so leaving them would break the build (CS0579) once the generated partial carries its own copy.
Anything else the project added by hand — helper methods, computed properties, additional
constructors — is not something the generator produces, so it is left alone. If something survives,
the class is marked partial (if it was not already) and the file is renamed *.partial.cs, the
same convention migrate sync-tables uses for a *Definition.cs that survives its own migration
— DefinitionFileMigrator.
If nothing survives at all (no hand-declared Id, no custom logic), the file is deleted instead — a
.model-only class needs nothing beside the generated partial. A class named in no file under
--source-dir is reported and left alone: its .model file was still written.
A hand-written
Idproperty is not stripped, even when the class did not already deriveBindingModelBase(ModelSourceFileGenerator's generated partial always does) — it becomesCS0114("hides inherited member"), a warning, not an error. Addnewto it, or remove it by hand, once you have looked at the diff.
Two classes sharing a name
.model files name the class they nest or embed by class name — Type, not a path — the same
convention ModelSourceFileGenerator already uses for ExtendBindingModel and LookupTargetModel.
If the assembly declares two classes named alike in different namespaces, writing them both to
{Name}.model would silently overwrite one with the other: the command detects this and skips
the pair with a warning naming both namespaces, rather than guessing which one to keep. Rename one
of them and re-run.
What does not round-trip
A hand-written property's body — whether its setter happens to call OnPropertyChanged() — is
not something reflection can see; the .model's UsePropCh is instead read off [CrmMapping]'s
IsValidForUpdate, which is the value that actually matters for the generated mapping. Likewise, a
[CrmLookup]'s RelationshipName (naming one specific relationship among several reaching the same
target table) and CrmMappingAttribute.LookupInfo have no .model equivalent yet and are not
carried over — a narrow, rare edge case, not the common path this command exists for.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success — including "no binding model found". |
2 |
DLL not found. |
3 |
Unexpected error (the stack trace is displayed). |
1 / -1 |
Argument parsing / validation error (Spectre). |
Implementation: ModelSyncHelper.Sync
→ ModelDefinitionAnalyzer
xrmframework new solution / plugin / console / azurefunction ✅ (available) — scaffolding, without dotnet new
Replaces the XrmFramework.Templates dotnet-new package (formerly xrmSolution,
xrmPluginProject, xrmConsoleProject, xrmAzureFunction), which no longer exists as a separate
project — the project skeletons are shipped as plain content inside the XrmFramework.Cli tool
itself, and a small in-process copier replaces $safeprojectname$ in file/directory names and text
content — no Template Engine, no dotnet new install, and no external interpreter for the
finishing steps a postAction used to run (initXrm.ps1, which required pwsh — see
the migration story below).
xrmframework new solution <NAME> [--output <DIRECTORY>]
xrmframework new plugin <NAME> [--solution-dir <DIRECTORY>] [--solution-unique-name <NAME>]
xrmframework new console <NAME> [--solution-dir <DIRECTORY>]
xrmframework new azurefunction <NAME> [--solution-dir <DIRECTORY>]
| Command | What it does |
|---|---|
new solution |
Creates <NAME>/ from scratch: <NAME>.Core, <NAME>.Plugins, Utils/ (DefinitionManager, RemoteDebugger, Deploy.*), Webresources/. Renames gitignore to .gitignore and materializes Config/connectionStrings.config from its .sample. |
new plugin |
Adds <NAME>/ and Utils/Deploy.<NAME>/ to the solution found under --solution-dir (one .sln/.slnx, or it's an error): dotnet sln add for both, a project reference from RemoteDebugger.csproj, and an <add name="<NAME>" targetSolution="…" type="PluginsWorkflows"/> appended to Config/xrmFramework.config. --solution-unique-name is prompted for if omitted. |
new console / new azurefunction |
Adds <NAME>/ to the solution found under --solution-dir and dotnet sln adds it. |
new solution generates the newer XML-based .slnx format, not the classic .sln — dotnet sln add
understands both transparently (requires .NET SDK 9.0.200+). new plugin/console/azurefunction look
for either extension, so they still work against a solution generated before this switch.
Every PackageReference in the scaffolded content is version-less: new solution also creates a
root Directory.Packages.props (Central Package Management) listing every package the four
templates use — including the XrmFramework packages themselves, at the CLI's own version. A project
added later via new plugin/new console/new azurefunction relies on that same file (MSBuild
finds it by walking up from the new project to the solution root), so no version is ever repeated
per project.
Example
xrmframework new solution Contoso
xrmframework new plugin Contoso.Warehouse --solution-dir Contoso --solution-unique-name ContosoPlugins
<a id="why-not-dotnet-new"></a>
Why not keep using dotnet new?
The XrmFramework.Templates project (and its dotnet-new templates) has been removed from this
repository — it carried a pwsh-based initXrm.ps1 postAction for everything the Template Engine
has no declarative feature for: deleting files (no post-action removes anything), producing
connectionStrings.config from its
.sample (no "copy" post-action, only rename), merging a plugin's generated content into the
already existing solution folder (the Template Engine only ever writes into its own fresh output
directory — hence the template's own mv $safeprojectname$/* ./ dance), and editing the XML
xrmFramework.config (the only config-editing post-action Microsoft ships targets JSON). Being a
plain C# tool with no such sandbox, new plugin writes straight to its final path under
--solution-dir and edits xrmFramework.config directly — no external interpreter, no postAction,
no merge step.
Implementation: SolutionScaffolder,
PluginScaffolder,
SimpleProjectScaffolder
→ TemplateScaffolder (the copy +
token-replace engine) + DotNetCliRunner
(dotnet sln add / dotnet add reference). Content lives under
XrmFramework.Cli/Scaffolding/, shipped as plain tool content
(AppContext.BaseDirectory/Scaffolding/<Solution|Plugin|ConsoleApp|AzureFunction>).
Roadmap
Target command tree (✅ exist, 🚧 are upcoming):
xrmframework
├── tables
│ ├── list ✅ lists the tables of the environment (connected)
│ ├── pull ✅ .table ← Dataverse metadata (connected)
│ ├── columns
│ │ ├── list ✅ lists the columns already tracked (offline)
│ │ ├── add ✅ activates columns (offline)
│ │ └── set ✅ renames a column / toggles selection (offline)
│ └── optionsets
│ ├── list ✅ lists option sets / their members (offline)
│ └── set ✅ renames an option set / a member (offline)
├── deploy
│ ├── plugins ✅ deploys a plugins / custom API / workflow assembly
│ └── webresources 🚧 deploys the webresources
├── migrate
│ ├── sync-tables ✅ migration 2.* -> 3.1+, run once (offline)
│ └── sync-models ✅ .model ← hand-written IBindingModel classes (offline)
└── new
├── solution ✅ scaffolds a new XrmFramework solution (offline)
├── plugin ✅ adds a plugin project to a solution (offline)
├── console ✅ adds a console app project to a solution (offline)
└── azurefunction ✅ adds an Azure Function project to a solution (offline)
migrate stands apart from tables and deploy: each command here rewrites the project's own
sources from what an assembly's classes declare, rather than from the environment or from local
edits. sync-tables is a one-time upgrade path from 2.*; sync-models is not — a .model file
has nothing to reconcile with, so it can be run once to bulk-convert or repeatedly as more classes
migrate. Routine work is pull (rich metadata from the environment) plus the local edits
tables columns and tables optionsets make scriptable: column selection and C# naming in the
.table.
deploy pluginsinventories thenet462plugin assembly by executing its registration code via theXrmFramework.PluginInventorytool (embeddednet462executable) — step registration therefore remains completely free-form (loops, conditions…). Requires the .NET Framework runtime (Windows).
🚧 deploy webresources — deploy the webresources
Deploys the webresources from a project folder to the SelectedConnection environment. Will
rely on WebResourceHelper.SyncWebResources
(existing options: -p/--path, -n/--noprompt).
Architecture & adding a command
The CLI follows the Spectre.Console.Cli model:
Program.csconfigures theCommandAppand the command tree (branchestables,deploy, …).Commands/contains one class per command:Command<TSettings>with aSettingsclass ([CommandOption]options +Validate()), and anExecute(...)that delegates to a helper inXrmFramework.DeployUtils(the CLI contains no business logic).
To add a command:
- Create
Commands/MyCommand.cs(Command<Settings>),Executecalls the helper. - Register it in
Program.cs(AddCommand/AddBranch). - If the logic doesn't already exist in
DeployUtils, add it there as a parameterized API returning anint(exit code), following the pattern ofTableSyncHelper.Sync(...)— noEnvironment.Exitin the helpers.
⚠️ Spectre.Console.Cli 0.55:
Command<T>.Executeisprotected override int Execute(CommandContext, T, CancellationToken). In help/description text, escape literal brackets ([→[[,]→]]), otherwise they are interpreted as style markup.
Development
# build
dotnet build src/XrmFramework.Cli -c Release
# run without packaging
dotnet run --project src/XrmFramework.Cli -- migrate sync-tables --dll <dll> --tables-dir <dir>
# package the tool locally and inspect it
dotnet pack src/XrmFramework.Cli -c Release -o ./nupkg
unzip -p ./nupkg/XrmFramework.Cli.*.nupkg "*.nuspec" # <packageType name="DotnetTool" />
The package embeds the entire dependency closure under
tools/net10.0/any/(includingXrmFramework.DeployUtilsand the Dataverse client): this is large but necessary for a self-contained tool.
The version comes from Nerdbank.GitVersioning (no version number to maintain by hand).
| 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.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.1.121 | 0 | 9/13/2026 |
| 3.1.120 | 46 | 9/11/2026 |
| 3.1.118 | 49 | 9/11/2026 |
| 3.1.117 | 43 | 9/11/2026 |
| 3.1.116 | 44 | 9/11/2026 |
| 3.1.113-beta | 54 | 9/8/2026 |
| 3.1.112-beta | 58 | 9/8/2026 |
| 3.1.111-beta | 58 | 9/4/2026 |
| 3.1.110-beta | 59 | 9/4/2026 |
| 3.1.100-beta | 76 | 8/19/2026 |
| 3.1.99-beta | 64 | 8/19/2026 |
| 3.1.97-beta | 72 | 8/19/2026 |
| 3.1.96-beta | 69 | 8/18/2026 |
| 3.1.92-beta | 76 | 8/18/2026 |