XrmFramework.Cli 3.1.121

dotnet tool install --global XrmFramework.Cli --version 3.1.121
                    
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 XrmFramework.Cli --version 3.1.121
                    
This package contains a .NET tool you can call from the shell/command line.
#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 (and migrate sync-models if 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
# 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.config carries 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) — see ConfigHelper.UseProjectConfig and ProjectConfigLocator.


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 .table file 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 .table file 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 .table files, the command stops with code 1 before 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. pull never downgrades a Select: true, and never re-activates a column that was deliberately deactivated — including createdon and the like, even though they are activated by default on creation. This guarantee is verified end-to-end (metadata → merge → write → re-read) by TablePullPersistenceTests.
  • The target file is located by its LogName, not by its file name: a table whose Name was manually renamed (Contract.tableContractLocation.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. pull refreshes, it does not destroy; deselecting orphaned columns is a separate decision (migrate sync-tables --clean does 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 pull on 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: CrmTableHelperProjectConfigLocator

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: ColumnHelperTableFileStore

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 / OptionSetEditorWindowTableFileStore

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: OptionSetHelperTableFileStore

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 is net10.0: it therefore cannot instantiate the plugin's types itself. It delegates to the XrmFramework.PluginInventory tool (a net462 executable, embedded under inventory/), 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_LAUNCHER environment variable (e.g. mono); XRMFRAMEWORK_INVENTORY_EXE allows 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.PluginInventoryPluginInventoryReader

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:

  1. it reflects over the assembly last compiled under 2.*, whose *Definition classes record which columns the project's code actually uses, and under which name each option set is compiled;
  2. it brings the .table files in line — creating what is missing, setting Select: true on every column the code references, and naming the option sets;
  3. it cleans up the *Definition.cs files 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 EntityName and EntityCollectionName constants;
  • the nested Columns, AlternateKeyNames, ManyToManyRelationships, ManyToOneRelationships and OneToManyRelationships classes, together with their attributes;
  • the namespace-level option set enums — only those the generator will actually re-emit, i.e. declared in a .table and referenced by a selected column. An option set no column uses is not regenerated, so its enum is 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 partial modifier 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 using and 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 .table in 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 the Name declared inside the .table, not on its file name (Systemuser.table declares SystemUser).
  • 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.cs finds ContactDefinition), an exact match winning whenever one exists; the declaration itself is never re-cased, C# being case-sensitive.
  • a *Definition.partial.cs already 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.props declares the package's .table files as AdditionalFiles before the project's own — and it folds them into a single *Definition class. 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 the enum it 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.SyncDefinitionAnalyzer


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", ...)]: ModelSourceFileGenerator or TableSourceFileGenerator already produced it, so there is nothing hand-written left to recover;
  • it extends something other than BindingModelBase by hand (a project's own shared base class, say): ModelSourceFileGenerator always emits partial class X : BindingModelBase, so converting it would break the build (CS0263) rather than migrate it — it stays hand-written, which MappingSourceGenerator already supports directly, without needing a .model file;
  • it lives in the one namespace the framework itself ships a hand-written binding model under (XrmFramework.ModelEnvironmentVariable, at the time of writing): XrmFramework's own NuGet package ships its .cs files as contentFiles, 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 .model file 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 Id property is not stripped, even when the class did not already derive BindingModelBase (ModelSourceFileGenerator's generated partial always does) — it becomes CS0114 ("hides inherited member"), a warning, not an error. Add new to 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.SyncModelDefinitionAnalyzer


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 .slndotnet 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, SimpleProjectScaffolderTemplateScaffolder (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 plugins inventories the net462 plugin assembly by executing its registration code via the XrmFramework.PluginInventory tool (embedded net462 executable) — 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.cs configures the CommandApp and the command tree (branches tables, deploy, …).
  • Commands/ contains one class per command: Command<TSettings> with a Settings class ([CommandOption] options + Validate()), and an Execute(...) that delegates to a helper in XrmFramework.DeployUtils (the CLI contains no business logic).

To add a command:

  1. Create Commands/MyCommand.cs (Command<Settings>), Execute calls the helper.
  2. Register it in Program.cs (AddCommand / AddBranch).
  3. If the logic doesn't already exist in DeployUtils, add it there as a parameterized API returning an int (exit code), following the pattern of TableSyncHelper.Sync(...) — no Environment.Exit in the helpers.

⚠️ Spectre.Console.Cli 0.55: Command<T>.Execute is protected 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/ (including XrmFramework.DeployUtils and 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 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
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
Loading failed