Changelog
All notable changes to crm are documented in this file.
The format is loosely based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- crm apply -f spec.yaml: declarative desired-state from a single YAML/JSON
spec. Orchestrates the existing metadata cores in dependency order (publisher →
solution → entities → option sets → attributes → relationships → views), each
with if_exists=skip, and runs PublishAllXml once at the end, so re-applying
an unchanged spec is a no-op. Emits {ok, data:{applied, skipped, planned,
failed}, meta:{staged}}. Honors --dry-run (greenfield specs report
dependents as planned-create instead of erroring) and --stage-only (create
without publishing). Metadata POSTs are non-transactional, so a failure
aborts-and-reports, leaving staged-but-unpublished residue. The spec is
validated up front. Adds a runtime dependency on PyYAML (#60).
- Machine-readable error taxonomy: in --json mode the error envelope now carries
meta.category (a closed enum: not_found, auth_failed, forbidden,
concurrency_conflict, duplicate_detected, validation, throttled,
server_error, transport_error) and meta.retryable, alongside the existing
meta.status / meta.code. Classification is status-first, with two D365 error
codes (0x80040217 → not_found, 0x80040237 → duplicate_detected) honored
regardless of status; retryable is true only for the transient classes. The
backend auto-retries the transport_error / throttled (429) / server_error
(5xx) classes, so for those retryable is a post-exhaustion hint;
concurrency_conflict (412) is not auto-retried — the caller refetches a fresh
ETag and retries. The status-less transport path now carries a transport_error
signal, and the fragile MissingPrivilege message-substring synthesis is
subsumed (403 → forbidden) (#62).
- Canonical meta.dry_run signal: in --json mode every dry-run invocation now
carries meta.dry_run: true in the envelope. It is keyed off the invocation-level
--dry-run flag (not by sniffing the data for the _dry_run sentinel), so
list-shaped batch previews and poll previews are covered uniformly and forced-real
existence-probe GETs do not false-positive. Existing meta keys (e.g. staged)
are preserved; the in-data _dry_run sentinel is retained for back-compat (#61).
[0.8.0] — 2026-06-04
Added
- Installer SHA-256 integrity verification: install.sh / install.ps1 verify the
downloaded archive against a published SHA256SUMS (uploaded per release to
<tag>/ and latest/ in R2) before extracting, and abort on a mismatch or if it
can't be fetched. CRM_SHA256 / $env:CRM_SHA256 pins a hash out-of-band (#46).
- Cloud impersonation by Entra ID object id via the CallerObjectId header:
new --as-user-object-id <guid> flag (alongside --as-user) and
CRM_AS_USER_OBJECT_ID env default, on every command that already carries
--as-user. Header selection is by which input you supply, independent of
auth_scheme; --as-user (MSCRMCallerID) and --as-user-object-id
(CallerObjectId) are mutually exclusive per request (#54).
- CHANGELOG is now published on the docs site at /changelog/, rendered
from this file via mkdocs-include-markdown-plugin.
0.7.0 — Fast startup + R2 install
Performance
- CLI subcommands and the D365 backend stack now load lazily: crm --version and
direct command invocations no longer import every command module (and their
requests/NTLM/prompt_toolkit dependencies), cutting cold startup substantially.
crm --help still loads all modules (accepted trade-off).
Changed
- PyInstaller builds switched from --onefile to --onedir (dist/crm/),
eliminating per-launch self-extraction overhead.
- Install is now a one-line script served from a public Cloudflare R2 bucket
(irm …/install.ps1 | iex on Windows, curl …/install.sh | sh on Linux),
replacing the private-repo GitHub release URL that 404'd for users.
Added
- scripts/install.ps1 (Windows) and scripts/install.sh (Linux): download the
prebuilt onedir bundle from R2, install to a user dir, wire up PATH / a symlink,
and support uninstall.
0.6.0 — Spec E: DX Polish
Refactor
- Split crm/cli.py (2098 lines) into focused modules under crm/commands/
(one Click group per file). Pure refactor — zero behavior change.
Added
- --log-level debug|info|warning|error + --log-format text|json-line on
the root CLI group (env: CRM_LOG_LEVEL, CRM_LOG_FORMAT).
- --verbose flag (alias for --log-level debug).
- --auth-scheme ntlm|kerberos|negotiate on the root CLI group
(env: CRM_AUTH_SCHEME). Kerberos/Negotiate via requests_negotiate_sspi
(install with pip install crm[kerberos]).
- crm init command: --template writes .env.example; no args runs an
interactive profile wizard.
- query count <entity> — calls RetrieveTotalRecordCount.
- metadata list-actions — parses $metadata and lists OData actions.
- metadata list-functions — parses $metadata and lists OData functions.
- REPL tab completion for entity-name argument slots, backed by a lazy
in-memory MetadataCache.
Changed
- ConnectionProfile gains an auth_scheme field (default "ntlm",
backward compatible).
- crm/utils/repl_skin.py::create_prompt_session accepts an optional
completer argument.
[0.5.0] — 2026-05-25
Added
metadata add-attribute— add columns to existing entities. Supports 14 attribute kinds: string, memo, integer, bigint, decimal, double, money, boolean, datetime, picklist, multiselect, lookup, image, file.metadata create-one-to-many+metadata create-many-to-many— create 1:N and N:N relationships via the dedicated Dataverse actions.- Global option set CRUD:
metadata list-optionsets,get-optionset,create-optionset,update-optionset,delete-optionset.updateis granular:--insert-option/--update-option/--delete-option/--reorderflags map to the matching bound actions. metadata delete-entity— drop a custom table, guarded by interactive confirm +--yesskip + client-sideIsCustomEntity+IsManagedpre-flight check.
All new write verbs accept --solution <uniquename> (header
MSCRM.SolutionUniqueName) and --publish/--no-publish (default ON),
matching metadata create-entity. Delete verbs skip publish.
0.4.0 — 2026-05-25
This release lands Spec C from the post-code-review roadmap: $batch
support, on-prem-correct impersonation via MSCRMCallerID, two admin
headers for write paths, an asyncoperations browse surface, and
explicit optimistic concurrency via If-Match. See
docs/superpowers/specs/2026-05-24-spec-c-throughput-admin-design.md
for the full design.
Added
D365Backend.batch(operations, *, transactional=True, continue_on_error=False, timeout=None)— execute a list of operations via POST$batch. Consecutive writes are auto-grouped into one changeset; GETs go as top-level operations.crm batch <file.json>CLI command with--no-transaction,--continue-on-error,--output,--timeoutflags.- Backend typed kwargs on every verb:
caller_id,suppress_duplicate_detection,bypass_custom_plugin_execution,etag. Env defaults:CRM_AS_USER,CRM_SUPPRESS_DUP,CRM_BYPASS_PLUGINS. - Per-command CLI flags on every write/action verb:
--as-user <guid>,--suppress-dup-detection,--bypass-plugins.--if-match <etag>onentity updateandentity delete. crm async list/get/cancelpluscrm solution job-status / job-cancelaliases.- New TypedDicts:
BatchOperation,BatchResult,AsyncOperationRow.
Changed
- HTTP
412responses now map toD365Error(code="PreconditionFailed"). - HTTP
403responses whose body referencesprvBypassCustomPluginExecutionmap toD365Error(code="MissingPrivilege").
Deferred
CreateMultiple/UpdateMultiple/UpsertMultiple— Dataverse cloud only; not present on Contoso 9.1.x on-prem.CallerObjectIdimpersonation header — requires Microsoft Entra ID; on-prem AD users useMSCRMCallerID.- Server-side
$batchsize limits (typical Dataverse: 100 changesets per batch; 1000 ops per changeset) are not enforced client-side; the server'sMaxBatchSize/MaxChangesetSizeerror surfaces verbatim.
Notes for callers
POST $batchis retried only on429and503(Spec B conservative-POST policy). A retried batch re-sends the assembled body verbatim — idempotency is the caller's responsibility.
0.3.0 — 2026-05-24
This release lands Spec B from the post-code-review roadmap: a retry
layer on every HTTP call plus a switch to the asynchronous variants of
ImportSolution and ExportSolution. See
docs/superpowers/specs/2026-05-24-spec-b-resilience-design.md for the
full design.
Breaking
crm.core.solution.import_solutionreturn shape changes. Now returns{import_job_id, async_operation_id, status, progress, started_on, completed_on, duration_ms}. Any caller reading the old ImportSolution response keys (ImportJobKey, etc.) must switch.crm.core.solution.export_solutionreturn shape gains keys. New fields:async_operation_id,export_job_id,duration_ms. The existingoutput,bytes,managed,solutionkeys are preserved.- Both functions can now block for up to
CRM_ASYNC_TIMEOUTseconds (default 1800). The sync versions blocked for up toprofile.timeoutseconds per HTTP call (default 120) with no client-side polling.
Added
D365Backend.requestnow retries on429, idempotent5xx(502/503/504onGET/PUT/PATCH/DELETE;503only onPOST), and retryable transport errors (ConnectionError,Timeout,ChunkedEncodingError). HonorsRetry-After; falls back to capped exponential backoff with full jitter.D365Backend.poll_async_operation(async_operation_id, *, timeout, import_job_id, on_progress)— blocks until anasyncoperations(<id>)row reachesstatecode=3. RaisesD365Erroron failure (statuscode=31), cancellation (32), or timeout.ConnectionProfilegains seven new fields:retry_max,retry_base_delay,retry_max_delay,retry_jitter,async_poll_initial,async_poll_max,async_timeout.- Env overrides:
CRM_RETRY_MAX,CRM_RETRY_BASE_DELAY,CRM_RETRY_MAX_DELAY,CRM_RETRY_JITTER,CRM_ASYNC_TIMEOUT,CRM_NO_RETRY. Env wins over profile. - New CLI flags on
crm solution exportandcrm solution import:--timeout N(overrideasync_timeoutfor this call),--no-retry(setCRM_NO_RETRY=1for this call).crm solution importalso gets--quiet/-qto suppress per-tick progress lines. x-ms-ratelimit-*headers are logged to stderr on every retried 429, and on every response underCRM_VERBOSE=1.
Changed
crm solution importandcrm solution exportnow block until the async operation reports completion, emitting per-tick progress to stderr (import only; suppress with--quiet).
0.2.0 — 2026-05-24
This release lands Spec A from the post-code-review roadmap: nine correctness
fixes plus pyright strict (zone-scoped) across crm/core/* and
crm/utils/d365_backend.py. See
docs/superpowers/specs/2026-05-24-spec-a-correctness-pyright-design.md for
the full design.
Breaking
- Error envelope
meta.statusandmeta.codenow emit JSONnullwhen absent, instead of the literal string"n/a". Scripts that string-match"n/a"must switch to a null check. (§3.5)
Added
--export-setting <name>flag oncrm solution export, repeatable. Accepted names:autonumbering,calendar,customizations,email-tracking,general,isv-config,marketing,outlook-sync,relationship-roles,sales. (§3.6)crm/utils/d365_types.py—TypedDictshapes for Web API responses.pyright(>=1.1.380) as a dev dependency and a CI step in.github/workflows/build.yml. Strict mode oncrm/core/*+crm/utils/d365_backend.py; basic mode (via file-level# pyright: basicpragma) oncrm/cli.py,crm/utils/repl_skin.py, andcrm/tests/*.
Changed
metadata create-entitynow readsEntitySetNameback from the server instead of guessing it via English pluralisation. Adds one round-trip per create call. On read-back failure the entity is still reported as created, withentity_set_name: nulland a diagnosticentity_set_lookup_errorfield. (§3.3)- REPL keeps a single
D365Backendper session instead of rebuilding on every command. Invalidated byconnection connect/connection disconnect. (§3.7) $countqueries parsetext/plaindirectly in one HTTP call on the happy path. Falls back to?$count=trueif the body is missing or non-numeric. (§3.9)fetchxml_querypasses the FetchXML viaparams=instead of manual URL concatenation. No on-wire change. (§3.4)
Fixed
entity createno longer sends the non-specIf-None-Match: nullheader on POST. (§3.1)data exportCSV no longer leaks_valuelookup columns and@odata.*annotations into headers —_ordered_keysboolean precedence bug. (§3.2).envvalue parser is now pair-aware:KEY="foo's bar"resolves tofoo's bar, notfoos bar. (§3.8)