@sbroenne/dvq 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sbroenne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # dvq
2
+
3
+ `dvq` is a small CLI and Node.js library for querying Dataverse OData endpoints with Azure CLI credentials.
4
+
5
+ ## What exists today
6
+
7
+ - CLI for running OData paths against `/api/data/v9.2/`
8
+ - Azure authentication via `az login`
9
+ - Pretty-printed JSON output
10
+ - Library helpers for URL building, token acquisition, single requests, and paginated requests
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @sbroenne/dvq
16
+ ```
17
+
18
+ Run without installing:
19
+
20
+ ```bash
21
+ npx @sbroenne/dvq --help
22
+ ```
23
+
24
+ ## Requirements
25
+
26
+ - Node.js 20 or later
27
+ - Azure CLI (`az`)
28
+ - Access to a Dataverse environment
29
+
30
+ Authenticate once before using the CLI or library:
31
+
32
+ ```bash
33
+ az login
34
+ ```
35
+
36
+ For development and release validation, `npm test` runs the unit suite and `npm run test:integration` runs the live Dataverse checks when `.env.integration` is configured.
37
+
38
+ ## Configuration
39
+
40
+ Set the base environment URL:
41
+
42
+ ```bash
43
+ export DATAVERSE_URL="https://yourorg.crm.dynamics.com"
44
+ ```
45
+
46
+ You can also pass `--url` on the CLI instead of setting `DATAVERSE_URL`.
47
+
48
+ ## CLI quick start
49
+
50
+ The query is an OData path relative to `/api/data/v9.2/`.
51
+
52
+ ```bash
53
+ dvq "accounts?$top=5"
54
+ ```
55
+
56
+ With an explicit URL:
57
+
58
+ ```bash
59
+ dvq --url "https://yourorg.crm.dynamics.com" "accounts?$select=name&$top=5"
60
+ ```
61
+
62
+ From a file:
63
+
64
+ ```bash
65
+ dvq --file query.odata
66
+ ```
67
+
68
+ From stdin:
69
+
70
+ ```bash
71
+ echo "accounts?$top=5" | dvq --url "https://yourorg.crm.dynamics.com"
72
+ ```
73
+
74
+ Verify auth and connectivity:
75
+
76
+ ```bash
77
+ dvq --whoami
78
+ ```
79
+
80
+ Follow `@odata.nextLink` pages automatically:
81
+
82
+ ```bash
83
+ dvq --all "accounts?$select=name"
84
+ ```
85
+
86
+ ## CLI usage
87
+
88
+ ```bash
89
+ dvq [options] [query]
90
+ ```
91
+
92
+ | Option | Argument | Description |
93
+ | --- | --- | --- |
94
+ | `[query]` | OData path | Inline query path after `/api/data/v9.2/` |
95
+ | `-f, --file` | `<path>` | Read the OData path from a file |
96
+ | `-a, --all` | — | Follow `@odata.nextLink` pages up to the built-in safety cap |
97
+ | `-u, --url` | `<url>` | Use this Dataverse base URL instead of `DATAVERSE_URL` |
98
+ | `--whoami` | — | Call `WhoAmI` and print the response |
99
+ | `--version` | — | Print the package version |
100
+ | `--help` | — | Show help text |
101
+
102
+ Notes:
103
+
104
+ - If neither `[query]` nor `--file` is given, `dvq` reads stdin when input is piped.
105
+ - Without `--all`, the CLI prints the first JSON response object exactly as returned by Dataverse.
106
+ - With `--all`, the CLI prints a JSON array aggregated across pages.
107
+
108
+ ## Library API
109
+
110
+ The package currently exports low-level helpers from `src/lib.ts`, not a single high-level `query()` function.
111
+
112
+ ### Common request flow
113
+
114
+ ```ts
115
+ import { buildUrl, fetchOData, getToken } from '@sbroenne/dvq';
116
+
117
+ const baseUrl = 'https://yourorg.crm.dynamics.com';
118
+ const token = await getToken({ baseUrl });
119
+ const url = buildUrl('accounts?$top=5', baseUrl);
120
+ const data = await fetchOData(url, token);
121
+
122
+ console.log(data);
123
+ ```
124
+
125
+ ### Fetch all pages
126
+
127
+ ```ts
128
+ import { getToken, queryAll } from '@sbroenne/dvq';
129
+
130
+ const baseUrl = 'https://yourorg.crm.dynamics.com';
131
+ const token = await getToken({ baseUrl });
132
+ const rows = await queryAll('accounts?$select=name', token, baseUrl);
133
+
134
+ console.log(rows);
135
+ ```
136
+
137
+ ### Main exports
138
+
139
+ | Export | Purpose |
140
+ | --- | --- |
141
+ | `resolveDataverseUrl`, `getDataverseUrl` | Resolve the Dataverse base URL from an explicit value or `DATAVERSE_URL` |
142
+ | `getDataverseScope` | Build the `/.default` scope for Azure auth |
143
+ | `buildUrl` | Build a full Dataverse Web API URL from an OData path |
144
+ | `buildHeaders` | Create request headers for Dataverse JSON calls |
145
+ | `getToken` | Acquire an access token using `AzureCliCredential` |
146
+ | `fetchOData` | Execute one HTTP request and parse the JSON response |
147
+ | `queryAll` | Follow paginated `@odata.nextLink` responses and return one array |
148
+ | `readQueryFile`, `readStdin` | CLI-oriented input helpers |
149
+ | `ODataError` | Error type for non-2xx HTTP responses |
150
+ | `API_PATH`, `DEFAULT_API_PATH`, `MAX_PAGES` | Public constants used by the helpers |
151
+
152
+ ## Errors and behavior
153
+
154
+ Missing configuration:
155
+
156
+ ```text
157
+ DATAVERSE_URL environment variable is required.
158
+ ```
159
+
160
+ Authentication failure:
161
+
162
+ ```text
163
+ Failed to get token. Run:
164
+ az login
165
+ ```
166
+
167
+ `queryAll()` stops after `MAX_PAGES` pages and throws if the safety cap is exceeded.
168
+
169
+ ## Contributing
170
+
171
+ See [CONTRIBUTING.md](./CONTRIBUTING.md).
172
+
173
+ ## Security
174
+
175
+ Please do not report suspected vulnerabilities in public issues. See [SECURITY.md](./SECURITY.md) for the current reporting path and maintainer security checklist.
176
+
177
+ ## License
178
+
179
+ MIT © 2026 sbroenne
package/dist/cli.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ export declare function createProgram(version?: string): Command;
4
+ export declare function runCli(argv?: string[]): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createRequire } from 'node:module';
4
+ import { Command } from 'commander';
5
+ import { MAX_PAGES, buildUrl, fetchOData, getDataverseUrl, getToken, queryAll, readQueryFile, readStdin, } from './lib.js';
6
+ const require = createRequire(import.meta.url);
7
+ const { version: VERSION } = require('../package.json');
8
+ const HELP_AFTER = `
9
+ Environment:
10
+ DATAVERSE_URL Base URL for your Dataverse org (required unless --url is provided)
11
+
12
+ Prerequisites:
13
+ az login
14
+
15
+ Examples:
16
+ dvq --url https://yourorg.crm.dynamics.com --whoami
17
+ dvq --file query.odata --all
18
+ DATAVERSE_URL=https://yourorg.crm.dynamics.com dvq "accounts?\\$top=5"
19
+ echo "accounts?\\$top=5" | dvq --url https://yourorg.crm.dynamics.com
20
+ `;
21
+ export function createProgram(version = VERSION) {
22
+ const program = new Command()
23
+ .name('dvq')
24
+ .description('Query a Dataverse environment via OData using Azure CLI credentials')
25
+ .version(version)
26
+ .option('-f, --file <path>', 'read an OData query path from a file')
27
+ .option('-a, --all', `follow @odata.nextLink pages (max ${MAX_PAGES})`)
28
+ .option('-u, --url <url>', 'use this Dataverse base URL for the request')
29
+ .option('--whoami', 'print the WhoAmI response to verify auth')
30
+ .argument('[query]', 'inline OData query path (everything after /api/data/v9.2/)')
31
+ .addHelpText('after', HELP_AFTER);
32
+ program.action(async (inlineQuery, opts) => {
33
+ let query = '';
34
+ if (opts.file) {
35
+ query = readQueryFile(opts.file);
36
+ }
37
+ else if (inlineQuery) {
38
+ query = inlineQuery;
39
+ }
40
+ else if (!opts.whoami && !process.stdin.isTTY) {
41
+ query = await readStdin();
42
+ }
43
+ if (!opts.whoami && !query) {
44
+ program.help();
45
+ }
46
+ const baseUrl = getDataverseUrl(opts.url);
47
+ const token = await getToken({ baseUrl });
48
+ if (opts.whoami) {
49
+ const data = await fetchOData(buildUrl('WhoAmI', baseUrl), token);
50
+ console.log(JSON.stringify(data, null, 2));
51
+ return;
52
+ }
53
+ if (opts.all) {
54
+ const results = await queryAll(query, token, baseUrl);
55
+ console.log(JSON.stringify(results, null, 2));
56
+ return;
57
+ }
58
+ const data = await fetchOData(buildUrl(query, baseUrl), token);
59
+ console.log(JSON.stringify(data, null, 2));
60
+ });
61
+ return program;
62
+ }
63
+ export async function runCli(argv = process.argv.slice(2)) {
64
+ await createProgram().parseAsync(argv, { from: 'user' });
65
+ }
66
+ const isEntrypoint = process.argv[1]
67
+ ? fileURLToPath(import.meta.url) === process.argv[1]
68
+ : false;
69
+ if (isEntrypoint) {
70
+ runCli().catch((error) => {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ console.error(message);
73
+ process.exit(1);
74
+ });
75
+ }
76
+ //# sourceMappingURL=cli.js.map
package/dist/lib.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ export declare const REQUIRED_DATAVERSE_URL_ERROR = "DATAVERSE_URL environment variable is required.";
2
+ export declare const AZ_LOGIN_GUIDANCE = "Failed to get token. Run:\n az login";
3
+ export declare const API_PATH = "/api/data/v9.2/";
4
+ export declare const DEFAULT_API_PATH = "/api/data/v9.2/";
5
+ export declare const MAX_PAGES = 40;
6
+ export interface ResolveDataverseUrlOptions {
7
+ env?: NodeJS.ProcessEnv;
8
+ url?: string | undefined;
9
+ }
10
+ export interface TokenCredentialLike {
11
+ getToken(scope: string): Promise<{
12
+ token: string;
13
+ } | null>;
14
+ }
15
+ interface ODataResponse {
16
+ value?: unknown[];
17
+ '@odata.nextLink'?: string;
18
+ error?: {
19
+ message?: string;
20
+ };
21
+ [key: string]: unknown;
22
+ }
23
+ export declare function resolveDataverseUrl(options?: ResolveDataverseUrlOptions): string;
24
+ export declare function getDataverseUrl(baseUrl?: string, env?: NodeJS.ProcessEnv): string;
25
+ export declare function getDataverseScope(baseUrl?: string, env?: NodeJS.ProcessEnv): string;
26
+ export declare function buildUrl(odataPath: string, baseUrl?: string, env?: NodeJS.ProcessEnv): string;
27
+ export declare function buildHeaders(token: string): Record<string, string>;
28
+ export declare function getToken(options?: {
29
+ baseUrl?: string;
30
+ credential?: TokenCredentialLike;
31
+ env?: NodeJS.ProcessEnv;
32
+ }): Promise<string>;
33
+ export declare class ODataError extends Error {
34
+ statusCode: number;
35
+ constructor(statusCode: number, detail: string);
36
+ }
37
+ export declare function fetchOData(url: string, token: string): Promise<ODataResponse>;
38
+ export declare function queryAll(odataPath: string, token: string, baseUrl?: string, env?: NodeJS.ProcessEnv): Promise<unknown[]>;
39
+ export declare function readQueryFile(filePath: string): string;
40
+ export declare function readStdin(): Promise<string>;
41
+ export {};
package/dist/lib.js ADDED
@@ -0,0 +1,133 @@
1
+ import * as fs from 'node:fs';
2
+ import { AzureCliCredential } from '@azure/identity';
3
+ export const REQUIRED_DATAVERSE_URL_ERROR = 'DATAVERSE_URL environment variable is required.';
4
+ export const AZ_LOGIN_GUIDANCE = 'Failed to get token. Run:\n az login';
5
+ export const API_PATH = '/api/data/v9.2/';
6
+ export const DEFAULT_API_PATH = API_PATH;
7
+ export const MAX_PAGES = 40;
8
+ function normalizeOptionalValue(value) {
9
+ const trimmed = value?.trim();
10
+ return trimmed ? trimmed : undefined;
11
+ }
12
+ function trimTrailingSlashes(value) {
13
+ let end = value.length;
14
+ while (end > 0 && value[end - 1] === '/') {
15
+ end -= 1;
16
+ }
17
+ return value.slice(0, end);
18
+ }
19
+ function trimLeadingSlashes(value) {
20
+ let start = 0;
21
+ while (start < value.length && value[start] === '/') {
22
+ start += 1;
23
+ }
24
+ return value.slice(start);
25
+ }
26
+ function normalizeBaseUrl(value) {
27
+ return trimTrailingSlashes(value);
28
+ }
29
+ export function resolveDataverseUrl(options = {}) {
30
+ const explicitUrl = normalizeOptionalValue(options.url);
31
+ if (explicitUrl) {
32
+ return normalizeBaseUrl(explicitUrl);
33
+ }
34
+ const envUrl = normalizeOptionalValue(options.env?.DATAVERSE_URL);
35
+ if (envUrl) {
36
+ return normalizeBaseUrl(envUrl);
37
+ }
38
+ throw new Error(REQUIRED_DATAVERSE_URL_ERROR);
39
+ }
40
+ export function getDataverseUrl(baseUrl, env = process.env) {
41
+ return resolveDataverseUrl({ env, url: baseUrl });
42
+ }
43
+ export function getDataverseScope(baseUrl, env = process.env) {
44
+ return `${getDataverseUrl(baseUrl, env)}/.default`;
45
+ }
46
+ export function buildUrl(odataPath, baseUrl, env = process.env) {
47
+ const queryPath = trimLeadingSlashes(odataPath);
48
+ return `${getDataverseUrl(baseUrl, env)}${API_PATH}${queryPath}`;
49
+ }
50
+ export function buildHeaders(token) {
51
+ return {
52
+ Authorization: `Bearer ${token}`,
53
+ 'OData-MaxVersion': '4.0',
54
+ 'OData-Version': '4.0',
55
+ Accept: 'application/json',
56
+ Prefer: 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"',
57
+ };
58
+ }
59
+ export async function getToken(options = {}) {
60
+ const credential = options.credential ?? new AzureCliCredential();
61
+ try {
62
+ const response = await credential.getToken(getDataverseScope(options.baseUrl, options.env));
63
+ if (!response?.token) {
64
+ throw new Error('Missing token response');
65
+ }
66
+ return response.token;
67
+ }
68
+ catch {
69
+ throw new Error(AZ_LOGIN_GUIDANCE);
70
+ }
71
+ }
72
+ export class ODataError extends Error {
73
+ statusCode;
74
+ constructor(statusCode, detail) {
75
+ super(`HTTP ${statusCode}: ${detail}`);
76
+ this.statusCode = statusCode;
77
+ this.name = 'ODataError';
78
+ }
79
+ }
80
+ export async function fetchOData(url, token) {
81
+ const response = await fetch(url, { headers: buildHeaders(token) });
82
+ if (!response.ok) {
83
+ let detail;
84
+ try {
85
+ const body = await response.json();
86
+ detail = body?.error?.message ?? JSON.stringify(body);
87
+ }
88
+ catch {
89
+ detail = response.statusText;
90
+ }
91
+ throw new ODataError(response.status, detail);
92
+ }
93
+ return response.json();
94
+ }
95
+ export async function queryAll(odataPath, token, baseUrl, env = process.env) {
96
+ let url = buildUrl(odataPath, baseUrl, env);
97
+ const results = [];
98
+ let hasNextPage = false;
99
+ for (let page = 0; page < MAX_PAGES; page += 1) {
100
+ const data = await fetchOData(url, token);
101
+ if (data.value) {
102
+ results.push(...data.value);
103
+ }
104
+ else {
105
+ return [data];
106
+ }
107
+ const nextLink = data['@odata.nextLink'];
108
+ hasNextPage = Boolean(nextLink);
109
+ if (!nextLink) {
110
+ break;
111
+ }
112
+ url = nextLink;
113
+ }
114
+ if (hasNextPage) {
115
+ throw new Error(`Query exceeded pagination safety cap (${MAX_PAGES} pages). Narrow the filter or increase MAX_PAGES.`);
116
+ }
117
+ return results;
118
+ }
119
+ export function readQueryFile(filePath) {
120
+ return fs.readFileSync(filePath, 'utf8').trim();
121
+ }
122
+ export function readStdin() {
123
+ return new Promise((resolve, reject) => {
124
+ let data = '';
125
+ process.stdin.setEncoding('utf8');
126
+ process.stdin.on('data', (chunk) => {
127
+ data += chunk;
128
+ });
129
+ process.stdin.on('end', () => resolve(data.trim()));
130
+ process.stdin.on('error', reject);
131
+ });
132
+ }
133
+ //# sourceMappingURL=lib.js.map
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@sbroenne/dvq",
3
+ "version": "0.1.1",
4
+ "description": "CLI for querying Dataverse OData endpoints with Azure CLI credentials",
5
+ "type": "module",
6
+ "bin": {
7
+ "dvq": "dist/cli.js"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/lib.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsc --watch",
15
+ "test": "npm run test:unit",
16
+ "test:watch": "vitest",
17
+ "lint": "eslint src/",
18
+ "format": "prettier --write 'src/**/*.ts'",
19
+ "prepublishOnly": "npm run lint && npm run build && npm test",
20
+ "test:unit": "vitest run --exclude src/lib.integration.test.ts",
21
+ "test:integration": "vitest run src/lib.integration.test.ts",
22
+ "format:check": "prettier --check 'src/**/*.ts'",
23
+ "lint:fix": "eslint src/ --fix"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "!dist/**/*.test.*",
28
+ "!dist/**/*.map",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "keywords": [
33
+ "dataverse",
34
+ "query",
35
+ "odata",
36
+ "dynamics365",
37
+ "cli",
38
+ "azure"
39
+ ],
40
+ "homepage": "https://github.com/sbroenne/dvq",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/sbroenne/dvq.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/sbroenne/dvq/issues"
47
+ },
48
+ "author": "sbroenne",
49
+ "license": "MIT",
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "dependencies": {
57
+ "@azure/identity": "^4.13.0",
58
+ "commander": "^14.0.3"
59
+ },
60
+ "devDependencies": {
61
+ "@eslint/js": "^10.0.1",
62
+ "@types/node": "^25.5.0",
63
+ "eslint": "^10.0.3",
64
+ "prettier": "^3.8.1",
65
+ "typescript": "^5.9.3",
66
+ "typescript-eslint": "^8.57.0",
67
+ "vitest": "^4.1.0"
68
+ }
69
+ }