@icedq/cli 0.1.0 → 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/package.json CHANGED
@@ -1,43 +1,43 @@
1
- {
2
- "name": "@icedq/cli",
3
- "version": "0.1.0",
4
- "description": "CLI for iceDQ rule and workflow promotion across environments",
5
- "type": "module",
6
- "main": "src/bin/icedq.js",
7
- "bin": {
8
- "icedq": "./src/bin/icedq.js"
9
- },
10
- "scripts": {
11
- "test": "node --test tests/unit/",
12
- "start": "node src/bin/icedq.js"
13
- },
14
- "engines": {
15
- "node": ">=18.0.0"
16
- },
17
- "keywords": [
18
- "icedq",
19
- "data-quality",
20
- "ci-cd",
21
- "promotion",
22
- "github-action"
23
- ],
24
- "author": "iceDQ Integration Team",
25
- "license": "MIT",
26
- "homepage": "https://github.com/icedq-tools/cli#readme",
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/icedq-tools/cli.git"
30
- },
31
- "bugs": {
32
- "url": "https://github.com/icedq-tools/cli/issues"
33
- },
34
- "dependencies": {
35
- "commander": "^12.0.0",
36
- "form-data": "^4.0.0"
37
- },
38
- "files": [
39
- "src/",
40
- "README.md",
41
- "LICENSE"
42
- ]
43
- }
1
+ {
2
+ "name": "@icedq/cli",
3
+ "version": "0.1.1",
4
+ "description": "CLI for iceDQ rule and workflow promotion across environments",
5
+ "type": "module",
6
+ "main": "src/bin/icedq.js",
7
+ "bin": {
8
+ "icedq": "./src/bin/icedq.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test tests/unit/*.test.js",
12
+ "start": "node src/bin/icedq.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "keywords": [
18
+ "icedq",
19
+ "data-quality",
20
+ "ci-cd",
21
+ "promotion",
22
+ "github-action"
23
+ ],
24
+ "author": "iceDQ Integration Team",
25
+ "license": "MIT",
26
+ "homepage": "https://github.com/icedq-tools/cli#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/icedq-tools/cli.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/icedq-tools/cli/issues"
33
+ },
34
+ "dependencies": {
35
+ "commander": "^12.0.0",
36
+ "form-data": "^4.0.0"
37
+ },
38
+ "files": [
39
+ "src/",
40
+ "README.md",
41
+ "LICENSE"
42
+ ]
43
+ }
@@ -1,100 +1,102 @@
1
- import { writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { loadConfig } from '../core/config.js';
4
- import { KeycloakClientCredentialsAuth } from '../core/auth.js';
5
- import { IcedqApiClient } from '../core/client.js';
6
- import { pollTask } from '../core/poller.js';
7
- import { Reporter } from '../core/reporter.js';
8
- import { isSuccess, STATUS } from '../lib/status-enum.js';
9
- import { TaskFailedError, CliError } from '../core/errors.js';
10
- import { log, setLevel } from '../core/logger.js';
11
-
12
- const VALID_RESOURCES = new Set(['rule', 'workflow', 'folder']);
13
-
14
- function endpointForResource(resource) {
15
- // Rules export uses /exports/rules; workflows + folders use /exports/workflows
16
- return resource === 'rule' ? 'rules' : 'workflows';
17
- }
18
-
19
- export async function runExport(rawOpts) {
20
- if (rawOpts.verbose) setLevel('debug');
21
- if (rawOpts.quiet) setLevel('error');
22
-
23
- const resource = rawOpts.resource;
24
- if (!VALID_RESOURCES.has(resource)) {
25
- throw new CliError(`--resource must be one of: rule, workflow, folder`);
26
- }
27
- if (!rawOpts.id) throw new CliError('--id is required');
28
- if (!rawOpts.outputFile) throw new CliError('--output-file is required');
29
-
30
- const cfg = loadConfig(rawOpts);
31
- const auth = new KeycloakClientCredentialsAuth({
32
- keycloakUrl: cfg.keycloakUrl,
33
- clientId: cfg.clientId,
34
- clientSecret: cfg.clientSecret,
35
- verifySsl: cfg.verifySsl
36
- });
37
- const client = new IcedqApiClient({
38
- baseUrl: cfg.icedqUrl,
39
- orgId: cfg.orgId,
40
- accountId: cfg.accountId,
41
- workspaceId: cfg.workspaceId,
42
- auth,
43
- verifySsl: cfg.verifySsl
44
- });
45
-
46
- const kindEndpoint = endpointForResource(resource);
47
- const body = [
48
- {
49
- id: rawOpts.id,
50
- resource,
51
- ...(rawOpts.includeChild ? { includeChild: 'true' } : {})
52
- }
53
- ];
54
-
55
- log.info(`Submitting export`, { resource, id: rawOpts.id, endpoint: kindEndpoint });
56
- const submitResp = await client.post(`/api/v1/exports/${kindEndpoint}`, body);
57
- const taskId = submitResp.taskInstanceId;
58
- if (!taskId) {
59
- throw new CliError(`Export submit did not return a taskInstanceId: ${JSON.stringify(submitResp)}`);
60
- }
61
- process.stderr.write(`task-id: ${taskId}\n`);
62
-
63
- log.info('Polling export task', { taskId, timeoutSec: cfg.timeoutSec });
64
- const start = Date.now();
65
- const { status, attempts, elapsedMs } = await pollTask(client, 'exports', taskId, {
66
- timeoutSec: cfg.timeoutSec,
67
- onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
68
- });
69
-
70
- let outputFile = rawOpts.outputFile;
71
- let logTail;
72
-
73
- if (isSuccess(status)) {
74
- const buffer = await client.getBinary(`/api/v1/exports/${taskId}/download`);
75
- outputFile = path.resolve(outputFile);
76
- await writeFile(outputFile, buffer);
77
- log.info('Export bundle written', { outputFile, bytes: buffer.length });
78
- } else {
79
- try {
80
- logTail = await client.get(`/api/v1/exports/${taskId}/log`);
81
- } catch (err) {
82
- log.warn('Could not retrieve task log', { error: err.message });
83
- }
84
- }
85
-
86
- const result = {
87
- command: 'export',
88
- taskId,
89
- status,
90
- attempts,
91
- durationMs: elapsedMs,
92
- outputFile: isSuccess(status) ? outputFile : undefined,
93
- elapsedSinceSubmitMs: Date.now() - start
94
- };
95
- new Reporter(rawOpts.output || 'text').emit(result);
96
-
97
- if (!isSuccess(status)) {
98
- throw new TaskFailedError(taskId, 'export', status, logTail);
99
- }
100
- }
1
+ import { writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { loadConfig } from '../core/config.js';
4
+ import { KeycloakClientCredentialsAuth } from '../core/auth.js';
5
+ import { IcedqApiClient } from '../core/client.js';
6
+ import { pollTask } from '../core/poller.js';
7
+ import { Reporter } from '../core/reporter.js';
8
+ import { isSuccess, STATUS } from '../lib/status-enum.js';
9
+ import { TaskFailedError, CliError } from '../core/errors.js';
10
+ import { log, setLevel } from '../core/logger.js';
11
+
12
+ const VALID_RESOURCES = new Set(['rule', 'workflow', 'folder']);
13
+
14
+ function endpointForResource(resource) {
15
+ // Rules export uses /exports/rules; workflows + folders use /exports/workflows
16
+ return resource === 'rule' ? 'rules' : 'workflows';
17
+ }
18
+
19
+ export async function runExport(rawOpts) {
20
+ if (rawOpts.verbose) setLevel('debug');
21
+ if (rawOpts.quiet) setLevel('error');
22
+
23
+ const resource = rawOpts.resource;
24
+ if (!VALID_RESOURCES.has(resource)) {
25
+ throw new CliError(`--resource must be one of: rule, workflow, folder`);
26
+ }
27
+ if (!rawOpts.id) throw new CliError('--id is required');
28
+ if (!rawOpts.outputFile) throw new CliError('--output-file is required');
29
+
30
+ const cfg = loadConfig(rawOpts);
31
+ const auth = new KeycloakClientCredentialsAuth({
32
+ keycloakUrl: cfg.keycloakUrl,
33
+ clientId: cfg.clientId,
34
+ clientSecret: cfg.clientSecret,
35
+ verifySsl: cfg.verifySsl
36
+ });
37
+ const client = new IcedqApiClient({
38
+ baseUrl: cfg.icedqUrl,
39
+ orgId: cfg.orgId,
40
+ accountId: cfg.accountId,
41
+ workspaceId: cfg.workspaceId,
42
+ auth,
43
+ verifySsl: cfg.verifySsl
44
+ });
45
+
46
+ const kindEndpoint = endpointForResource(resource);
47
+ const body = {
48
+ objects: [
49
+ {
50
+ id: rawOpts.id,
51
+ resource,
52
+ ...(rawOpts.includeChild ? { includeChild: 'true' } : {})
53
+ }
54
+ ]
55
+ };
56
+
57
+ log.info(`Submitting export`, { resource, id: rawOpts.id, endpoint: kindEndpoint });
58
+ const submitResp = await client.post(`/api/v1/exports/${kindEndpoint}`, body);
59
+ const taskId = submitResp.taskInstanceId;
60
+ if (!taskId) {
61
+ throw new CliError(`Export submit did not return a taskInstanceId: ${JSON.stringify(submitResp)}`);
62
+ }
63
+ process.stderr.write(`task-id: ${taskId}\n`);
64
+
65
+ log.info('Polling export task', { taskId, timeoutSec: cfg.timeoutSec });
66
+ const start = Date.now();
67
+ const { status, attempts, elapsedMs } = await pollTask(client, 'exports', taskId, {
68
+ timeoutSec: cfg.timeoutSec,
69
+ onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
70
+ });
71
+
72
+ let outputFile = rawOpts.outputFile;
73
+ let logTail;
74
+
75
+ if (isSuccess(status)) {
76
+ const buffer = await client.getBinary(`/api/v1/exports/${taskId}/download`);
77
+ outputFile = path.resolve(outputFile);
78
+ await writeFile(outputFile, buffer);
79
+ log.info('Export bundle written', { outputFile, bytes: buffer.length });
80
+ } else {
81
+ try {
82
+ logTail = await client.get(`/api/v1/exports/${taskId}/log`);
83
+ } catch (err) {
84
+ log.warn('Could not retrieve task log', { error: err.message });
85
+ }
86
+ }
87
+
88
+ const result = {
89
+ command: 'export',
90
+ taskId,
91
+ status,
92
+ attempts,
93
+ durationMs: elapsedMs,
94
+ outputFile: isSuccess(status) ? outputFile : undefined,
95
+ elapsedSinceSubmitMs: Date.now() - start
96
+ };
97
+ new Reporter(rawOpts.output || 'text').emit(result);
98
+
99
+ if (!isSuccess(status)) {
100
+ throw new TaskFailedError(taskId, 'export', status, logTail);
101
+ }
102
+ }
@@ -1,182 +1,183 @@
1
- import https from 'node:https';
2
- import http from 'node:http';
3
- import { URL } from 'node:url';
4
- import FormData from 'form-data';
5
- import { ApiError } from './errors.js';
6
- import { log } from './logger.js';
7
-
8
- const STATUS_RETRY_5XX = 3;
9
- const STATUS_RETRY_DELAY_MS = 5000;
10
-
11
- const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
12
-
13
- export class IcedqApiClient {
14
- constructor({ baseUrl, orgId, accountId, workspaceId, auth, verifySsl = true }) {
15
- this.baseUrl = baseUrl.replace(/\/+$/, '');
16
- this.orgId = orgId;
17
- this.accountId = accountId;
18
- this.workspaceId = workspaceId;
19
- this.auth = auth;
20
- this.verifySsl = verifySsl;
21
- }
22
-
23
- _buildHeaders(extra = {}, { includeWorkspace = true } = {}) {
24
- const headers = {
25
- Accept: 'application/json',
26
- 'Org-Id': this.orgId,
27
- 'Account-Id': this.accountId,
28
- ...extra
29
- };
30
- if (includeWorkspace && this.workspaceId) {
31
- headers['Workspace-Id'] = this.workspaceId;
32
- }
33
- return headers;
34
- }
35
-
36
- async get(path, opts = {}) {
37
- return this._request('GET', path, opts);
38
- }
39
-
40
- async post(path, body, opts = {}) {
41
- return this._request('POST', path, { ...opts, jsonBody: body });
42
- }
43
-
44
- async postMultipart(path, parts, opts = {}) {
45
- const form = new FormData();
46
- for (const [name, value] of Object.entries(parts)) {
47
- if (value && typeof value === 'object' && value.buffer && value.filename) {
48
- form.append(name, value.buffer, {
49
- filename: value.filename,
50
- contentType: value.contentType || 'application/octet-stream'
51
- });
52
- } else if (value && typeof value === 'object' && value.json !== undefined) {
53
- form.append(name, JSON.stringify(value.json), { contentType: 'application/json' });
54
- } else {
55
- form.append(name, value);
56
- }
57
- }
58
- return this._request('POST', path, { ...opts, multipart: form });
59
- }
60
-
61
- async getBinary(path, opts = {}) {
62
- return this._request('GET', path, { ...opts, expectBinary: true });
63
- }
64
-
65
- async _request(method, path, opts = {}) {
66
- const includeWorkspace = opts.includeWorkspace !== false;
67
- let attempt = 0;
68
- let last401 = false;
69
-
70
- while (true) {
71
- attempt++;
72
- const token = await this.auth.getToken();
73
- const headers = this._buildHeaders(
74
- {
75
- Authorization: `Bearer ${token}`,
76
- ...(opts.headers || {})
77
- },
78
- { includeWorkspace }
79
- );
80
-
81
- let bodyBuffer;
82
- if (opts.multipart) {
83
- Object.assign(headers, opts.multipart.getHeaders());
84
- bodyBuffer = opts.multipart.getBuffer();
85
- } else if (opts.jsonBody !== undefined) {
86
- headers['Content-Type'] = 'application/json';
87
- bodyBuffer = Buffer.from(JSON.stringify(opts.jsonBody));
88
- }
89
-
90
- const url = path.startsWith('http')
91
- ? path
92
- : `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
93
-
94
- log.debug('iceDQ request', { method, url, attempt });
95
-
96
- let response;
97
- try {
98
- response = await this._doRequest(method, url, headers, bodyBuffer, !!opts.expectBinary);
99
- } catch (err) {
100
- throw new ApiError(`Network error calling ${url}: ${err.message}`, { endpoint: path });
101
- }
102
-
103
- if (response.status === 401 && !last401) {
104
- log.warn('iceDQ 401 refreshing token and retrying once');
105
- last401 = true;
106
- await this.auth.forceRefresh();
107
- continue;
108
- }
109
-
110
- if (response.status >= 500 && opts.retryOn5xx && attempt <= STATUS_RETRY_5XX) {
111
- log.warn(`iceDQ ${response.status} retrying after ${STATUS_RETRY_DELAY_MS}ms`, { attempt });
112
- await sleep(STATUS_RETRY_DELAY_MS);
113
- continue;
114
- }
115
-
116
- if (response.status < 200 || response.status >= 300) {
117
- const parsed = parseErrorBody(response.body);
118
- throw new ApiError(`HTTP ${response.status} from ${path}: ${parsed.summary}`, {
119
- status: response.status,
120
- code: parsed.code,
121
- messages: parsed.messages,
122
- body: response.body,
123
- endpoint: path
124
- });
125
- }
126
-
127
- if (opts.expectBinary) return response.buffer;
128
- if (!response.body) return {};
129
- try {
130
- return JSON.parse(response.body);
131
- } catch {
132
- return response.body;
133
- }
134
- }
135
- }
136
-
137
- _doRequest(method, urlString, headers, bodyBuffer, expectBinary) {
138
- return new Promise((resolve, reject) => {
139
- const url = new URL(urlString);
140
- const isHttps = url.protocol === 'https:';
141
- const lib = isHttps ? https : http;
142
- const reqOpts = { method, headers };
143
- if (isHttps && !this.verifySsl) reqOpts.rejectUnauthorized = false;
144
-
145
- const req = lib.request(url, reqOpts, (res) => {
146
- const chunks = [];
147
- res.on('data', (c) => chunks.push(c));
148
- res.on('end', () => {
149
- if (expectBinary) {
150
- resolve({ status: res.statusCode, buffer: Buffer.concat(chunks) });
151
- } else {
152
- resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') });
153
- }
154
- });
155
- });
156
- req.on('error', reject);
157
- if (bodyBuffer) req.write(bodyBuffer);
158
- req.end();
159
- });
160
- }
161
- }
162
-
163
- function parseErrorBody(body) {
164
- if (!body) return { summary: '(empty body)', code: undefined, messages: [] };
165
- try {
166
- const parsed = JSON.parse(body);
167
- if (parsed && typeof parsed === 'object') {
168
- const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
169
- const summary =
170
- messages
171
- .map((m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`)
172
- .filter(Boolean)
173
- .join('; ') || parsed.message || JSON.stringify(parsed);
174
- return { summary, code: parsed.code, messages };
175
- }
176
- } catch {
177
- // not JSON, fall through
178
- }
179
- return { summary: body.slice(0, 200), code: undefined, messages: [] };
180
- }
181
-
182
- export const _internal = { parseErrorBody };
1
+ import https from 'node:https';
2
+ import http from 'node:http';
3
+ import { URL } from 'node:url';
4
+ import FormData from 'form-data';
5
+ import { ApiError } from './errors.js';
6
+ import { log } from './logger.js';
7
+
8
+ const STATUS_RETRY_5XX = 3;
9
+ const STATUS_RETRY_DELAY_MS = 5000;
10
+
11
+ const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
12
+
13
+ export class IcedqApiClient {
14
+ constructor({ baseUrl, orgId, accountId, workspaceId, auth, verifySsl = true }) {
15
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
16
+ this.orgId = orgId;
17
+ this.accountId = accountId;
18
+ this.workspaceId = workspaceId;
19
+ this.auth = auth;
20
+ this.verifySsl = verifySsl;
21
+ }
22
+
23
+ _buildHeaders(extra = {}, { includeWorkspace = true } = {}) {
24
+ const headers = {
25
+ Accept: 'application/json',
26
+ 'Org-Id': this.orgId,
27
+ 'Account-Id': this.accountId,
28
+ ...extra
29
+ };
30
+ if (includeWorkspace && this.workspaceId) {
31
+ headers['Workspace-Id'] = this.workspaceId;
32
+ }
33
+ return headers;
34
+ }
35
+
36
+ async get(path, opts = {}) {
37
+ return this._request('GET', path, opts);
38
+ }
39
+
40
+ async post(path, body, opts = {}) {
41
+ return this._request('POST', path, { ...opts, jsonBody: body });
42
+ }
43
+
44
+ async postMultipart(path, parts, opts = {}) {
45
+ const form = new FormData();
46
+ for (const [name, value] of Object.entries(parts)) {
47
+ if (value && typeof value === 'object' && value.buffer && value.filename) {
48
+ form.append(name, value.buffer, {
49
+ filename: value.filename,
50
+ contentType: value.contentType || 'application/octet-stream'
51
+ });
52
+ } else if (value && typeof value === 'object' && value.json !== undefined) {
53
+ form.append(name, JSON.stringify(value.json), { contentType: 'application/json' });
54
+ } else {
55
+ form.append(name, value);
56
+ }
57
+ }
58
+ return this._request('POST', path, { ...opts, multipart: form });
59
+ }
60
+
61
+ async getBinary(path, opts = {}) {
62
+ return this._request('GET', path, { ...opts, expectBinary: true });
63
+ }
64
+
65
+ async _request(method, path, opts = {}) {
66
+ const includeWorkspace = opts.includeWorkspace !== false;
67
+ let attempt = 0;
68
+ let last401 = false;
69
+
70
+ while (true) {
71
+ attempt++;
72
+ const token = await this.auth.getToken();
73
+ const headers = this._buildHeaders(
74
+ {
75
+ Authorization: `Bearer ${token}`,
76
+ ...(opts.headers || {})
77
+ },
78
+ { includeWorkspace }
79
+ );
80
+
81
+ let bodyBuffer;
82
+ if (opts.multipart) {
83
+ Object.assign(headers, opts.multipart.getHeaders());
84
+ bodyBuffer = opts.multipart.getBuffer();
85
+ } else if (opts.jsonBody !== undefined) {
86
+ headers['Content-Type'] = 'application/json';
87
+ bodyBuffer = Buffer.from(JSON.stringify(opts.jsonBody));
88
+ headers['Content-Length'] = bodyBuffer.length;
89
+ }
90
+
91
+ const url = path.startsWith('http')
92
+ ? path
93
+ : `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
94
+
95
+ log.debug('iceDQ request', { method, url, attempt });
96
+
97
+ let response;
98
+ try {
99
+ response = await this._doRequest(method, url, headers, bodyBuffer, !!opts.expectBinary);
100
+ } catch (err) {
101
+ throw new ApiError(`Network error calling ${url}: ${err.message}`, { endpoint: path });
102
+ }
103
+
104
+ if (response.status === 401 && !last401) {
105
+ log.warn('iceDQ 401 — refreshing token and retrying once');
106
+ last401 = true;
107
+ await this.auth.forceRefresh();
108
+ continue;
109
+ }
110
+
111
+ if (response.status >= 500 && opts.retryOn5xx && attempt <= STATUS_RETRY_5XX) {
112
+ log.warn(`iceDQ ${response.status} — retrying after ${STATUS_RETRY_DELAY_MS}ms`, { attempt });
113
+ await sleep(STATUS_RETRY_DELAY_MS);
114
+ continue;
115
+ }
116
+
117
+ if (response.status < 200 || response.status >= 300) {
118
+ const parsed = parseErrorBody(response.body);
119
+ throw new ApiError(`HTTP ${response.status} from ${path}: ${parsed.summary}`, {
120
+ status: response.status,
121
+ code: parsed.code,
122
+ messages: parsed.messages,
123
+ body: response.body,
124
+ endpoint: path
125
+ });
126
+ }
127
+
128
+ if (opts.expectBinary) return response.buffer;
129
+ if (!response.body) return {};
130
+ try {
131
+ return JSON.parse(response.body);
132
+ } catch {
133
+ return response.body;
134
+ }
135
+ }
136
+ }
137
+
138
+ _doRequest(method, urlString, headers, bodyBuffer, expectBinary) {
139
+ return new Promise((resolve, reject) => {
140
+ const url = new URL(urlString);
141
+ const isHttps = url.protocol === 'https:';
142
+ const lib = isHttps ? https : http;
143
+ const reqOpts = { method, headers };
144
+ if (isHttps && !this.verifySsl) reqOpts.rejectUnauthorized = false;
145
+
146
+ const req = lib.request(url, reqOpts, (res) => {
147
+ const chunks = [];
148
+ res.on('data', (c) => chunks.push(c));
149
+ res.on('end', () => {
150
+ if (expectBinary) {
151
+ resolve({ status: res.statusCode, buffer: Buffer.concat(chunks) });
152
+ } else {
153
+ resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') });
154
+ }
155
+ });
156
+ });
157
+ req.on('error', reject);
158
+ if (bodyBuffer) req.write(bodyBuffer);
159
+ req.end();
160
+ });
161
+ }
162
+ }
163
+
164
+ function parseErrorBody(body) {
165
+ if (!body) return { summary: '(empty body)', code: undefined, messages: [] };
166
+ try {
167
+ const parsed = JSON.parse(body);
168
+ if (parsed && typeof parsed === 'object') {
169
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
170
+ const summary =
171
+ messages
172
+ .map((m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`)
173
+ .filter(Boolean)
174
+ .join('; ') || parsed.message || JSON.stringify(parsed);
175
+ return { summary, code: parsed.code, messages };
176
+ }
177
+ } catch {
178
+ // not JSON, fall through
179
+ }
180
+ return { summary: body.slice(0, 200), code: undefined, messages: [] };
181
+ }
182
+
183
+ export const _internal = { parseErrorBody };