@slamb2k/dvx 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1503 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AuthManager,
4
+ DataverseError,
5
+ EntityNotFoundError,
6
+ ImpersonationPrivilegeError,
7
+ MsalCachePlugin,
8
+ ValidationError,
9
+ buildBatchBody,
10
+ chunkArray,
11
+ createClient,
12
+ createSpinner,
13
+ isInteractive,
14
+ logError,
15
+ logInfo,
16
+ logMutationSuccess,
17
+ logStep,
18
+ logSuccess,
19
+ logWarn,
20
+ openBrowser,
21
+ promptConfirmClack,
22
+ promptUrl,
23
+ setUxOptions,
24
+ stripAnsi,
25
+ validateEntityName,
26
+ validateFetchXml,
27
+ validateGuid,
28
+ validateUrl
29
+ } from "./chunk-QFYVVOAX.js";
30
+
31
+ // src/index.ts
32
+ import { Command, Option } from "commander";
33
+
34
+ // src/commands/auth-login.ts
35
+ import { PublicClientApplication } from "@azure/msal-node";
36
+ import { Client } from "@microsoft/microsoft-graph-client";
37
+ import * as clack from "@clack/prompts";
38
+ import * as path from "path";
39
+ var DVX_BOOTSTRAPPER_CLIENT_ID = "73f809fb-5469-4956-85f9-e0141af82d90";
40
+ var MANUAL_INSTRUCTIONS = `
41
+ Manual Setup Instructions:
42
+ 1. Go to https://portal.azure.com \u2192 Azure Active Directory \u2192 App registrations
43
+ 2. Click "New registration", set name to "dvx", type "Single tenant"
44
+ 3. Go to the new app \u2192 Certificates & secrets \u2192 New client secret
45
+ 4. Copy the Application (client) ID and the secret value
46
+ 5. In Dataverse admin, create an Application User with the client ID
47
+ 6. Assign the "System Administrator" or relevant security role
48
+ `;
49
+ async function bootstrapSignIn(tenantId) {
50
+ const authority = tenantId ? `https://login.microsoftonline.com/${tenantId}` : "https://login.microsoftonline.com/organizations";
51
+ const cacheFilePath = path.join(process.cwd(), ".dvx", "msal-cache-bootstrapper.json");
52
+ const pca = new PublicClientApplication({
53
+ auth: {
54
+ clientId: DVX_BOOTSTRAPPER_CLIENT_ID,
55
+ authority
56
+ },
57
+ cache: {
58
+ cachePlugin: new MsalCachePlugin(cacheFilePath)
59
+ }
60
+ });
61
+ const scopes = ["https://globaldisco.crm.dynamics.com//user_impersonation"];
62
+ const accounts = await pca.getAllAccounts();
63
+ let discoveredTenantId = tenantId;
64
+ if (accounts.length > 0 && accounts[0]) {
65
+ try {
66
+ const result2 = await pca.acquireTokenSilent({ account: accounts[0], scopes });
67
+ if (result2?.accessToken && result2.account?.tenantId) {
68
+ return { pca, tenantId: discoveredTenantId ?? result2.account.tenantId };
69
+ }
70
+ } catch {
71
+ }
72
+ }
73
+ const result = await pca.acquireTokenInteractive({
74
+ scopes,
75
+ openBrowser: async (url) => {
76
+ openBrowser(url);
77
+ },
78
+ successTemplate: "<h1>Authentication complete. You may close this tab.</h1>",
79
+ errorTemplate: "<h1>Authentication failed: {error}</h1>"
80
+ });
81
+ if (!result?.accessToken) {
82
+ throw new Error("Failed to acquire token during sign-in");
83
+ }
84
+ discoveredTenantId = discoveredTenantId ?? result.account?.tenantId;
85
+ if (!discoveredTenantId) {
86
+ throw new Error("Could not determine tenant ID from sign-in. Pass --tenant-id explicitly.");
87
+ }
88
+ return { pca, tenantId: discoveredTenantId };
89
+ }
90
+ async function discoverEnvironments(pca) {
91
+ const scopes = ["https://globaldisco.crm.dynamics.com//user_impersonation"];
92
+ const accounts = await pca.getAllAccounts();
93
+ let accessToken;
94
+ if (accounts.length > 0 && accounts[0]) {
95
+ try {
96
+ const result = await pca.acquireTokenSilent({ account: accounts[0], scopes });
97
+ accessToken = result?.accessToken;
98
+ } catch {
99
+ }
100
+ }
101
+ if (!accessToken) {
102
+ const result = await pca.acquireTokenInteractive({
103
+ scopes,
104
+ openBrowser: async (url) => {
105
+ openBrowser(url);
106
+ },
107
+ successTemplate: "<h1>Authentication complete. You may close this tab.</h1>",
108
+ errorTemplate: "<h1>Authentication failed: {error}</h1>"
109
+ });
110
+ accessToken = result?.accessToken;
111
+ }
112
+ if (!accessToken) {
113
+ throw new Error("Failed to acquire discovery token");
114
+ }
115
+ const response = await fetch("https://globaldisco.crm.dynamics.com/api/discovery/v2.0/Instances", {
116
+ headers: { Authorization: `Bearer ${accessToken}` }
117
+ });
118
+ if (!response.ok) {
119
+ throw new Error(`Discovery API returned ${response.status}: ${response.statusText}`);
120
+ }
121
+ const data = await response.json();
122
+ return data.value.filter((env) => env.State === 0).map((env) => ({
123
+ url: env.Url,
124
+ friendlyName: env.FriendlyName,
125
+ uniqueName: env.UniqueName,
126
+ state: env.State === 0 ? "Enabled" : "Disabled",
127
+ version: env.Version
128
+ }));
129
+ }
130
+ async function selectEnvironment(environments) {
131
+ const options = environments.map((env) => ({
132
+ value: env.url,
133
+ label: env.friendlyName || env.uniqueName,
134
+ hint: env.url
135
+ }));
136
+ options.push({
137
+ value: "__manual__",
138
+ label: "Enter URL manually",
139
+ hint: "Specify a Dataverse environment URL"
140
+ });
141
+ const selected = await clack.select({
142
+ message: "Select a Dataverse environment",
143
+ options
144
+ });
145
+ if (clack.isCancel(selected)) {
146
+ clack.cancel("Login cancelled.");
147
+ process.exit(0);
148
+ }
149
+ if (selected === "__manual__") {
150
+ const url = await clack.text({
151
+ message: "Dataverse environment URL",
152
+ placeholder: "https://org.crm.dynamics.com",
153
+ validate: (value) => {
154
+ if (!value) return "Please enter a valid URL";
155
+ try {
156
+ new URL(value);
157
+ } catch {
158
+ return "Please enter a valid URL";
159
+ }
160
+ }
161
+ });
162
+ if (clack.isCancel(url)) {
163
+ clack.cancel("Login cancelled.");
164
+ process.exit(0);
165
+ }
166
+ return url;
167
+ }
168
+ return selected;
169
+ }
170
+ async function acquireGraphToken(pca) {
171
+ const scopes = ["https://graph.microsoft.com/Application.ReadWrite.All"];
172
+ const accounts = await pca.getAllAccounts();
173
+ if (accounts.length > 0 && accounts[0]) {
174
+ try {
175
+ const result2 = await pca.acquireTokenSilent({ account: accounts[0], scopes });
176
+ if (result2?.accessToken) return result2.accessToken;
177
+ } catch {
178
+ }
179
+ }
180
+ const result = await pca.acquireTokenInteractive({
181
+ scopes,
182
+ openBrowser: async (url) => {
183
+ openBrowser(url);
184
+ },
185
+ successTemplate: "<h1>Authentication complete. You may close this tab.</h1>",
186
+ errorTemplate: "<h1>Authentication failed: {error}</h1>"
187
+ });
188
+ if (!result?.accessToken) {
189
+ throw new Error("Failed to acquire Graph API token");
190
+ }
191
+ return result.accessToken;
192
+ }
193
+ async function provisionViaGraph(pca) {
194
+ const accessToken = await acquireGraphToken(pca);
195
+ const graphClient = Client.init({
196
+ authProvider: (done) => {
197
+ done(null, accessToken);
198
+ }
199
+ });
200
+ logStep('Creating app registration "dvx-service"...');
201
+ const app = await graphClient.api("/applications").post({
202
+ displayName: "dvx-service",
203
+ signInAudience: "AzureADMyOrg",
204
+ publicClient: {
205
+ redirectUris: ["http://localhost"]
206
+ },
207
+ requiredResourceAccess: [
208
+ {
209
+ resourceAppId: "00000007-0000-0000-c000-000000000000",
210
+ resourceAccess: [
211
+ { id: "78ce3f0f-a1ce-49c2-8cde-64b5c0896db4", type: "Scope" }
212
+ ]
213
+ }
214
+ ]
215
+ });
216
+ logSuccess(`App created: ${app.appId}`);
217
+ logStep("Creating service principal...");
218
+ await graphClient.api("/servicePrincipals").post({
219
+ appId: app.appId
220
+ });
221
+ logSuccess("Service principal created");
222
+ logStep("Granting admin consent for Dataverse access...");
223
+ try {
224
+ const crmSp = await graphClient.api("/servicePrincipals").filter("appId eq '00000007-0000-0000-c000-000000000000'").select("id").get();
225
+ const dvxSp = await graphClient.api("/servicePrincipals").filter(`appId eq '${app.appId}'`).select("id").get();
226
+ if (crmSp.value[0] && dvxSp.value[0]) {
227
+ await graphClient.api("/oauth2PermissionGrants").post({
228
+ clientId: dvxSp.value[0].id,
229
+ consentType: "AllPrincipals",
230
+ resourceId: crmSp.value[0].id,
231
+ scope: "user_impersonation"
232
+ });
233
+ logSuccess("Admin consent granted");
234
+ }
235
+ } catch {
236
+ logWarn("Could not auto-grant consent (may require Global Admin). Users will be prompted on first login.");
237
+ }
238
+ return app.appId;
239
+ }
240
+ async function authLogin(options) {
241
+ if (options.servicePrincipal) {
242
+ if (!options.url) {
243
+ throw new Error("Environment URL required for service principal auth. Pass --url.");
244
+ }
245
+ const envUrl2 = validateUrl(options.url);
246
+ const clientSecret = options.clientSecret ?? process.env["DATAVERSE_CLIENT_SECRET"];
247
+ if (!clientSecret) {
248
+ throw new Error("Client secret required. Pass --client-secret or set DATAVERSE_CLIENT_SECRET.");
249
+ }
250
+ if (!options.clientId) {
251
+ throw new Error("Client ID required for service principal auth. Pass --client-id.");
252
+ }
253
+ if (!options.tenantId) {
254
+ throw new Error("Tenant ID required for service principal auth. Pass --tenant-id.");
255
+ }
256
+ const profile2 = {
257
+ name: options.name,
258
+ type: "service-principal",
259
+ environmentUrl: envUrl2,
260
+ tenantId: options.tenantId,
261
+ clientId: options.clientId,
262
+ clientSecret
263
+ };
264
+ const manager2 = new AuthManager();
265
+ manager2.createProfile(profile2);
266
+ process.env["DATAVERSE_CLIENT_SECRET"] = clientSecret;
267
+ const spSpinner = createSpinner();
268
+ spSpinner.start("Validating connection...");
269
+ let entityCount;
270
+ try {
271
+ const { client } = await createClient();
272
+ const entityList = await client.listEntities();
273
+ entityCount = entityList.length;
274
+ spSpinner.stop(`Connected \u2014 found ${entityCount} entities`);
275
+ } catch (err) {
276
+ spSpinner.error("Connection failed");
277
+ throw err;
278
+ }
279
+ console.log(JSON.stringify({ profile: options.name, type: "service-principal", status: "logged_in", entityCount }));
280
+ return;
281
+ }
282
+ if (isInteractive()) clack.intro("dvx auth login");
283
+ let envUrl = options.url ? validateUrl(options.url) : void 0;
284
+ let tenantId = options.tenantId;
285
+ let clientId = options.clientId;
286
+ let session;
287
+ if (!envUrl) {
288
+ const s = createSpinner();
289
+ s.start("Signing in to discover environments...");
290
+ try {
291
+ session = await bootstrapSignIn(tenantId);
292
+ tenantId = session.tenantId;
293
+ s.stop("Signed in");
294
+ } catch (err) {
295
+ s.stop("Sign-in failed");
296
+ const message = err instanceof Error ? err.message : String(err);
297
+ logWarn(`Could not sign in for discovery: ${message}`);
298
+ envUrl = validateUrl(await promptUrl("Dataverse environment URL"));
299
+ }
300
+ if (!envUrl && session) {
301
+ const s2 = createSpinner();
302
+ s2.start("Discovering environments...");
303
+ try {
304
+ const environments = await discoverEnvironments(session.pca);
305
+ s2.stop(`Found ${environments.length} environment${environments.length === 1 ? "" : "s"}`);
306
+ if (environments.length > 0) {
307
+ envUrl = validateUrl(await selectEnvironment(environments));
308
+ } else {
309
+ logWarn("No Dataverse environments found for this account.");
310
+ envUrl = validateUrl(await promptUrl("Dataverse environment URL"));
311
+ }
312
+ } catch (err) {
313
+ s2.stop("Discovery failed");
314
+ const message = err instanceof Error ? err.message : String(err);
315
+ logWarn(`Discovery failed: ${message}`);
316
+ envUrl = validateUrl(await promptUrl("Dataverse environment URL"));
317
+ }
318
+ }
319
+ }
320
+ if (!envUrl) {
321
+ throw new Error("No environment URL determined. Pass --url explicitly.");
322
+ }
323
+ if (!clientId) {
324
+ try {
325
+ if (!session) {
326
+ const s = createSpinner();
327
+ s.start("Signing in for app registration...");
328
+ session = await bootstrapSignIn(tenantId);
329
+ tenantId = session.tenantId;
330
+ s.stop("Signed in");
331
+ }
332
+ clientId = await provisionViaGraph(session.pca);
333
+ } catch (err) {
334
+ const message = err instanceof Error ? err.message : String(err);
335
+ logWarn(`Automated app registration failed: ${message}`);
336
+ logInfo(MANUAL_INSTRUCTIONS);
337
+ const enteredClientId = await clack.text({ message: "Enter client ID from app registration" });
338
+ if (clack.isCancel(enteredClientId)) {
339
+ clack.cancel("Login cancelled.");
340
+ process.exit(0);
341
+ }
342
+ clientId = enteredClientId;
343
+ if (!tenantId) {
344
+ const enteredTenantId = await clack.text({ message: "Enter Entra tenant ID" });
345
+ if (clack.isCancel(enteredTenantId)) {
346
+ clack.cancel("Login cancelled.");
347
+ process.exit(0);
348
+ }
349
+ tenantId = enteredTenantId;
350
+ }
351
+ }
352
+ }
353
+ if (!tenantId) {
354
+ throw new Error("Could not determine tenant ID. Pass --tenant-id explicitly.");
355
+ }
356
+ const profile = {
357
+ name: options.name,
358
+ type: "delegated",
359
+ environmentUrl: envUrl,
360
+ tenantId,
361
+ clientId
362
+ };
363
+ const manager = new AuthManager();
364
+ manager.createProfile(profile);
365
+ const finalSpinner = createSpinner();
366
+ finalSpinner.start("Signing in to Dataverse...");
367
+ await manager.getToken(options.name);
368
+ finalSpinner.stop("Signed in");
369
+ if (isInteractive()) clack.outro(`Logged in as '${options.name}' \u2192 ${envUrl}`);
370
+ console.log(JSON.stringify({ profile: options.name, type: "delegated", status: "logged_in" }));
371
+ }
372
+ async function authLogout(options) {
373
+ const manager = new AuthManager();
374
+ if (options.all) {
375
+ manager.deleteAllProfiles();
376
+ console.log("All auth profiles removed.");
377
+ return;
378
+ }
379
+ const active = manager.getActiveProfile();
380
+ manager.deleteProfile(active.name);
381
+ console.log(`Profile '${active.name}' removed.`);
382
+ }
383
+
384
+ // src/utils/table.ts
385
+ function renderTable(rows, headers, options) {
386
+ const allRows = headers ? [headers, ...rows] : rows;
387
+ if (allRows.length === 0) return "";
388
+ const colCount = Math.max(...allRows.map((r) => r.length));
389
+ const colWidths = [];
390
+ for (let col = 0; col < colCount; col++) {
391
+ colWidths.push(Math.max(...allRows.map((r) => (r[col] ?? "").length)));
392
+ }
393
+ const lines = [];
394
+ for (let i = 0; i < allRows.length; i++) {
395
+ const row = allRows[i];
396
+ const formatted = row.map((cell, col) => {
397
+ let display = cell;
398
+ if (headers && i === 0 && options?.dimHeaders) {
399
+ display = `\x1B[2m${cell}\x1B[0m`;
400
+ }
401
+ if (col === row.length - 1) return display;
402
+ return display + " ".repeat(Math.max(0, (colWidths[col] ?? 0) + 2 - cell.length));
403
+ }).join("");
404
+ lines.push(formatted);
405
+ if (headers && i === 0) {
406
+ lines.push("-".repeat(colWidths.reduce((sum, w) => sum + w + 2, 0)));
407
+ }
408
+ }
409
+ if (options?.showRowCount) {
410
+ lines.push(`(${rows.length} rows)`);
411
+ }
412
+ return lines.join("\n");
413
+ }
414
+
415
+ // src/commands/auth-list.ts
416
+ async function authList(options) {
417
+ const { authManager } = await createClient();
418
+ const profiles = authManager.listProfiles();
419
+ if (profiles.length === 0) {
420
+ console.log("No auth profiles configured.");
421
+ return;
422
+ }
423
+ if (options.output === "json") {
424
+ console.log(JSON.stringify(profiles.map((p) => ({
425
+ name: p.name,
426
+ active: p.active,
427
+ environmentUrl: p.profile.environmentUrl
428
+ })), null, 2));
429
+ } else {
430
+ const rows = profiles.map((p) => {
431
+ const marker = p.active ? "*" : " ";
432
+ return [marker, p.name, p.profile.environmentUrl];
433
+ });
434
+ console.log(renderTable(rows, [" ", "Name", "URL"]));
435
+ }
436
+ }
437
+
438
+ // src/commands/auth-select.ts
439
+ async function authSelect(profileName) {
440
+ if (!profileName.trim()) {
441
+ throw new ValidationError("Profile name must be a non-empty string");
442
+ }
443
+ const { authManager } = await createClient();
444
+ authManager.selectProfile(profileName);
445
+ console.log(`Active profile switched to '${profileName}'.`);
446
+ }
447
+
448
+ // src/commands/entities.ts
449
+ async function entities(options) {
450
+ const { client } = await createClient();
451
+ const s = createSpinner();
452
+ s.start("Fetching entities...");
453
+ let entityList;
454
+ try {
455
+ entityList = await client.listEntities();
456
+ } catch (err) {
457
+ s.error("Failed to fetch entities");
458
+ throw err;
459
+ }
460
+ s.stop(`Found ${entityList.length} entities`);
461
+ if (options.output === "json") {
462
+ console.log(JSON.stringify(entityList, null, 2));
463
+ } else if (options.output === "ndjson") {
464
+ for (const e of entityList) {
465
+ console.log(JSON.stringify({ name: e.logicalName, displayName: e.displayName, entitySetName: e.entitySetName }));
466
+ }
467
+ } else {
468
+ if (entityList.length === 0) {
469
+ console.log("No entities found.");
470
+ return;
471
+ }
472
+ const rows = entityList.map((e) => [e.logicalName, e.displayName, e.entitySetName]);
473
+ console.log(renderTable(rows, ["LogicalName", "DisplayName", "EntitySetName"]));
474
+ }
475
+ }
476
+
477
+ // src/commands/schema.ts
478
+ async function schema(entityName, options) {
479
+ const { client } = await createClient();
480
+ if (options.refreshAll) {
481
+ client.clearSchemaCache();
482
+ } else if (options.refresh) {
483
+ client.invalidateSchema(entityName);
484
+ }
485
+ const s = createSpinner();
486
+ s.start(`Fetching schema for ${entityName}...`);
487
+ let entry;
488
+ try {
489
+ entry = await client.getEntitySchema(entityName, options.noCache);
490
+ } catch (err) {
491
+ s.error("Failed to fetch schema");
492
+ throw err;
493
+ }
494
+ s.stop(`Schema loaded: ${entry.attributes.length} attributes`);
495
+ if (options.output === "table") {
496
+ const rows = entry.attributes.map((a) => [
497
+ a.logicalName,
498
+ a.displayName,
499
+ a.attributeType,
500
+ a.requiredLevel === "ApplicationRequired" || a.requiredLevel === "SystemRequired" ? "Yes" : "No"
501
+ ]);
502
+ console.log(renderTable(rows, ["LogicalName", "DisplayName", "Type", "Required"]));
503
+ } else {
504
+ console.log(JSON.stringify(entry, null, 2));
505
+ }
506
+ }
507
+
508
+ // src/commands/query.ts
509
+ import { readFileSync } from "fs";
510
+ function renderRecordTable(records) {
511
+ if (records.length === 0) {
512
+ console.log("No records found.");
513
+ return;
514
+ }
515
+ const keys = Object.keys(records[0]).filter((k) => !k.startsWith("@"));
516
+ const rows = records.map((r) => keys.map((k) => String(r[k] ?? "")));
517
+ console.log(renderTable(rows, keys));
518
+ }
519
+ async function query(options) {
520
+ const { client } = await createClient({ dryRun: options.dryRun });
521
+ let fetchXmlContent;
522
+ let entityName;
523
+ if (options.file) {
524
+ const content = readFileSync(options.file, "utf-8");
525
+ if (content.trimStart().startsWith("<")) {
526
+ fetchXmlContent = content;
527
+ } else {
528
+ options.odata = content.trim();
529
+ }
530
+ }
531
+ if (options.fetchxml) {
532
+ fetchXmlContent = options.fetchxml;
533
+ }
534
+ const s = createSpinner();
535
+ if (fetchXmlContent) {
536
+ validateFetchXml(fetchXmlContent);
537
+ const entityMatch = /entity\s+name=["']([^"']+)["']/i.exec(fetchXmlContent);
538
+ entityName = entityMatch?.[1];
539
+ if (!entityName) {
540
+ throw new ValidationError("Could not determine entity name from FetchXML");
541
+ }
542
+ s.start("Querying...");
543
+ if (options.output === "ndjson" || options.pageAll) {
544
+ let count = 0;
545
+ try {
546
+ await client.queryFetchXml(entityName, fetchXmlContent, (record) => {
547
+ count++;
548
+ console.log(JSON.stringify(record));
549
+ });
550
+ } catch (err) {
551
+ s.error("Query failed");
552
+ throw err;
553
+ }
554
+ s.stop(`Query complete: ${count} records`);
555
+ } else {
556
+ let records;
557
+ try {
558
+ records = await client.queryFetchXml(entityName, fetchXmlContent);
559
+ } catch (err) {
560
+ s.error("Query failed");
561
+ throw err;
562
+ }
563
+ s.stop(`Query complete: ${records.length} records`);
564
+ if (options.output === "json") {
565
+ console.log(JSON.stringify(records, null, 2));
566
+ } else {
567
+ renderRecordTable(records);
568
+ }
569
+ }
570
+ return;
571
+ }
572
+ if (!options.odata) {
573
+ throw new ValidationError("Either --odata, --fetchxml, or --file is required");
574
+ }
575
+ const odataParts = options.odata.split("?");
576
+ const entitySetName = odataParts[0] ?? "";
577
+ const odataQuery = odataParts.slice(1).join("?");
578
+ if (!entitySetName) {
579
+ throw new ValidationError(`OData expression must start with the entity set name (e.g., "accounts?$filter=name eq 'test'")`);
580
+ }
581
+ const fields = options.fields?.split(",").map((f) => f.trim());
582
+ s.start("Querying...");
583
+ if (options.output === "ndjson" || options.pageAll) {
584
+ let count = 0;
585
+ try {
586
+ await client.query(entitySetName, odataQuery, {
587
+ fields,
588
+ pageAll: options.pageAll,
589
+ maxRows: options.maxRows,
590
+ onRecord: (record) => {
591
+ count++;
592
+ console.log(JSON.stringify(record));
593
+ },
594
+ onProgress: (info) => {
595
+ s.message(`Page ${info.pageNumber} \u2014 ${info.recordCount} records...`);
596
+ }
597
+ });
598
+ } catch (err) {
599
+ s.error("Query failed");
600
+ throw err;
601
+ }
602
+ s.stop(`Query complete: ${count} records`);
603
+ } else {
604
+ let records;
605
+ try {
606
+ records = await client.query(entitySetName, odataQuery, {
607
+ fields,
608
+ pageAll: false,
609
+ maxRows: options.maxRows,
610
+ onProgress: (info) => {
611
+ s.message(`Page ${info.pageNumber} \u2014 ${info.recordCount} records...`);
612
+ }
613
+ });
614
+ } catch (err) {
615
+ s.error("Query failed");
616
+ throw err;
617
+ }
618
+ s.stop(`Query complete: ${records.length} records`);
619
+ if (options.output === "json") {
620
+ console.log(JSON.stringify(records, null, 2));
621
+ } else {
622
+ renderRecordTable(records);
623
+ }
624
+ }
625
+ }
626
+
627
+ // src/commands/get.ts
628
+ async function get(entityName, id, options) {
629
+ validateEntityName(entityName);
630
+ const validatedId = validateGuid(id);
631
+ const { client } = await createClient();
632
+ const fields = options.fields?.split(",").map((f) => f.trim()).filter((f) => f.length > 0);
633
+ const s = createSpinner();
634
+ s.start(`Fetching ${entityName} ${validatedId}...`);
635
+ let record;
636
+ try {
637
+ record = await client.getRecord(entityName, validatedId, fields);
638
+ } catch (err) {
639
+ s.error("Failed to fetch record");
640
+ throw err;
641
+ }
642
+ s.stop("Record loaded");
643
+ if (options.output === "json") {
644
+ console.log(JSON.stringify(record, null, 2));
645
+ } else {
646
+ const entries = Object.entries(record).filter(([k]) => !k.startsWith("@"));
647
+ if (entries.length === 0) {
648
+ console.log("No fields returned.");
649
+ return;
650
+ }
651
+ const rows = entries.map(([key, value]) => [key, String(value ?? "")]);
652
+ console.log(renderTable(rows, ["Field", "Value"]));
653
+ }
654
+ }
655
+
656
+ // src/utils/parse-json.ts
657
+ function parseJsonPayload(raw) {
658
+ let parsed;
659
+ try {
660
+ parsed = JSON.parse(raw);
661
+ } catch {
662
+ throw new ValidationError("Invalid JSON payload");
663
+ }
664
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
665
+ throw new ValidationError("JSON payload must be an object");
666
+ }
667
+ return parsed;
668
+ }
669
+
670
+ // src/utils/output.ts
671
+ function formatMutationResult(result, opts) {
672
+ const payload = result ?? (opts.id ? { ok: true, id: opts.id } : { ok: true });
673
+ if (opts.format === "json") {
674
+ console.log(JSON.stringify(payload));
675
+ } else {
676
+ const entries = Object.entries(payload);
677
+ if (entries.length === 0) {
678
+ console.log("ok");
679
+ } else {
680
+ for (const [k, v] of entries) {
681
+ console.log(`${k}: ${String(v)}`);
682
+ }
683
+ }
684
+ }
685
+ }
686
+
687
+ // src/commands/create.ts
688
+ async function createRecord(entityName, options) {
689
+ validateEntityName(entityName);
690
+ const { client } = await createClient({ dryRun: options.dryRun, callerObjectId: options.callerObjectId });
691
+ const data = parseJsonPayload(options.json);
692
+ const s = createSpinner();
693
+ s.start(`Creating ${entityName}...`);
694
+ let id;
695
+ try {
696
+ id = await client.createRecord(entityName, data);
697
+ } catch (err) {
698
+ s.error("Create failed");
699
+ throw err;
700
+ }
701
+ s.stop(`Created ${entityName}`);
702
+ logMutationSuccess(`Created ${entityName} ${id}`);
703
+ formatMutationResult(null, { format: options.output ?? "table", id });
704
+ }
705
+
706
+ // src/commands/update.ts
707
+ async function updateRecord(entityName, id, options) {
708
+ validateEntityName(entityName);
709
+ validateGuid(id);
710
+ const { client } = await createClient({ dryRun: options.dryRun, callerObjectId: options.callerObjectId });
711
+ const data = parseJsonPayload(options.json);
712
+ const s = createSpinner();
713
+ s.start(`Updating ${entityName} ${id}...`);
714
+ try {
715
+ await client.updateRecord(entityName, id, data);
716
+ } catch (err) {
717
+ s.error("Update failed");
718
+ throw err;
719
+ }
720
+ s.stop(`Updated ${entityName}`);
721
+ logMutationSuccess(`Updated ${entityName} ${id}`);
722
+ formatMutationResult(null, { format: options.output ?? "table" });
723
+ }
724
+
725
+ // src/commands/upsert.ts
726
+ async function upsertRecord(entityName, options) {
727
+ validateEntityName(entityName);
728
+ const { client } = await createClient({ dryRun: options.dryRun, callerObjectId: options.callerObjectId });
729
+ const data = parseJsonPayload(options.json);
730
+ const matchValue = data[options.matchField];
731
+ if (matchValue === void 0) {
732
+ throw new ValidationError(`Match field '${options.matchField}' not found in JSON payload`);
733
+ }
734
+ const schema2 = await client.getEntitySchema(entityName);
735
+ const attr = schema2.attributes.find((a) => a.logicalName === options.matchField);
736
+ if (!attr) {
737
+ throw new ValidationError(`Unknown field: ${options.matchField}`);
738
+ }
739
+ const escapedValue = typeof matchValue === "string" ? `'${matchValue.replace(/'/g, "''")}'` : String(matchValue);
740
+ const odata = `$filter=${options.matchField} eq ${escapedValue}&$select=${schema2.primaryIdAttribute}`;
741
+ const s = createSpinner();
742
+ s.start(`Upserting ${entityName}...`);
743
+ try {
744
+ const records = await client.query(schema2.entitySetName, odata, { pageAll: false, maxRows: 1 });
745
+ if (records.length > 0) {
746
+ const existingId = String(records[0][schema2.primaryIdAttribute]);
747
+ await client.updateRecord(entityName, existingId, data);
748
+ s.stop(`Upserted ${entityName}`);
749
+ logMutationSuccess(`Upserted ${entityName} ${existingId} (updated)`);
750
+ formatMutationResult({ action: "updated", id: existingId }, { format: options.output ?? "table", id: existingId });
751
+ } else {
752
+ const id = await client.createRecord(entityName, data);
753
+ s.stop(`Upserted ${entityName}`);
754
+ logMutationSuccess(`Upserted ${entityName} ${id} (created)`);
755
+ formatMutationResult({ action: "created", id }, { format: options.output ?? "table", id });
756
+ }
757
+ } catch (err) {
758
+ s.error("Upsert failed");
759
+ throw err;
760
+ }
761
+ }
762
+
763
+ // src/commands/delete.ts
764
+ async function deleteRecord(entityName, id, options) {
765
+ validateEntityName(entityName);
766
+ validateGuid(id);
767
+ if (!options.confirm && !options.dryRun) {
768
+ if (isInteractive()) {
769
+ const confirmed = await promptConfirmClack(`Delete record '${id}' from '${entityName}'?`);
770
+ if (!confirmed) {
771
+ process.stderr.write("Aborted\n");
772
+ return;
773
+ }
774
+ } else {
775
+ throw new ValidationError("Non-interactive mode requires --confirm flag for delete operations");
776
+ }
777
+ }
778
+ const { client } = await createClient({ dryRun: options.dryRun, callerObjectId: options.callerObjectId });
779
+ const s = createSpinner();
780
+ s.start(`Deleting ${entityName} ${id}...`);
781
+ try {
782
+ await client.deleteRecord(entityName, id);
783
+ } catch (err) {
784
+ s.error("Delete failed");
785
+ throw err;
786
+ }
787
+ s.stop(`Deleted ${entityName}`);
788
+ logMutationSuccess(`Deleted ${id} from ${entityName}`);
789
+ formatMutationResult(null, { format: options.output ?? "table" });
790
+ }
791
+
792
+ // src/commands/batch.ts
793
+ import { readFile } from "fs/promises";
794
+ import { z } from "zod";
795
+ var BatchOperationSchema = z.object({
796
+ method: z.enum(["GET", "POST", "PATCH", "DELETE"]),
797
+ path: z.string(),
798
+ headers: z.record(z.string(), z.string()).optional(),
799
+ body: z.unknown().optional(),
800
+ contentId: z.string().optional()
801
+ });
802
+ var BatchFileSchema = z.array(BatchOperationSchema);
803
+ async function batch(options) {
804
+ const { client } = await createClient({ dryRun: options.dryRun, callerObjectId: options.callerObjectId });
805
+ const content = await readFile(options.file, "utf-8");
806
+ let parsed;
807
+ try {
808
+ parsed = JSON.parse(content);
809
+ } catch {
810
+ throw new ValidationError("Invalid JSON in batch file");
811
+ }
812
+ const operations = BatchFileSchema.parse(parsed);
813
+ const chunks = chunkArray(operations, 1e3);
814
+ const totalOps = operations.length;
815
+ const s = createSpinner();
816
+ for (let i = 0; i < chunks.length; i++) {
817
+ const chunk = chunks[i];
818
+ const boundary = `batch_dvx_${Date.now()}_${i}`;
819
+ s.start(`Chunk ${i + 1}/${chunks.length} (${chunk.length} operations)...`);
820
+ let result;
821
+ try {
822
+ const body = buildBatchBody(chunk, boundary, { atomic: options.atomic });
823
+ result = await client.executeBatch(body, boundary);
824
+ } catch (err) {
825
+ s.error(`Chunk ${i + 1}/${chunks.length} failed`);
826
+ throw err;
827
+ }
828
+ s.stop(`Chunk ${i + 1}/${chunks.length} complete`);
829
+ formatMutationResult(
830
+ { chunk: i + 1, totalChunks: chunks.length, operations: chunk.length, responseLength: result.length },
831
+ { format: options.output ?? "table" }
832
+ );
833
+ }
834
+ logSuccess(`Batch complete: ${totalOps} operations across ${chunks.length} chunk${chunks.length === 1 ? "" : "s"}`);
835
+ }
836
+
837
+ // src/commands/action.ts
838
+ async function actionCommand(actionName, options) {
839
+ if (!!options.entity !== !!options.id) {
840
+ throw new ValidationError("--entity and --id must both be provided for bound actions");
841
+ }
842
+ const payload = parseJsonPayload(options.json);
843
+ const { client } = await createClient({ dryRun: options.dryRun, callerObjectId: options.callerObjectId });
844
+ const s = createSpinner();
845
+ s.start(`Executing ${actionName}...`);
846
+ let result;
847
+ try {
848
+ result = await client.executeAction(actionName, payload, {
849
+ entityName: options.entity,
850
+ id: options.id
851
+ });
852
+ } catch (err) {
853
+ s.error("Action failed");
854
+ throw err;
855
+ }
856
+ s.stop("Action complete");
857
+ logMutationSuccess(`Executed ${actionName}`);
858
+ const resultRecord = result && typeof result === "object" && !Array.isArray(result) ? result : { result: JSON.stringify(result) };
859
+ formatMutationResult(resultRecord, { format: options.output ?? "table" });
860
+ }
861
+
862
+ // src/commands/demo.ts
863
+ import * as clack2 from "@clack/prompts";
864
+ var DEMO_PREFIX = "[dvx-demo]";
865
+ var TIER_ORDER = ["read", "write", "full"];
866
+ function callout(msg) {
867
+ const text2 = `\u26A1 dvx advantage: ${msg}`;
868
+ if (isInteractive()) {
869
+ clack2.log.info(text2);
870
+ } else {
871
+ process.stderr.write(`${text2}
872
+ `);
873
+ }
874
+ }
875
+ async function selectTier(options) {
876
+ if (options.tier) return options.tier;
877
+ if (!isInteractive()) {
878
+ throw new ValidationError("--tier is required in non-interactive mode (choices: read, write, full)");
879
+ }
880
+ const result = await clack2.select({
881
+ message: "Select demo tier",
882
+ options: [
883
+ { value: "read", label: "Read", hint: "Schema discovery, queries, FetchXML \u2014 no data changes" },
884
+ { value: "write", label: "Write", hint: "Read + CRUD lifecycle with auto-cleanup" },
885
+ { value: "full", label: "Full", hint: "Write + batch, actions, impersonation, aggregation" }
886
+ ]
887
+ });
888
+ if (clack2.isCancel(result)) {
889
+ clack2.cancel("Demo cancelled.");
890
+ process.exit(0);
891
+ }
892
+ return result;
893
+ }
894
+ async function checkOpportunityAvailable(client) {
895
+ try {
896
+ await client.getEntitySchema("opportunity");
897
+ return true;
898
+ } catch (err) {
899
+ if (err instanceof EntityNotFoundError) return false;
900
+ if (err instanceof DataverseError && (err.statusCode === 404 || err.message.includes("does not exist"))) return false;
901
+ throw err;
902
+ }
903
+ }
904
+ function logData(text2) {
905
+ if (isInteractive()) {
906
+ clack2.log.step(text2);
907
+ } else {
908
+ console.log(text2);
909
+ }
910
+ }
911
+ function formatRecords(records, maxRows = 5) {
912
+ if (records.length === 0) return "(no records)";
913
+ const subset = records.slice(0, maxRows);
914
+ const keys = Object.keys(subset[0]);
915
+ const rows = subset.map((r) => keys.map((k) => String(r[k] ?? "")));
916
+ let out = renderTable(rows, keys, { dimHeaders: true });
917
+ if (records.length > maxRows) out += `
918
+ ... and ${records.length - maxRows} more`;
919
+ return out;
920
+ }
921
+ async function runStep(step, ctx) {
922
+ const s = createSpinner();
923
+ const start = performance.now();
924
+ try {
925
+ s.start(step.name);
926
+ const output = await step.run(ctx, s);
927
+ s.stop(step.name);
928
+ if (output) logData(output);
929
+ callout(step.callout);
930
+ return { name: step.name, status: "pass", elapsedMs: Math.round(performance.now() - start) };
931
+ } catch (err) {
932
+ const msg = err instanceof Error ? err.message : String(err);
933
+ s.error(`${step.name} \u2014 ${msg}`);
934
+ if (err instanceof ImpersonationPrivilegeError) {
935
+ return { name: step.name, status: "skip", elapsedMs: Math.round(performance.now() - start), error: msg };
936
+ }
937
+ return { name: step.name, status: "fail", elapsedMs: Math.round(performance.now() - start), error: msg };
938
+ }
939
+ }
940
+ async function cleanup(client, createdIds) {
941
+ const entries = [...createdIds.entries()].reverse();
942
+ for (const [entity, id] of entries) {
943
+ try {
944
+ await client.deleteRecord(entity, id);
945
+ } catch {
946
+ logWarn(`Cleanup: failed to delete ${entity} ${id}`);
947
+ }
948
+ }
949
+ try {
950
+ const orphans = await client.query("accounts", `$filter=startswith(name,'${DEMO_PREFIX}')&$select=accountid`);
951
+ for (const orphan of orphans) {
952
+ const id = orphan["accountid"];
953
+ if (id) {
954
+ try {
955
+ await client.deleteRecord("account", id);
956
+ } catch {
957
+ logWarn(`Cleanup: failed to delete orphan account ${id}`);
958
+ }
959
+ }
960
+ }
961
+ } catch {
962
+ logWarn("Cleanup: orphan sweep failed");
963
+ }
964
+ try {
965
+ const orphanContacts = await client.query("contacts", `$filter=startswith(firstname,'${DEMO_PREFIX}')&$select=contactid`);
966
+ for (const orphan of orphanContacts) {
967
+ const id = orphan["contactid"];
968
+ if (id) {
969
+ try {
970
+ await client.deleteRecord("contact", id);
971
+ } catch {
972
+ logWarn(`Cleanup: failed to delete orphan contact ${id}`);
973
+ }
974
+ }
975
+ }
976
+ } catch {
977
+ logWarn("Cleanup: contact orphan sweep failed");
978
+ }
979
+ }
980
+ function renderSummary(results) {
981
+ const rows = results.map((r) => {
982
+ let status;
983
+ switch (r.status) {
984
+ case "pass":
985
+ status = "\x1B[32mPASS\x1B[0m";
986
+ break;
987
+ case "skip":
988
+ status = "\x1B[33mSKIP\x1B[0m";
989
+ break;
990
+ case "fail":
991
+ status = "\x1B[31mFAIL\x1B[0m";
992
+ break;
993
+ }
994
+ return [r.name, status, `${r.elapsedMs}ms`];
995
+ });
996
+ console.log(renderTable(rows, ["Demo", "Status", "Elapsed"], { dimHeaders: true }));
997
+ }
998
+ var ALL_DEMO_STEPS = [
999
+ // === Read tier ===
1000
+ {
1001
+ name: "List entities",
1002
+ tier: "read",
1003
+ callout: "Full entity catalog with display names \u2014 native MCP only exposes pre-configured entities",
1004
+ async run(ctx, s) {
1005
+ const list = await ctx.client.listEntities();
1006
+ s.message(`Found ${list.length} entities`);
1007
+ const sample = list.slice(0, 8);
1008
+ const rows = sample.map((e) => [e.logicalName, e.displayName, e.entitySetName]);
1009
+ let out = renderTable(rows, ["Logical Name", "Display Name", "Entity Set"], { dimHeaders: true });
1010
+ if (list.length > 8) out += `
1011
+ ... and ${list.length - 8} more`;
1012
+ return out;
1013
+ }
1014
+ },
1015
+ {
1016
+ name: "Schema introspection",
1017
+ tier: "read",
1018
+ callout: "Live schema with attribute types and required levels \u2014 native MCP has no schema introspection",
1019
+ async run(ctx, s) {
1020
+ const schema2 = await ctx.client.getEntitySchema("account");
1021
+ s.message(`account: ${schema2.attributes.length} attributes`);
1022
+ const sample = schema2.attributes.slice(0, 8);
1023
+ const rows = sample.map((a) => [a.logicalName, a.attributeType, a.requiredLevel]);
1024
+ let out = renderTable(rows, ["Attribute", "Type", "Required"], { dimHeaders: true });
1025
+ if (schema2.attributes.length > 8) out += `
1026
+ ... and ${schema2.attributes.length - 8} more`;
1027
+ return out;
1028
+ }
1029
+ },
1030
+ // === Write tier (create data before read steps query it) ===
1031
+ {
1032
+ name: "Create account",
1033
+ tier: "write",
1034
+ callout: "Create with auto-schema resolution \u2014 dvx maps logical name to entity set automatically",
1035
+ async run(ctx) {
1036
+ const id = await ctx.client.createRecord("account", {
1037
+ name: `${DEMO_PREFIX} Contoso Ltd`,
1038
+ description: "Demo account created by dvx demo \u2014 will be auto-cleaned"
1039
+ });
1040
+ ctx.createdIds.set("account", id);
1041
+ return `Created account ${id}`;
1042
+ }
1043
+ },
1044
+ {
1045
+ name: "Update account",
1046
+ tier: "write",
1047
+ callout: "Partial update via PATCH \u2014 only changed fields sent, not full record replacement",
1048
+ async run(ctx) {
1049
+ const id = ctx.createdIds.get("account");
1050
+ if (!id) throw new Error("No account to update \u2014 create step must run first");
1051
+ await ctx.client.updateRecord("account", id, {
1052
+ websiteurl: "https://dvx.dev",
1053
+ telephone1: "+1-555-DVX-DEMO"
1054
+ });
1055
+ return `Updated ${id}: websiteurl, telephone1`;
1056
+ }
1057
+ },
1058
+ {
1059
+ name: "Upsert contact",
1060
+ tier: "write",
1061
+ callout: "Upsert with alternate key matching \u2014 dvx resolves match fields automatically",
1062
+ async run(ctx) {
1063
+ const accountId = ctx.createdIds.get("account");
1064
+ const id = await ctx.client.createRecord("contact", {
1065
+ firstname: `${DEMO_PREFIX}`,
1066
+ lastname: "Demo User",
1067
+ emailaddress1: "demo@dvx.dev",
1068
+ ...accountId ? { "parentcustomerid_account@odata.bind": `/accounts(${accountId})` } : {}
1069
+ });
1070
+ ctx.createdIds.set("contact", id);
1071
+ return `Created contact ${id}${accountId ? ` (linked to account ${accountId})` : ""}`;
1072
+ }
1073
+ },
1074
+ // === Read tier (queries — run after write tier creates data) ===
1075
+ {
1076
+ name: "OData query",
1077
+ tier: "read",
1078
+ callout: "Full OData $filter/$orderby/$select/$expand \u2014 native MCP limited to basic retrieval",
1079
+ async run(ctx, s) {
1080
+ const results = await ctx.client.query("accounts", "$filter=startswith(name,'[dvx-demo]')&$orderby=name&$top=5&$select=name,accountid");
1081
+ s.message(`${results.length} account(s) matched`);
1082
+ return formatRecords(results);
1083
+ }
1084
+ },
1085
+ {
1086
+ name: "FetchXML with joins",
1087
+ tier: "read",
1088
+ callout: "FetchXML with linked entities and paging \u2014 native MCP has no FetchXML support",
1089
+ async run(ctx, s) {
1090
+ const fetchXml = `<fetch top="5">
1091
+ <entity name="account">
1092
+ <attribute name="name" />
1093
+ <attribute name="accountid" />
1094
+ <filter>
1095
+ <condition attribute="name" operator="like" value="${DEMO_PREFIX}%" />
1096
+ </filter>
1097
+ <link-entity name="contact" from="parentcustomerid" to="accountid" link-type="outer">
1098
+ <attribute name="fullname" />
1099
+ </link-entity>
1100
+ </entity>
1101
+ </fetch>`;
1102
+ const results = await ctx.client.queryFetchXml("account", fetchXml);
1103
+ s.message(`${results.length} record(s) with linked contacts`);
1104
+ return formatRecords(results);
1105
+ }
1106
+ },
1107
+ {
1108
+ name: "Get single record",
1109
+ tier: "read",
1110
+ callout: "Field-level $select on single record \u2014 native MCP returns all fields always",
1111
+ async run(ctx, s) {
1112
+ const list = await ctx.client.query("accounts", "$top=1&$select=accountid,name");
1113
+ if (list.length === 0) {
1114
+ s.message("No accounts available");
1115
+ return;
1116
+ }
1117
+ const id = list[0]["accountid"];
1118
+ const record = await ctx.client.getRecord("account", id, ["name", "createdon"]);
1119
+ s.message(`Retrieved: ${record["name"] ?? id}`);
1120
+ const keys = Object.keys(record);
1121
+ const rows = keys.map((k) => [k, String(record[k] ?? "")]);
1122
+ return renderTable(rows, ["Field", "Value"], { dimHeaders: true });
1123
+ }
1124
+ },
1125
+ // === Write tier (delete) ===
1126
+ {
1127
+ name: "Delete record",
1128
+ tier: "write",
1129
+ callout: "Delete with GUID validation and confirmation \u2014 dvx validates before sending to API",
1130
+ async run(ctx) {
1131
+ const id = ctx.createdIds.get("account");
1132
+ if (!id) throw new Error("No account to delete \u2014 create step must run first");
1133
+ await ctx.client.deleteRecord("account", id);
1134
+ ctx.createdIds.delete("account");
1135
+ return `Deleted account ${id}`;
1136
+ }
1137
+ },
1138
+ // === Full tier ===
1139
+ {
1140
+ name: "Batch atomic create",
1141
+ tier: "full",
1142
+ callout: "Atomic batch with changeset \u2014 all-or-nothing, 1000 ops/request \u2014 native MCP has no batch",
1143
+ async run(ctx) {
1144
+ const ops = Array.from({ length: 5 }, (_, i) => ({
1145
+ method: "POST",
1146
+ path: "accounts",
1147
+ body: { name: `${DEMO_PREFIX} Batch ${i + 1}` }
1148
+ }));
1149
+ const boundary = `batch_dvx_demo_${Date.now()}`;
1150
+ const body = buildBatchBody(ops, boundary, { atomic: true });
1151
+ await ctx.client.executeBatch(body, boundary);
1152
+ return `5 accounts created atomically in a single changeset`;
1153
+ }
1154
+ },
1155
+ {
1156
+ name: "WhoAmI action",
1157
+ tier: "full",
1158
+ callout: "Custom action execution \u2014 call any action/SDK message \u2014 native MCP cannot call actions",
1159
+ async run(ctx, s) {
1160
+ const result = await ctx.client.executeAction("WhoAmI", {});
1161
+ const userId = result["UserId"];
1162
+ if (userId) s.message(`Authenticated as ${userId}`);
1163
+ const keys = Object.keys(result);
1164
+ const rows = keys.map((k) => [k, String(result[k] ?? "")]);
1165
+ return renderTable(rows, ["Field", "Value"], { dimHeaders: true });
1166
+ }
1167
+ },
1168
+ {
1169
+ name: "Impersonated query",
1170
+ tier: "full",
1171
+ callout: "CallerObjectId impersonation \u2014 run as another user for audit trails",
1172
+ async run(ctx, s) {
1173
+ const whoami = await ctx.client.executeAction("WhoAmI", {});
1174
+ const userId = whoami["UserId"];
1175
+ if (!userId) {
1176
+ s.message("Could not determine user ID \u2014 skipping impersonation");
1177
+ return;
1178
+ }
1179
+ const { client: impersonatedClient } = await createClient({ callerObjectId: userId });
1180
+ const results = await impersonatedClient.query("accounts", "$top=1&$select=name");
1181
+ s.message(`Impersonated query returned ${results.length} record(s)`);
1182
+ return formatRecords(results);
1183
+ }
1184
+ },
1185
+ {
1186
+ name: "FetchXML aggregation",
1187
+ tier: "full",
1188
+ callout: "FetchXML aggregation with groupby \u2014 server-side analytics, not possible via native MCP",
1189
+ async run(ctx, s) {
1190
+ const fetchXml = `<fetch aggregate="true">
1191
+ <entity name="account">
1192
+ <attribute name="statecode" groupby="true" alias="state" />
1193
+ <attribute name="accountid" aggregate="count" alias="count" />
1194
+ </entity>
1195
+ </fetch>`;
1196
+ const results = await ctx.client.queryFetchXml("account", fetchXml);
1197
+ s.message(`${results.length} state group(s)`);
1198
+ return formatRecords(results);
1199
+ }
1200
+ }
1201
+ ];
1202
+ async function demo(options) {
1203
+ const selectedTier = await selectTier(options);
1204
+ if (isInteractive()) {
1205
+ clack2.intro(`dvx demo \u2014 ${selectedTier} tier`);
1206
+ }
1207
+ const { client } = await createClient();
1208
+ const hasOpportunity = await checkOpportunityAvailable(client);
1209
+ const maxIdx = TIER_ORDER.indexOf(selectedTier);
1210
+ const activeSteps = ALL_DEMO_STEPS.filter((s) => TIER_ORDER.indexOf(s.tier) <= maxIdx);
1211
+ const ctx = { client, createdIds: /* @__PURE__ */ new Map(), hasOpportunity };
1212
+ const results = [];
1213
+ const totalStart = performance.now();
1214
+ try {
1215
+ for (const step of activeSteps) {
1216
+ const result = await runStep(step, ctx);
1217
+ results.push(result);
1218
+ }
1219
+ } finally {
1220
+ if (ctx.createdIds.size > 0) {
1221
+ const s = createSpinner();
1222
+ s.start("Cleaning up demo data...");
1223
+ await cleanup(client, ctx.createdIds);
1224
+ s.stop("Cleanup complete");
1225
+ }
1226
+ if (TIER_ORDER.indexOf(selectedTier) >= TIER_ORDER.indexOf("full")) {
1227
+ const s = createSpinner();
1228
+ s.start("Sweeping orphan demo records...");
1229
+ await cleanup(client, /* @__PURE__ */ new Map());
1230
+ s.stop("Orphan sweep complete");
1231
+ }
1232
+ }
1233
+ const totalMs = Math.round(performance.now() - totalStart);
1234
+ const passed = results.filter((r) => r.status === "pass").length;
1235
+ if (options.output === "json") {
1236
+ console.log(JSON.stringify({
1237
+ tier: selectedTier,
1238
+ totalMs,
1239
+ passed,
1240
+ total: results.length,
1241
+ results: results.map((r) => ({ name: r.name, status: r.status, elapsedMs: r.elapsedMs, ...r.error ? { error: r.error } : {} }))
1242
+ }, null, 2));
1243
+ } else {
1244
+ console.log("");
1245
+ renderSummary(results);
1246
+ }
1247
+ if (isInteractive()) {
1248
+ clack2.outro(`${passed}/${results.length} demos passed in ${totalMs}ms`);
1249
+ }
1250
+ }
1251
+
1252
+ // src/commands/completion.ts
1253
+ var COMMANDS = ["auth", "entities", "schema", "query", "get", "create", "update", "upsert", "delete", "batch", "action", "mcp", "init", "completion"];
1254
+ var BASH_COMPLETION = `
1255
+ # dvx bash completion
1256
+ _dvx_completion() {
1257
+ local cur prev words
1258
+ COMPREPLY=()
1259
+ cur="\${COMP_WORDS[COMP_CWORD]}"
1260
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
1261
+
1262
+ local commands="${COMMANDS.join(" ")}"
1263
+ local auth_commands="create select login list"
1264
+
1265
+ case "\${prev}" in
1266
+ dvx) COMPREPLY=($(compgen -W "\${commands}" -- "\${cur}")) ;;
1267
+ auth) COMPREPLY=($(compgen -W "\${auth_commands}" -- "\${cur}")) ;;
1268
+ completion) COMPREPLY=($(compgen -W "bash zsh powershell" -- "\${cur}")) ;;
1269
+ *) COMPREPLY=($(compgen -f -- "\${cur}")) ;;
1270
+ esac
1271
+ }
1272
+ complete -F _dvx_completion dvx
1273
+ `.trim();
1274
+ var ZSH_COMPLETION = `
1275
+ #compdef dvx
1276
+ _dvx() {
1277
+ local state
1278
+ _arguments \\
1279
+ '1: :->cmd' \\
1280
+ '*: :->args'
1281
+ case $state in
1282
+ cmd) _values 'commands' ${COMMANDS.join(" ")} ;;
1283
+ args) case $words[2] in
1284
+ auth) _values 'auth commands' create select login list ;;
1285
+ completion) _values 'shells' bash zsh powershell ;;
1286
+ esac ;;
1287
+ esac
1288
+ }
1289
+ _dvx "$@"
1290
+ `.trim();
1291
+ var POWERSHELL_COMPLETION = `
1292
+ Register-ArgumentCompleter -Native -CommandName dvx -ScriptBlock {
1293
+ param($wordToComplete, $commandAst, $cursorPosition)
1294
+ $commands = @(${COMMANDS.map((c) => `'${c}'`).join(",")})
1295
+ $commands | Where-Object { $_ -like "\${wordToComplete}*" } | ForEach-Object {
1296
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
1297
+ }
1298
+ }
1299
+ `.trim();
1300
+ function completion(shell) {
1301
+ switch (shell) {
1302
+ case "bash":
1303
+ console.log(BASH_COMPLETION);
1304
+ break;
1305
+ case "zsh":
1306
+ console.log(ZSH_COMPLETION);
1307
+ break;
1308
+ case "powershell":
1309
+ console.log(POWERSHELL_COMPLETION);
1310
+ break;
1311
+ }
1312
+ }
1313
+
1314
+ // src/index.ts
1315
+ import { createRequire } from "module";
1316
+ var require2 = createRequire(import.meta.url);
1317
+ var { version } = require2("../package.json");
1318
+ var BANNER = `
1319
+ \x1B[36m{_____ {__ {__\x1B[0m\x1B[33m{__ {__\x1B[0m
1320
+ \x1B[36m{__ {__ {__ {__ \x1B[0m\x1B[33m{__ {__\x1B[0m
1321
+ \x1B[36m{__ {__ {__ {__ \x1B[0m\x1B[33m{__ {__\x1B[0m
1322
+ \x1B[36m{__ {__ {__ {__ \x1B[0m\x1B[33m {__\x1B[0m
1323
+ \x1B[36m{__ {__ {__ {__ \x1B[0m\x1B[33m{__ {__\x1B[0m \x1B[2mAgent-first CLI/MCP for\x1B[0m
1324
+ \x1B[36m{__ {__ {____ \x1B[0m\x1B[33m{__ {__\x1B[0m \x1B[2mMicrosoft Dataverse\x1B[0m
1325
+ \x1B[36m{_____ {__ \x1B[0m\x1B[33m{__ {__\x1B[0m \x1B[2mv${version}\x1B[0m
1326
+ `;
1327
+ var program = new Command();
1328
+ program.name("dvx").description("Agent-first CLI for Microsoft Dataverse CE/Sales/Service").version(version).option("--no-color", "Disable color output").option("--quiet", "Suppress all progress output").addHelpText("before", () => {
1329
+ const opts = program.opts();
1330
+ return opts.color === false ? stripAnsi(BANNER) : BANNER;
1331
+ }).action(() => {
1332
+ program.outputHelp();
1333
+ });
1334
+ program.hook("preAction", (thisCommand) => {
1335
+ const opts = thisCommand.optsWithGlobals();
1336
+ setUxOptions({
1337
+ quiet: opts.quiet ?? false,
1338
+ noColor: opts.color === false
1339
+ });
1340
+ });
1341
+ var auth = program.command("auth").description("Manage authentication profiles");
1342
+ auth.command("login").description("Sign in to a Dataverse environment").option("--name <name>", "Profile name", "default").option("--url <url>", "Dataverse environment URL (auto-discovered if omitted)").option("--tenant-id <id>", "Entra tenant ID (auto-detected from sign-in if omitted)").option("--client-id <id>", "App registration client ID (auto-created if omitted)").option("--service-principal", "Use service principal (client credentials) auth").option("--client-secret <secret>", "Client secret (prefer DATAVERSE_CLIENT_SECRET env var)").action(async (opts) => {
1343
+ await authLogin({
1344
+ name: opts.name,
1345
+ url: opts.url,
1346
+ tenantId: opts.tenantId,
1347
+ clientId: opts.clientId,
1348
+ clientSecret: opts.clientSecret,
1349
+ servicePrincipal: opts.servicePrincipal
1350
+ });
1351
+ });
1352
+ auth.command("logout").description("Remove auth profile").option("--all", "Remove all profiles").action(async (opts) => {
1353
+ await authLogout({ all: opts.all });
1354
+ });
1355
+ auth.command("list").alias("status").description("List all authentication profiles").addOption(new Option("--output <format>", "Output format").choices(["json", "table"]).default("table")).action(async (opts) => {
1356
+ await authList({ output: opts.output });
1357
+ });
1358
+ auth.command("select").description("Switch active authentication profile").argument("<profile>", "Profile name to activate").action(async (profileName) => {
1359
+ await authSelect(profileName);
1360
+ });
1361
+ program.command("entities").description("List all entities in the environment").addOption(new Option("--output <format>", "Output format").choices(["json", "table", "ndjson"]).default("table")).action(async (opts) => {
1362
+ await entities({ output: opts.output });
1363
+ });
1364
+ program.command("schema").description("Get entity schema with attribute definitions").argument("<entity>", "Entity logical name").addOption(new Option("--output <format>", "Output format").choices(["json", "table"]).default("json")).option("--no-cache", "Force live fetch, bypass cache").option("--refresh", "Invalidate cached schema for this entity before fetching").option("--refresh-all", "Clear entire schema cache before fetching").action(async (entityName, opts) => {
1365
+ await schema(entityName, { output: opts.output, noCache: !opts.cache, refresh: opts.refresh, refreshAll: opts.refreshAll });
1366
+ });
1367
+ program.command("query").description("Query records using OData or FetchXML").option("--odata <expression>", "OData query expression (entitySetName?$filter=...)").option("--fetchxml <xml>", "FetchXML query string").option("--file <path>", "Read query from file (auto-detects OData or FetchXML)").option("--fields <fields>", "Comma-separated field names to select").option("--page-all", "Stream all pages as NDJSON", false).option("--max-rows <n>", "Maximum rows to return", parseInt).option("--dry-run", "Preview the operation without executing", false).addOption(new Option("--output <format>", "Output format").choices(["json", "ndjson", "table"]).default("json")).action(async (opts) => {
1368
+ await query({
1369
+ odata: opts.odata,
1370
+ fetchxml: opts.fetchxml,
1371
+ file: opts.file,
1372
+ fields: opts.fields,
1373
+ pageAll: opts.pageAll,
1374
+ maxRows: opts.maxRows,
1375
+ output: opts.output,
1376
+ dryRun: opts.dryRun
1377
+ });
1378
+ });
1379
+ program.command("get").description("Get a single record by ID").argument("<entity>", "Entity logical name").argument("<id>", "Record GUID").option("--fields <fields>", "Comma-separated field names to select").addOption(new Option("--output <format>", "Output format").choices(["json", "table"]).default("json")).action(async (entityName, id, opts) => {
1380
+ await get(entityName, id, { fields: opts.fields, output: opts.output });
1381
+ });
1382
+ program.command("create").description("Create a new record").argument("<entity>", "Entity logical name").requiredOption("--json <data>", "JSON payload for the record").option("--dry-run", "Preview the operation without executing", false).option("--as-user <id>", "Run as this Entra user object ID (CallerObjectId)").addOption(new Option("--output <format>", "Output format: json|table").choices(["json", "table"]).default("table")).action(async (entityName, opts) => {
1383
+ await createRecord(entityName, { json: opts.json, dryRun: opts.dryRun, callerObjectId: opts.asUser, output: opts.output });
1384
+ });
1385
+ program.command("update").description("Update an existing record").argument("<entity>", "Entity logical name").argument("<id>", "Record GUID").requiredOption("--json <data>", "JSON payload with fields to update").option("--dry-run", "Preview the operation without executing", false).option("--as-user <id>", "Run as this Entra user object ID (CallerObjectId)").addOption(new Option("--output <format>", "Output format: json|table").choices(["json", "table"]).default("table")).action(async (entityName, id, opts) => {
1386
+ await updateRecord(entityName, id, { json: opts.json, dryRun: opts.dryRun, callerObjectId: opts.asUser, output: opts.output });
1387
+ });
1388
+ program.command("upsert").description("Create or update a record based on a match field").argument("<entity>", "Entity logical name").requiredOption("--match-field <field>", "Field to match on for upsert").requiredOption("--json <data>", "JSON payload for the record").option("--dry-run", "Preview the operation without executing", false).option("--as-user <id>", "Run as this Entra user object ID (CallerObjectId)").addOption(new Option("--output <format>", "Output format: json|table").choices(["json", "table"]).default("table")).action(async (entityName, opts) => {
1389
+ await upsertRecord(entityName, { matchField: opts.matchField, json: opts.json, dryRun: opts.dryRun, callerObjectId: opts.asUser, output: opts.output });
1390
+ });
1391
+ program.command("delete").description("Delete a record by ID").argument("<entity>", "Entity logical name").argument("<id>", "Record GUID").option("--confirm", "Skip confirmation prompt", false).option("--dry-run", "Preview the operation without executing", false).option("--as-user <id>", "Run as this Entra user object ID (CallerObjectId)").addOption(new Option("--output <format>", "Output format: json|table").choices(["json", "table"]).default("table")).action(async (entityName, id, opts) => {
1392
+ await deleteRecord(entityName, id, { confirm: opts.confirm, dryRun: opts.dryRun, callerObjectId: opts.asUser, output: opts.output });
1393
+ });
1394
+ program.command("batch").description("Execute batch operations from a file").requiredOption("--file <path>", "JSON file with batch operations").option("--atomic", "Wrap operations in a changeset for atomicity", false).option("--dry-run", "Preview the operation without executing", false).option("--as-user <id>", "Run as this Entra user object ID (CallerObjectId)").addOption(new Option("--output <format>", "Output format: json|table").choices(["json", "table"]).default("table")).action(async (opts) => {
1395
+ await batch({ file: opts.file, atomic: opts.atomic, dryRun: opts.dryRun, callerObjectId: opts.asUser, output: opts.output });
1396
+ });
1397
+ program.command("action").description("Execute a Dataverse action or SDK message").argument("<action>", "Action name (PascalCase)").requiredOption("--json <data>", "JSON payload for the action").option("--entity <entity>", "Entity logical name for bound actions").option("--id <id>", "Record GUID for bound actions").option("--dry-run", "Preview the operation without executing", false).option("--as-user <id>", "Run as this Entra user object ID (CallerObjectId)").addOption(new Option("--output <format>", "Output format: json|table").choices(["json", "table"]).default("table")).action(async (actionName, opts) => {
1398
+ await actionCommand(actionName, { json: opts.json, entity: opts.entity, id: opts.id, dryRun: opts.dryRun, callerObjectId: opts.asUser, output: opts.output });
1399
+ });
1400
+ program.command("demo").description("Run interactive demo showcasing dvx capabilities").addOption(new Option("--tier <tier>", "Demo depth tier").choices(["read", "write", "full"])).addOption(new Option("--output <format>", "Output format").choices(["json", "table"]).default("table")).action(async (opts) => {
1401
+ await demo({ tier: opts.tier, output: opts.output });
1402
+ });
1403
+ program.command("mcp").description("Start MCP server for agent consumption").option("--entities <entities>", "Comma-separated entity logical names to scope tools").option("--port <port>", "Port for HTTP transport", parseInt).addOption(new Option("--transport <transport>", "Transport type: stdio|http").choices(["stdio", "http"]).default("stdio")).action(async (options) => {
1404
+ const { startMcpServer } = await import("./server-KJMLJWZI.js");
1405
+ await startMcpServer({
1406
+ entities: options.entities?.split(",").map((e) => e.trim()),
1407
+ transport: options.transport,
1408
+ port: options.port
1409
+ });
1410
+ });
1411
+ program.command("completion").description("Generate shell completion script").argument("<shell>", "Shell type: bash, zsh, or powershell").action((shell) => {
1412
+ completion(shell);
1413
+ });
1414
+ function getHint(error, argv) {
1415
+ const msg = error.message;
1416
+ const args = argv.join(" ");
1417
+ if (msg.includes("Bad Request") && args.includes("--odata")) {
1418
+ const odataArg = argv[argv.indexOf("--odata") + 1] ?? "";
1419
+ if (odataArg.includes("?=") || odataArg.includes("&=") || !odataArg.includes("$")) {
1420
+ return "Use single quotes to prevent shell expansion of $ in OData: --odata '/entities?$top=10'";
1421
+ }
1422
+ }
1423
+ if (error.name === "AuthProfileNotFoundError") {
1424
+ return "Run `dvx auth login` to set up authentication.";
1425
+ }
1426
+ if (error.name === "AuthProfileExistsError") {
1427
+ return "Use `dvx auth select <name>` to switch profiles, or `dvx auth logout` to remove the existing one.";
1428
+ }
1429
+ if (error.name === "TokenAcquisitionError" && msg.includes("Client secret not found")) {
1430
+ return "Set DATAVERSE_CLIENT_SECRET env var, or use `dvx auth login` for delegated (browser) auth.";
1431
+ }
1432
+ if (error.name === "EntityNotFoundError") {
1433
+ return 'Run `dvx entities` to list available entities. Entity names are singular (e.g., "account" not "accounts").';
1434
+ }
1435
+ if (error.name === "RecordNotFoundError") {
1436
+ return "Verify the record ID is correct. Use `dvx query` to search for records.";
1437
+ }
1438
+ if (error.name === "ValidationError" && msg.includes("GUID")) {
1439
+ return "GUIDs must be in format: 00000000-0000-0000-0000-000000000000";
1440
+ }
1441
+ if (error.name === "ImpersonationPrivilegeError") {
1442
+ return "The application user needs the prvActOnBehalfOfAnotherUser privilege. Assign it via a security role in Dataverse admin.";
1443
+ }
1444
+ if (error.name === "FetchXmlValidationError") {
1445
+ return "Wrap FetchXML in single quotes to prevent shell interpretation of < and > characters.";
1446
+ }
1447
+ if (error.name === "DataverseError" && msg.includes("401")) {
1448
+ return "Authentication expired or invalid. Run `dvx auth login` to re-authenticate.";
1449
+ }
1450
+ if (error.name === "DataverseError" && msg.includes("403")) {
1451
+ return "Check that the authenticated user/app has the required security role in Dataverse.";
1452
+ }
1453
+ if (msg.includes("JSON") || msg.includes("Unexpected token")) {
1454
+ if (args.includes("--json")) {
1455
+ return `Ensure --json value is valid JSON. Use single quotes around the value: --json '{"name": "value"}'`;
1456
+ }
1457
+ }
1458
+ return void 0;
1459
+ }
1460
+ function getOutputFormat(argv) {
1461
+ const idx = argv.indexOf("--output");
1462
+ return idx >= 0 ? argv[idx + 1] : void 0;
1463
+ }
1464
+ async function main() {
1465
+ try {
1466
+ await program.parseAsync(process.argv);
1467
+ } catch (error) {
1468
+ const outputFormat = getOutputFormat(process.argv);
1469
+ if (error instanceof Error) {
1470
+ if (outputFormat === "json") {
1471
+ const hint = getHint(error, process.argv);
1472
+ const payload = {
1473
+ error: error.message,
1474
+ code: error.name
1475
+ };
1476
+ if (hint) payload["hint"] = hint;
1477
+ console.log(JSON.stringify(payload));
1478
+ } else {
1479
+ logError(error.message);
1480
+ const hint = getHint(error, process.argv);
1481
+ if (hint) {
1482
+ logInfo(hint);
1483
+ }
1484
+ }
1485
+ if (process.env["DVX_DEBUG"] === "true") {
1486
+ console.error(error.stack);
1487
+ }
1488
+ } else {
1489
+ if (outputFormat === "json") {
1490
+ console.log(JSON.stringify({ error: String(error), code: "UnknownError" }));
1491
+ } else {
1492
+ console.error("Unknown error:", error);
1493
+ }
1494
+ }
1495
+ process.exit(1);
1496
+ }
1497
+ }
1498
+ var index_default = main;
1499
+ main();
1500
+ export {
1501
+ index_default as default
1502
+ };
1503
+ //# sourceMappingURL=index.js.map