@icedq/cli 0.1.0

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 Torana Inc. (iceDQ)
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,69 @@
1
+ # @icedq/cli
2
+
3
+ CLI for iceDQ rule and workflow promotion across environments.
4
+
5
+ Wraps the iceDQ import/export REST APIs to handle authentication, async job polling, multipart bundle uploads, and import log parsing in a single command.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @icedq/cli
11
+ ```
12
+
13
+ Requires Node.js 18 or newer.
14
+
15
+ ## Authenticate
16
+
17
+ Set the following environment variables (or pass equivalent flags):
18
+
19
+ | Variable | Description |
20
+ |---|---|
21
+ | `ICEDQ_URL` | iceDQ instance base URL, e.g. `https://app.icedq.com` |
22
+ | `ICEDQ_KEYCLOAK_URL` | Keycloak token endpoint base, e.g. `https://auth.icedq.com/auth/realms/icedq` |
23
+ | `ICEDQ_CLIENT_ID` | OAuth client ID (`client_credentials` grant) |
24
+ | `ICEDQ_CLIENT_SECRET` | OAuth client secret |
25
+ | `ICEDQ_ORG_ID` | iceDQ organization ID |
26
+ | `ICEDQ_ACCOUNT_ID` | iceDQ account ID |
27
+ | `ICEDQ_WORKSPACE_ID` | Source/target workspace ID |
28
+
29
+ ## Commands (v0.1)
30
+
31
+ ### `icedq export`
32
+
33
+ Initiates an export, polls until complete, downloads the bundle.
34
+
35
+ ```bash
36
+ icedq export --resource workflow --id wkfl-... --output-file ./finance.zip
37
+ icedq export --resource folder --id fldr-... --include-child --output-file ./finance.zip
38
+ ```
39
+
40
+ ### `icedq import`
41
+
42
+ Submits a bundle, polls until complete, parses the log.
43
+
44
+ ```bash
45
+ icedq import \
46
+ --bundle ./finance.zip \
47
+ --kind workflows \
48
+ --mapping-file ./mapping.json \
49
+ --strict \
50
+ --retain-log ./icedq-import.log
51
+ ```
52
+
53
+ A hand-authored `mapping.json` is required in v0.1. Auto-mapping by name (`generate-mapping`) ships in v0.2.
54
+
55
+ ## GitHub Actions
56
+
57
+ For CI/CD usage via GitHub Actions, see the **[Using the iceDQ GitHub Actions](docs/github-actions.md)** guide. It covers prerequisites (Keycloak `client_credentials` setup, GitHub secrets/environments), quick-start examples, a full Dev → QA → UAT → Prod promotion pipeline, mapping file authoring, self-hosted runners, troubleshooting, and FAQ.
58
+
59
+ Companion repos:
60
+
61
+ - [`icedq-tools/export-action`](https://github.com/icedq-tools/export-action)
62
+ - [`icedq-tools/import-action`](https://github.com/icedq-tools/import-action)
63
+
64
+ ## Roadmap
65
+
66
+ - v0.2 — `icedq generate-mapping`, `icedq jobs`, `icedq published`, `icedq validate`
67
+ - v0.2 — companion GitHub Actions: `icedq/export-action`, `icedq/validate-action`
68
+
69
+ The build specification is the source of truth for behavior.
package/package.json ADDED
@@ -0,0 +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
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from 'commander';
3
+ import { runExport } from '../commands/export.js';
4
+ import { runImport } from '../commands/import.js';
5
+ import { CliError } from '../core/errors.js';
6
+ import { log } from '../core/logger.js';
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name('icedq')
12
+ .description('CLI for iceDQ rule and workflow promotion')
13
+ .version('0.1.0');
14
+
15
+ function addGlobalOptions(cmd) {
16
+ cmd
17
+ .option('--icedq-url <url>', 'iceDQ instance base URL [env: ICEDQ_URL]')
18
+ .option('--keycloak-url <url>', 'Keycloak token endpoint base [env: ICEDQ_KEYCLOAK_URL]')
19
+ .option('--client-id <id>', 'OAuth client ID [env: ICEDQ_CLIENT_ID]')
20
+ .option('--client-secret <secret>', 'OAuth client secret [env: ICEDQ_CLIENT_SECRET]')
21
+ .option('--org-id <id>', '[env: ICEDQ_ORG_ID]')
22
+ .option('--account-id <id>', '[env: ICEDQ_ACCOUNT_ID]')
23
+ .option('--workspace-id <id>', '[env: ICEDQ_WORKSPACE_ID]')
24
+ .option('--verify-ssl <bool>', 'verify TLS (default true)')
25
+ .option('--timeout <seconds>', 'polling timeout in seconds', '1800')
26
+ .addOption(new Option('--output <format>', 'output format').choices(['text', 'json', 'markdown']).default('text'))
27
+ .option('-v, --verbose', 'verbose logging')
28
+ .option('-q, --quiet', 'suppress non-error logging');
29
+ return cmd;
30
+ }
31
+
32
+ addGlobalOptions(
33
+ program
34
+ .command('export')
35
+ .description('Export rules, workflows, or folders to a bundle')
36
+ .requiredOption('--resource <kind>', 'rule | workflow | folder')
37
+ .requiredOption('--id <uuid>', 'resource UUID')
38
+ .option('--include-child', 'recurse folder children (folder resource only)', false)
39
+ .requiredOption('--output-file <path>', 'where to write the bundle ZIP')
40
+ ).action(async (opts, cmd) => wrap(() => runExport(merge(cmd, opts))));
41
+
42
+ addGlobalOptions(
43
+ program
44
+ .command('import')
45
+ .description('Import a bundle into the target workspace')
46
+ .requiredOption('--bundle <path>', 'path to the export ZIP')
47
+ .requiredOption('--kind <kind>', 'rules | workflows')
48
+ .requiredOption('--mapping-file <path>', 'mapping JSON (auto-generation arrives in v0.2)')
49
+ .option('--use-fqn', 'use FQN (name) resolution instead of UUIDs', false)
50
+ .option('--strict', 'exit non-zero on any skipped rule', false)
51
+ .option('--terminate-on-conflict', 'cancel any active import in the target workspace and retry', false)
52
+ .option('--retain-log <path>', 'write the full import log to this path')
53
+ ).action(async (opts, cmd) => wrap(() => runImport(merge(cmd, opts))));
54
+
55
+ function merge(cmd, localOpts) {
56
+ return { ...cmd.parent.opts(), ...cmd.opts(), ...localOpts };
57
+ }
58
+
59
+ async function wrap(fn) {
60
+ try {
61
+ await fn();
62
+ } catch (err) {
63
+ if (err instanceof CliError) {
64
+ log.error(err.message);
65
+ process.exit(err.exitCode || 1);
66
+ }
67
+ log.error(`Unexpected error: ${err.message}`, { stack: err.stack });
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ program.parseAsync(process.argv).catch((err) => {
73
+ log.error(`Fatal: ${err.message}`);
74
+ process.exit(1);
75
+ });
@@ -0,0 +1,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
+ {
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
+ }
@@ -0,0 +1,175 @@
1
+ import { readFile, 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 { parseImportLog } from '../lib/log-parser.js';
9
+ import { isSuccess } from '../lib/status-enum.js';
10
+ import { ApiError, BundleError, CliError, TaskFailedError } from '../core/errors.js';
11
+ import { log, setLevel } from '../core/logger.js';
12
+
13
+ const VALID_KINDS = new Set(['rules', 'workflows']);
14
+
15
+ export async function runImport(rawOpts) {
16
+ if (rawOpts.verbose) setLevel('debug');
17
+ if (rawOpts.quiet) setLevel('error');
18
+
19
+ if (!rawOpts.bundle) throw new CliError('--bundle is required');
20
+ if (!rawOpts.kind || !VALID_KINDS.has(rawOpts.kind)) {
21
+ throw new CliError(`--kind must be one of: rules, workflows`);
22
+ }
23
+ if (!rawOpts.mappingFile) {
24
+ throw new CliError(
25
+ '--mapping-file is required in v0.1. Auto-mapping (`generate-mapping`) ships in v0.2.'
26
+ );
27
+ }
28
+
29
+ const cfg = loadConfig(rawOpts);
30
+ const auth = new KeycloakClientCredentialsAuth({
31
+ keycloakUrl: cfg.keycloakUrl,
32
+ clientId: cfg.clientId,
33
+ clientSecret: cfg.clientSecret,
34
+ verifySsl: cfg.verifySsl
35
+ });
36
+ const client = new IcedqApiClient({
37
+ baseUrl: cfg.icedqUrl,
38
+ orgId: cfg.orgId,
39
+ accountId: cfg.accountId,
40
+ workspaceId: cfg.workspaceId,
41
+ auth,
42
+ verifySsl: cfg.verifySsl
43
+ });
44
+
45
+ const bundlePath = path.resolve(rawOpts.bundle);
46
+ const mappingPath = path.resolve(rawOpts.mappingFile);
47
+
48
+ let bundleBuffer;
49
+ try {
50
+ bundleBuffer = await readFile(bundlePath);
51
+ } catch (err) {
52
+ throw new BundleError(`could not read bundle at ${bundlePath}: ${err.message}`);
53
+ }
54
+
55
+ let mappingDoc;
56
+ try {
57
+ const text = await readFile(mappingPath, 'utf8');
58
+ mappingDoc = JSON.parse(text);
59
+ } catch (err) {
60
+ throw new CliError(`could not parse --mapping-file at ${mappingPath}: ${err.message}`);
61
+ }
62
+
63
+ if (rawOpts.useFqn !== undefined) {
64
+ mappingDoc.useFqn = !!rawOpts.useFqn;
65
+ }
66
+ if (mappingDoc.useFqn === undefined) mappingDoc.useFqn = false;
67
+
68
+ const submitParts = {
69
+ file: {
70
+ buffer: bundleBuffer,
71
+ filename: path.basename(bundlePath),
72
+ contentType: 'application/zip'
73
+ },
74
+ mapping: { json: mappingDoc }
75
+ };
76
+
77
+ const importPath = `/api/v1/imports/${rawOpts.kind}`;
78
+ log.info('Submitting import', { kind: rawOpts.kind, bundle: bundlePath, mapping: mappingPath });
79
+
80
+ let submitResp;
81
+ try {
82
+ submitResp = await client.postMultipart(importPath, submitParts);
83
+ } catch (err) {
84
+ if (err instanceof ApiError && err.isConstraintViolation()) {
85
+ const result = {
86
+ command: 'import',
87
+ status: 'ConstraintViolation',
88
+ hardErrors: err.messages.map(
89
+ (m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`
90
+ )
91
+ };
92
+ new Reporter(rawOpts.output || 'text').emit(result);
93
+ throw err;
94
+ }
95
+ if (err instanceof ApiError && err.status === 409 && rawOpts.terminateOnConflict) {
96
+ log.warn('Active import job conflicts; locating and terminating');
97
+ await terminateActiveImport(client);
98
+ submitResp = await client.postMultipart(importPath, submitParts);
99
+ } else {
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ const taskId = submitResp.taskInstanceId;
105
+ if (!taskId) throw new CliError(`Import submit did not return taskInstanceId: ${JSON.stringify(submitResp)}`);
106
+ process.stderr.write(`task-id: ${taskId}\n`);
107
+
108
+ const start = Date.now();
109
+ const { status, elapsedMs, attempts } = await pollTask(client, 'imports', taskId, {
110
+ timeoutSec: cfg.timeoutSec,
111
+ onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
112
+ });
113
+
114
+ let logText = '';
115
+ try {
116
+ logText = await client.get(`/api/v1/imports/${taskId}/log`);
117
+ if (typeof logText !== 'string') logText = JSON.stringify(logText);
118
+ } catch (err) {
119
+ log.warn('Could not retrieve import log', { error: err.message });
120
+ }
121
+
122
+ if (rawOpts.retainLog && logText) {
123
+ const logPath = path.resolve(rawOpts.retainLog);
124
+ try {
125
+ await writeFile(logPath, logText, 'utf8');
126
+ log.info('Import log retained', { logPath });
127
+ } catch (err) {
128
+ log.warn('Could not write retained log', { logPath, error: err.message });
129
+ }
130
+ }
131
+
132
+ const parsed = parseImportLog(logText);
133
+ const result = {
134
+ command: 'import',
135
+ taskId,
136
+ status,
137
+ attempts,
138
+ durationMs: elapsedMs,
139
+ skippedCount: parsed.skippedCount,
140
+ skippedRules: parsed.skippedRules,
141
+ hardErrors: parsed.hardErrors,
142
+ elapsedSinceSubmitMs: Date.now() - start
143
+ };
144
+ new Reporter(rawOpts.output || 'text').emit(result);
145
+
146
+ if (!isSuccess(status)) {
147
+ throw new TaskFailedError(taskId, 'import', status);
148
+ }
149
+ if (rawOpts.strict && parsed.skippedCount > 0) {
150
+ const e = new CliError(
151
+ `--strict: import completed but ${parsed.skippedCount} rule(s) skipped. See log for details.`
152
+ );
153
+ e.exitCode = 1;
154
+ throw e;
155
+ }
156
+ }
157
+
158
+ async function terminateActiveImport(client) {
159
+ const search = await client.post('/api/v1/taskruns/search', {
160
+ filter: [
161
+ { attribute: 'type', operator: 'In', value: 'import-rules' },
162
+ { attribute: 'type', operator: 'In', value: 'import-workflows' }
163
+ ]
164
+ });
165
+ const active = (search?.items || search?.taskRuns || []).find((r) =>
166
+ ['Submitted', 'Running'].includes(r.taskStatus || r.status)
167
+ );
168
+ if (!active) {
169
+ log.warn('No active import found to terminate');
170
+ return;
171
+ }
172
+ const id = active.id || active.taskInstanceId;
173
+ log.info('Terminating active import', { taskId: id });
174
+ await client.post(`/api/v1/imports/${id}:terminate`, {});
175
+ }
@@ -0,0 +1,101 @@
1
+ import https from 'node:https';
2
+ import http from 'node:http';
3
+ import { URL } from 'node:url';
4
+ import { AuthError } from './errors.js';
5
+ import { log } from './logger.js';
6
+
7
+ const REFRESH_BUFFER_MS = 30 * 1000;
8
+
9
+ export class KeycloakClientCredentialsAuth {
10
+ constructor({ keycloakUrl, clientId, clientSecret, verifySsl = true }) {
11
+ if (!keycloakUrl || !clientId || !clientSecret) {
12
+ throw new AuthError('keycloakUrl, clientId, and clientSecret are required');
13
+ }
14
+ this.keycloakUrl = keycloakUrl.replace(/\/+$/, '');
15
+ this.clientId = clientId;
16
+ this.clientSecret = clientSecret;
17
+ this.verifySsl = verifySsl;
18
+ this._token = null;
19
+ this._expiresAt = 0;
20
+ this._inFlight = null;
21
+ }
22
+
23
+ async getToken() {
24
+ if (this._token && Date.now() < this._expiresAt - REFRESH_BUFFER_MS) {
25
+ return this._token;
26
+ }
27
+ if (!this._inFlight) {
28
+ this._inFlight = this._fetchToken().finally(() => {
29
+ this._inFlight = null;
30
+ });
31
+ }
32
+ return this._inFlight;
33
+ }
34
+
35
+ async forceRefresh() {
36
+ this._token = null;
37
+ this._expiresAt = 0;
38
+ return this.getToken();
39
+ }
40
+
41
+ async _fetchToken() {
42
+ const tokenUrl = `${this.keycloakUrl}/protocol/openid-connect/token`;
43
+ const body = new URLSearchParams({
44
+ grant_type: 'client_credentials',
45
+ client_id: this.clientId,
46
+ client_secret: this.clientSecret
47
+ }).toString();
48
+
49
+ log.debug('Requesting Keycloak token', { url: tokenUrl, clientId: this.clientId });
50
+
51
+ const response = await this._post(tokenUrl, body);
52
+
53
+ if (!response.access_token) {
54
+ throw new AuthError('Token response missing access_token');
55
+ }
56
+
57
+ this._token = response.access_token;
58
+ const ttlMs = (response.expires_in ?? 300) * 1000;
59
+ this._expiresAt = Date.now() + ttlMs;
60
+
61
+ log.info('Keycloak token acquired', { expiresInSec: response.expires_in ?? 300 });
62
+ return this._token;
63
+ }
64
+
65
+ _post(urlString, body) {
66
+ return new Promise((resolve, reject) => {
67
+ const url = new URL(urlString);
68
+ const isHttps = url.protocol === 'https:';
69
+ const lib = isHttps ? https : http;
70
+ const opts = {
71
+ method: 'POST',
72
+ headers: {
73
+ 'Content-Type': 'application/x-www-form-urlencoded',
74
+ 'Content-Length': Buffer.byteLength(body),
75
+ Accept: 'application/json'
76
+ }
77
+ };
78
+ if (isHttps && !this.verifySsl) opts.rejectUnauthorized = false;
79
+
80
+ const req = lib.request(url, opts, (res) => {
81
+ const chunks = [];
82
+ res.on('data', (c) => chunks.push(c));
83
+ res.on('end', () => {
84
+ const text = Buffer.concat(chunks).toString('utf8');
85
+ if (res.statusCode < 200 || res.statusCode >= 300) {
86
+ reject(new AuthError(`HTTP ${res.statusCode} from Keycloak: ${text}`));
87
+ return;
88
+ }
89
+ try {
90
+ resolve(JSON.parse(text));
91
+ } catch (err) {
92
+ reject(new AuthError(`Invalid JSON from Keycloak: ${err.message}`));
93
+ }
94
+ });
95
+ });
96
+ req.on('error', (err) => reject(new AuthError(err.message, { cause: err })));
97
+ req.write(body);
98
+ req.end();
99
+ });
100
+ }
101
+ }
@@ -0,0 +1,182 @@
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 };
@@ -0,0 +1,68 @@
1
+ import { ConfigError } from './errors.js';
2
+
3
+ const FIELD_TO_ENV = {
4
+ icedqUrl: 'ICEDQ_URL',
5
+ keycloakUrl: 'ICEDQ_KEYCLOAK_URL',
6
+ clientId: 'ICEDQ_CLIENT_ID',
7
+ clientSecret: 'ICEDQ_CLIENT_SECRET',
8
+ orgId: 'ICEDQ_ORG_ID',
9
+ accountId: 'ICEDQ_ACCOUNT_ID',
10
+ workspaceId: 'ICEDQ_WORKSPACE_ID',
11
+ verifySsl: 'ICEDQ_VERIFY_SSL',
12
+ timeout: 'ICEDQ_TIMEOUT'
13
+ };
14
+
15
+ const DEFAULT_REQUIRED = ['icedqUrl', 'keycloakUrl', 'clientId', 'clientSecret', 'orgId', 'accountId'];
16
+
17
+ function pick(opts, key, env) {
18
+ const flag = opts[key];
19
+ if (flag !== undefined && flag !== null && flag !== '') return flag;
20
+ const envName = FIELD_TO_ENV[key];
21
+ const envVal = env[envName];
22
+ if (envVal !== undefined && envVal !== '') return envVal;
23
+ return undefined;
24
+ }
25
+
26
+ function toBool(v, fallback) {
27
+ if (v === undefined) return fallback;
28
+ if (typeof v === 'boolean') return v;
29
+ const s = String(v).toLowerCase().trim();
30
+ if (['true', '1', 'yes', 'y'].includes(s)) return true;
31
+ if (['false', '0', 'no', 'n'].includes(s)) return false;
32
+ return fallback;
33
+ }
34
+
35
+ function toInt(v, fallback) {
36
+ if (v === undefined || v === null || v === '') return fallback;
37
+ const n = Number.parseInt(String(v), 10);
38
+ return Number.isFinite(n) ? n : fallback;
39
+ }
40
+
41
+ function trimTrailingSlash(url) {
42
+ return typeof url === 'string' ? url.replace(/\/+$/, '') : url;
43
+ }
44
+
45
+ export function loadConfig(opts = {}, env = process.env, { requireWorkspace = true } = {}) {
46
+ const required = requireWorkspace ? [...DEFAULT_REQUIRED, 'workspaceId'] : DEFAULT_REQUIRED;
47
+
48
+ const cfg = {
49
+ icedqUrl: trimTrailingSlash(pick(opts, 'icedqUrl', env)),
50
+ keycloakUrl: trimTrailingSlash(pick(opts, 'keycloakUrl', env)),
51
+ clientId: pick(opts, 'clientId', env),
52
+ clientSecret: pick(opts, 'clientSecret', env),
53
+ orgId: pick(opts, 'orgId', env),
54
+ accountId: pick(opts, 'accountId', env),
55
+ workspaceId: pick(opts, 'workspaceId', env),
56
+ verifySsl: toBool(pick(opts, 'verifySsl', env), true),
57
+ timeoutSec: toInt(pick(opts, 'timeout', env), 1800)
58
+ };
59
+
60
+ const missing = required.filter((k) => !cfg[k]);
61
+ if (missing.length > 0) {
62
+ throw new ConfigError(missing.map((k) => FIELD_TO_ENV[k]));
63
+ }
64
+
65
+ return Object.freeze(cfg);
66
+ }
67
+
68
+ export const _internal = { FIELD_TO_ENV, DEFAULT_REQUIRED };
@@ -0,0 +1,62 @@
1
+ export class CliError extends Error {
2
+ constructor(message, { exitCode = 1, cause } = {}) {
3
+ super(message);
4
+ this.name = this.constructor.name;
5
+ this.exitCode = exitCode;
6
+ if (cause) this.cause = cause;
7
+ }
8
+ }
9
+
10
+ export class ConfigError extends CliError {
11
+ constructor(missing) {
12
+ const list = Array.isArray(missing) ? missing : [missing];
13
+ super(`Missing required configuration: ${list.join(', ')}`, { exitCode: 2 });
14
+ this.missing = list;
15
+ }
16
+ }
17
+
18
+ export class AuthError extends CliError {
19
+ constructor(message, { cause } = {}) {
20
+ super(`Authentication failed: ${message}`, { exitCode: 2, cause });
21
+ }
22
+ }
23
+
24
+ export class ApiError extends CliError {
25
+ constructor(message, { status, code, messages, body, endpoint } = {}) {
26
+ super(message, { exitCode: 2 });
27
+ this.status = status;
28
+ this.code = code;
29
+ this.messages = messages;
30
+ this.body = body;
31
+ this.endpoint = endpoint;
32
+ }
33
+
34
+ isConstraintViolation() {
35
+ return this.code === 'ConstraintViolation';
36
+ }
37
+ }
38
+
39
+ export class PollTimeoutError extends CliError {
40
+ constructor(taskId, kind, elapsedMs) {
41
+ super(`Timed out waiting for ${kind} task ${taskId} after ${elapsedMs}ms`, { exitCode: 3 });
42
+ this.taskId = taskId;
43
+ this.kind = kind;
44
+ this.elapsedMs = elapsedMs;
45
+ }
46
+ }
47
+
48
+ export class TaskFailedError extends CliError {
49
+ constructor(taskId, kind, status, logTail) {
50
+ super(`${kind} task ${taskId} ended in status ${status}`, { exitCode: 1 });
51
+ this.taskId = taskId;
52
+ this.kind = kind;
53
+ this.status = status;
54
+ this.logTail = logTail;
55
+ }
56
+ }
57
+
58
+ export class BundleError extends CliError {
59
+ constructor(message) {
60
+ super(`Bundle error: ${message}`, { exitCode: 2 });
61
+ }
62
+ }
@@ -0,0 +1,58 @@
1
+ const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
2
+
3
+ let level = LEVELS.info;
4
+
5
+ export function setLevel(name) {
6
+ if (Object.prototype.hasOwnProperty.call(LEVELS, name)) {
7
+ level = LEVELS[name];
8
+ }
9
+ }
10
+
11
+ const TOKEN_PATTERNS = [
12
+ /Bearer\s+[A-Za-z0-9._\-+/=]+/g,
13
+ /(access_token|refresh_token|client_secret)["':\s=]+["']?[A-Za-z0-9._\-+/=]+["']?/gi
14
+ ];
15
+
16
+ function maskTokens(value) {
17
+ if (typeof value === 'string') {
18
+ let out = value;
19
+ for (const pattern of TOKEN_PATTERNS) {
20
+ out = out.replace(pattern, (m) => {
21
+ const head = m.slice(0, m.indexOf(' ') > 0 ? m.indexOf(' ') + 1 : 12);
22
+ return `${head}***`;
23
+ });
24
+ }
25
+ return out;
26
+ }
27
+ if (value && typeof value === 'object') {
28
+ const clone = Array.isArray(value) ? [] : {};
29
+ for (const [k, v] of Object.entries(value)) {
30
+ if (/(authorization|token|secret|password)/i.test(k)) {
31
+ clone[k] = '***';
32
+ } else {
33
+ clone[k] = maskTokens(v);
34
+ }
35
+ }
36
+ return clone;
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function emit(name, lvl, msg, ctx) {
42
+ if (lvl > level) return;
43
+ const stamp = new Date().toISOString();
44
+ const safeCtx = ctx ? maskTokens(ctx) : undefined;
45
+ const line = safeCtx
46
+ ? `[${stamp}] ${name.toUpperCase()} ${maskTokens(msg)} ${JSON.stringify(safeCtx)}`
47
+ : `[${stamp}] ${name.toUpperCase()} ${maskTokens(msg)}`;
48
+ process.stderr.write(line + '\n');
49
+ }
50
+
51
+ export const log = {
52
+ error: (msg, ctx) => emit('error', LEVELS.error, msg, ctx),
53
+ warn: (msg, ctx) => emit('warn', LEVELS.warn, msg, ctx),
54
+ info: (msg, ctx) => emit('info', LEVELS.info, msg, ctx),
55
+ debug: (msg, ctx) => emit('debug', LEVELS.debug, msg, ctx)
56
+ };
57
+
58
+ export const _internal = { maskTokens };
@@ -0,0 +1,60 @@
1
+ import { isTerminal } from '../lib/status-enum.js';
2
+ import { PollTimeoutError } from './errors.js';
3
+ import { log } from './logger.js';
4
+
5
+ const INITIAL_DELAY_MS = 2000;
6
+ const MAX_DELAY_MS = 30000;
7
+ const BACKOFF = 2;
8
+
9
+ const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
10
+
11
+ export async function pollTask(client, kind, taskId, opts = {}) {
12
+ if (kind !== 'exports' && kind !== 'imports') {
13
+ throw new Error(`pollTask: kind must be 'exports' or 'imports', got '${kind}'`);
14
+ }
15
+
16
+ const timeoutMs = (opts.timeoutSec ?? 1800) * 1000;
17
+ const onTick = opts.onTick || (() => {});
18
+ const sleeper = opts.sleep || sleep;
19
+ const now = opts.now || (() => Date.now());
20
+
21
+ const start = now();
22
+ let delay = opts.initialDelayMs ?? INITIAL_DELAY_MS;
23
+ let attempt = 0;
24
+
25
+ while (true) {
26
+ attempt++;
27
+ const elapsedMs = now() - start;
28
+ if (elapsedMs >= timeoutMs) {
29
+ throw new PollTimeoutError(taskId, kind, elapsedMs);
30
+ }
31
+
32
+ const remaining = timeoutMs - elapsedMs;
33
+ const waitMs = Math.min(delay, remaining);
34
+ await sleeper(waitMs);
35
+
36
+ let response;
37
+ try {
38
+ response = await client.get(`/api/v1/${kind}/${taskId}/status`, { retryOn5xx: true });
39
+ } catch (err) {
40
+ log.warn('Status poll failed', { taskId, attempt, error: err.message });
41
+ throw err;
42
+ }
43
+
44
+ const status = extractStatus(response);
45
+ onTick({ status, attempt, elapsedMs: now() - start, response });
46
+
47
+ if (isTerminal(status)) {
48
+ return { status, response, attempts: attempt, elapsedMs: now() - start };
49
+ }
50
+
51
+ delay = Math.min(delay * BACKOFF, MAX_DELAY_MS);
52
+ }
53
+ }
54
+
55
+ function extractStatus(response) {
56
+ if (!response || typeof response !== 'object') return undefined;
57
+ return response.taskStatus || response.status || response.state;
58
+ }
59
+
60
+ export const _internal = { extractStatus, INITIAL_DELAY_MS, MAX_DELAY_MS, BACKOFF };
@@ -0,0 +1,64 @@
1
+ export class Reporter {
2
+ constructor(format = 'text') {
3
+ this.format = format;
4
+ }
5
+
6
+ emit(result) {
7
+ switch (this.format) {
8
+ case 'json':
9
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
10
+ return;
11
+ case 'markdown':
12
+ process.stdout.write(toMarkdown(result) + '\n');
13
+ return;
14
+ case 'text':
15
+ default:
16
+ process.stdout.write(toText(result) + '\n');
17
+ }
18
+ }
19
+ }
20
+
21
+ function toText(r) {
22
+ const lines = [];
23
+ lines.push(`Command: ${r.command}`);
24
+ if (r.taskId) lines.push(`Task ID: ${r.taskId}`);
25
+ lines.push(`Status: ${r.status}`);
26
+ if (r.durationMs !== undefined) lines.push(`Duration: ${(r.durationMs / 1000).toFixed(1)}s`);
27
+ if (r.outputFile) lines.push(`Output: ${r.outputFile}`);
28
+ if (r.skippedCount !== undefined) lines.push(`Skipped: ${r.skippedCount}`);
29
+
30
+ if (r.skippedRules?.length) {
31
+ lines.push('');
32
+ lines.push('Skipped rules:');
33
+ for (const s of r.skippedRules) lines.push(` - ${s.name}: ${s.reason}`);
34
+ }
35
+ if (r.hardErrors?.length) {
36
+ lines.push('');
37
+ lines.push('Errors:');
38
+ for (const e of r.hardErrors) lines.push(` - ${e}`);
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+
43
+ function toMarkdown(r) {
44
+ const lines = [];
45
+ lines.push(`## iceDQ ${r.command}`);
46
+ lines.push('');
47
+ lines.push(`- **Status:** ${r.status}`);
48
+ if (r.taskId) lines.push(`- **Task ID:** \`${r.taskId}\``);
49
+ if (r.durationMs !== undefined) lines.push(`- **Duration:** ${(r.durationMs / 1000).toFixed(1)}s`);
50
+ if (r.outputFile) lines.push(`- **Output:** \`${r.outputFile}\``);
51
+ if (r.skippedCount !== undefined) lines.push(`- **Skipped:** ${r.skippedCount}`);
52
+
53
+ if (r.skippedRules?.length) {
54
+ lines.push('');
55
+ lines.push('### Skipped rules');
56
+ for (const s of r.skippedRules) lines.push(`- \`${s.name}\` — ${s.reason}`);
57
+ }
58
+ if (r.hardErrors?.length) {
59
+ lines.push('');
60
+ lines.push('### Errors');
61
+ for (const e of r.hardErrors) lines.push(`- ${e}`);
62
+ }
63
+ return lines.join('\n');
64
+ }
@@ -0,0 +1,55 @@
1
+ const SKIP_PATTERNS = [
2
+ /(?:Skipped|Skipping)\s+(?:rule|workflow)\s+["']?([^"'\s:]+)["']?\s*[:\-]?\s*(.+?)$/i,
3
+ /Rule\s+["']?([^"'\s]+)["']?\s+(?:was\s+)?skipped\s*[:\-]?\s*(.+?)$/i
4
+ ];
5
+
6
+ const ERROR_PATTERNS = [
7
+ /^\s*(?:ERROR|FATAL)\b\s*[:\-]?\s*(.+)$/i,
8
+ /^\s*Error\s*[:\-]\s*(.+)$/
9
+ ];
10
+
11
+ export function parseImportLog(text) {
12
+ if (!text || typeof text !== 'string') {
13
+ return { skippedCount: 0, skippedRules: [], hardErrors: [] };
14
+ }
15
+
16
+ const skippedRules = [];
17
+ const hardErrors = [];
18
+ const seenSkips = new Set();
19
+
20
+ for (const rawLine of text.split(/\r?\n/)) {
21
+ const line = rawLine.trim();
22
+ if (!line) continue;
23
+
24
+ let matched = false;
25
+ for (const re of SKIP_PATTERNS) {
26
+ const m = line.match(re);
27
+ if (m) {
28
+ const name = m[1].trim();
29
+ const reason = (m[2] || 'skipped').trim();
30
+ const key = `${name}::${reason}`;
31
+ if (!seenSkips.has(key)) {
32
+ seenSkips.add(key);
33
+ skippedRules.push({ name, reason });
34
+ }
35
+ matched = true;
36
+ break;
37
+ }
38
+ }
39
+ if (matched) continue;
40
+
41
+ for (const re of ERROR_PATTERNS) {
42
+ const m = line.match(re);
43
+ if (m) {
44
+ hardErrors.push(m[1].trim());
45
+ break;
46
+ }
47
+ }
48
+ }
49
+
50
+ return {
51
+ skippedCount: skippedRules.length,
52
+ skippedRules,
53
+ hardErrors
54
+ };
55
+ }
@@ -0,0 +1,17 @@
1
+ export const STATUS = {
2
+ SUBMITTED: 'Submitted',
3
+ RUNNING: 'Running',
4
+ COMPLETED: 'Completed',
5
+ TERMINATED: 'Terminated',
6
+ ERROR: 'Error'
7
+ };
8
+
9
+ export const TERMINAL_STATES = new Set([STATUS.COMPLETED, STATUS.TERMINATED, STATUS.ERROR]);
10
+
11
+ export function isTerminal(status) {
12
+ return TERMINAL_STATES.has(status);
13
+ }
14
+
15
+ export function isSuccess(status) {
16
+ return status === STATUS.COMPLETED;
17
+ }