@icedq/cli 0.1.3 → 0.2.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.
@@ -0,0 +1,217 @@
1
+ import { readFile, writeFile, mkdir } 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 { Reporter } from '../core/reporter.js';
7
+ import { ApiError, CliError } from '../core/errors.js';
8
+ import { log, setLevel } from '../core/logger.js';
9
+
10
+ const PAGE_SIZE = 1000;
11
+
12
+ export async function runGenerateMapping(rawOpts) {
13
+ if (rawOpts.verbose) setLevel('debug');
14
+ if (rawOpts.quiet) setLevel('error');
15
+
16
+ if (!rawOpts.bundle) throw new CliError('--bundle is required');
17
+ if (!rawOpts.outputFile) throw new CliError('--output-file is required');
18
+
19
+ const cfg = loadConfig(rawOpts);
20
+ const auth = new KeycloakClientCredentialsAuth({
21
+ keycloakUrl: cfg.keycloakUrl,
22
+ clientId: cfg.clientId,
23
+ clientSecret: cfg.clientSecret,
24
+ verifySsl: cfg.verifySsl
25
+ });
26
+ const client = new IcedqApiClient({
27
+ baseUrl: cfg.icedqUrl,
28
+ orgId: cfg.orgId,
29
+ accountId: cfg.accountId,
30
+ workspaceId: cfg.workspaceId,
31
+ auth,
32
+ verifySsl: cfg.verifySsl
33
+ });
34
+
35
+ const bundlePath = path.resolve(rawOpts.bundle);
36
+ let bundleBuffer;
37
+ try {
38
+ bundleBuffer = await readFile(bundlePath);
39
+ } catch (err) {
40
+ throw new CliError(`could not read bundle at ${bundlePath}: ${err.message}`);
41
+ }
42
+
43
+ // Step 1: get expected mappings from bundle
44
+ log.info('Uploading bundle to get expected mappings');
45
+ const expected = await client.postMultipart('/api/v1/internal/imports/mapping', {
46
+ file: {
47
+ buffer: bundleBuffer,
48
+ filename: path.basename(bundlePath),
49
+ contentType: 'application/zip'
50
+ }
51
+ });
52
+
53
+ const srcConnections = expected.connections || [];
54
+ const srcParameters = expected.parameters || [];
55
+ const srcCustomFields = expected.customFields || [];
56
+
57
+ log.info('Expected mappings', {
58
+ connections: srcConnections.length,
59
+ parameters: srcParameters.length,
60
+ customFields: srcCustomFields.length
61
+ });
62
+
63
+ // Step 2: resolve connections from target environment
64
+ const connections = await resolveConnections(client, srcConnections);
65
+
66
+ // Step 3: resolve parameters from target environment
67
+ const parameters = await resolveParameters(client, srcParameters);
68
+
69
+ // Step 4: resolve custom fields from target environment
70
+ const customFields = await resolveCustomFields(client, srcCustomFields);
71
+
72
+ const mappingDoc = {
73
+ useFqn: true,
74
+ mapping: { connections, parameters, customFields }
75
+ };
76
+
77
+ const outputFile = path.resolve(rawOpts.outputFile);
78
+ await mkdir(path.dirname(outputFile), { recursive: true });
79
+ await writeFile(outputFile, JSON.stringify(mappingDoc, null, 2), 'utf8');
80
+ log.info('Mapping file written', { outputFile });
81
+
82
+ const result = {
83
+ command: 'generate-mapping',
84
+ outputFile,
85
+ connections: connections.length,
86
+ parameters: parameters.length,
87
+ customFields: customFields.length
88
+ };
89
+ new Reporter(rawOpts.output || 'text').emit(result);
90
+ }
91
+
92
+ async function resolveConnections(client, srcConnections) {
93
+ if (srcConnections.length === 0) return [];
94
+
95
+ // Group source connections by connectorId
96
+ const byConnectorId = new Map();
97
+ for (const conn of srcConnections) {
98
+ if (!byConnectorId.has(conn.connectorId)) byConnectorId.set(conn.connectorId, []);
99
+ byConnectorId.get(conn.connectorId).push(conn);
100
+ }
101
+
102
+ const mappings = [];
103
+
104
+ for (const [connectorId, srcConns] of byConnectorId) {
105
+ log.info('Searching connections in target', { connectorId });
106
+
107
+ // Fetch all pages for this connectorId
108
+ const targetItems = await fetchAllPages(client, '/api/v1/connections/search', {
109
+ filter: [{ attribute: 'connectorId', operator: 'In', datatype: 'string', value: connectorId }]
110
+ }, `connections with connectorId=${connectorId}`);
111
+
112
+ for (const src of srcConns) {
113
+ const match = targetItems.find(
114
+ (t) => t.name.toLowerCase() === src.name.toLowerCase()
115
+ );
116
+ if (!match) {
117
+ throw new CliError(
118
+ `Connection "${src.name}" (connectorId: ${connectorId}) was not found in the target environment. ` +
119
+ `Ensure a connection with this name exists in the target workspace before generating the mapping.`
120
+ );
121
+ }
122
+ mappings.push({ existingId: src.id, newId: match.id, action: 'override' });
123
+ log.info('Mapped connection', { name: src.name, existingId: src.id, newId: match.id });
124
+ }
125
+ }
126
+
127
+ return mappings;
128
+ }
129
+
130
+ async function resolveParameters(client, srcParameters) {
131
+ if (srcParameters.length === 0) return [];
132
+
133
+ log.info('Searching parameters in target');
134
+
135
+ let targetItems = [];
136
+ try {
137
+ targetItems = await fetchAllPages(client, '/api/v1/parameters/search', {}, 'parameters');
138
+ } catch (err) {
139
+ if (err instanceof ApiError && err.code === 'ResultsNotFound') {
140
+ log.warn('No parameters found in target environment — mapping without newId');
141
+ return srcParameters.map((p) => ({ existingId: p.id, action: 'upsert' }));
142
+ }
143
+ throw err;
144
+ }
145
+
146
+ return srcParameters.map((src) => {
147
+ const match = targetItems.find(
148
+ (t) => t.name.toLowerCase() === src.name.toLowerCase()
149
+ );
150
+ if (!match) {
151
+ log.warn('Parameter not matched in target, mapping without newId', { name: src.name });
152
+ return { existingId: src.id, action: 'upsert' };
153
+ }
154
+ log.info('Mapped parameter', { name: src.name, existingId: src.id, newId: match.id });
155
+ return { existingId: src.id, newId: match.id, action: 'upsert' };
156
+ });
157
+ }
158
+
159
+ async function resolveCustomFields(client, srcCustomFields) {
160
+ if (srcCustomFields.length === 0) return [];
161
+
162
+ log.info('Fetching screens for custom field mapping');
163
+ const screens = await client.post('/api/v1/screens/default/search', ['rule', 'check']);
164
+
165
+ // Flatten all fields from all sections of all screens into a name→id map
166
+ const fieldMap = new Map();
167
+ for (const screen of Array.isArray(screens) ? screens : []) {
168
+ const sections = screen?.info?.sections || [];
169
+ for (const section of sections) {
170
+ for (const field of section.fields || []) {
171
+ if (field.name && field.id) {
172
+ fieldMap.set(field.name.toLowerCase(), field.id);
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ const mappings = [];
179
+ for (const fieldName of srcCustomFields) {
180
+ const targetId = fieldMap.get(fieldName.toLowerCase());
181
+ if (!targetId) {
182
+ log.warn('Custom field not found in target screens, skipping', { fieldName });
183
+ continue;
184
+ }
185
+ mappings.push({ existingId: fieldName, newId: targetId, action: 'override' });
186
+ log.info('Mapped custom field', { fieldName, newId: targetId });
187
+ }
188
+
189
+ return mappings;
190
+ }
191
+
192
+ // Fetches all pages from a paginated POST search endpoint.
193
+ // Throws ApiError with code='ResultsNotFound' when the endpoint reports nothing found.
194
+ async function fetchAllPages(client, endpoint, body, label) {
195
+ const items = [];
196
+ let pageNo = 1;
197
+ let totalPages = 1;
198
+
199
+ do {
200
+ const url = `${endpoint}?pageNo=${pageNo}&pageSize=${PAGE_SIZE}&sort=updatedTimestamp:desc`;
201
+ const resp = await client.post(url, body);
202
+
203
+ // API may return 200 with {code, message} instead of a 4xx
204
+ if (resp && resp.code === 'ResultsNotFound') {
205
+ throw new ApiError(`${label}: ${resp.message || 'ResultsNotFound'}`, {
206
+ status: 200,
207
+ code: 'ResultsNotFound'
208
+ });
209
+ }
210
+
211
+ items.push(...(resp?.items || []));
212
+ totalPages = resp?.pageable?.pages ?? 1;
213
+ pageNo++;
214
+ } while (pageNo <= totalPages);
215
+
216
+ return items;
217
+ }
@@ -1,174 +1,174 @@
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 (mappingDoc.useFqn === undefined) {
64
- mappingDoc.useFqn = !!rawOpts.useFqn;
65
- }
66
-
67
- const submitParts = {
68
- file: {
69
- buffer: bundleBuffer,
70
- filename: path.basename(bundlePath),
71
- contentType: 'application/zip'
72
- },
73
- mapping: { json: mappingDoc }
74
- };
75
-
76
- const importPath = `/api/v1/imports/${rawOpts.kind}`;
77
- log.info('Submitting import', { kind: rawOpts.kind, bundle: bundlePath, mapping: mappingPath });
78
-
79
- let submitResp;
80
- try {
81
- submitResp = await client.postMultipart(importPath, submitParts);
82
- } catch (err) {
83
- if (err instanceof ApiError && err.isConstraintViolation()) {
84
- const result = {
85
- command: 'import',
86
- status: 'ConstraintViolation',
87
- hardErrors: err.messages.map(
88
- (m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`
89
- )
90
- };
91
- new Reporter(rawOpts.output || 'text').emit(result);
92
- throw err;
93
- }
94
- if (err instanceof ApiError && err.status === 409 && rawOpts.terminateOnConflict) {
95
- log.warn('Active import job conflicts; locating and terminating');
96
- await terminateActiveImport(client);
97
- submitResp = await client.postMultipart(importPath, submitParts);
98
- } else {
99
- throw err;
100
- }
101
- }
102
-
103
- const taskId = submitResp.taskInstanceId;
104
- if (!taskId) throw new CliError(`Import submit did not return taskInstanceId: ${JSON.stringify(submitResp)}`);
105
- process.stderr.write(`task-id: ${taskId}\n`);
106
-
107
- const start = Date.now();
108
- const { status, elapsedMs, attempts } = await pollTask(client, 'imports', taskId, {
109
- timeoutSec: cfg.timeoutSec,
110
- onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
111
- });
112
-
113
- let logText = '';
114
- try {
115
- logText = await client.get(`/api/v1/imports/${taskId}/log`);
116
- if (typeof logText !== 'string') logText = JSON.stringify(logText);
117
- } catch (err) {
118
- log.warn('Could not retrieve import log', { error: err.message });
119
- }
120
-
121
- if (rawOpts.retainLog && logText) {
122
- const logPath = path.resolve(rawOpts.retainLog);
123
- try {
124
- await writeFile(logPath, logText, 'utf8');
125
- log.info('Import log retained', { logPath });
126
- } catch (err) {
127
- log.warn('Could not write retained log', { logPath, error: err.message });
128
- }
129
- }
130
-
131
- const parsed = parseImportLog(logText);
132
- const result = {
133
- command: 'import',
134
- taskId,
135
- status,
136
- attempts,
137
- durationMs: elapsedMs,
138
- skippedCount: parsed.skippedCount,
139
- skippedRules: parsed.skippedRules,
140
- hardErrors: parsed.hardErrors,
141
- elapsedSinceSubmitMs: Date.now() - start
142
- };
143
- new Reporter(rawOpts.output || 'text').emit(result);
144
-
145
- if (!isSuccess(status)) {
146
- throw new TaskFailedError(taskId, 'import', status);
147
- }
148
- if (rawOpts.strict && parsed.skippedCount > 0) {
149
- const e = new CliError(
150
- `--strict: import completed but ${parsed.skippedCount} rule(s) skipped. See log for details.`
151
- );
152
- e.exitCode = 1;
153
- throw e;
154
- }
155
- }
156
-
157
- async function terminateActiveImport(client) {
158
- const search = await client.post('/api/v1/taskruns/search', {
159
- filter: [
160
- { attribute: 'type', operator: 'In', value: 'import-rules' },
161
- { attribute: 'type', operator: 'In', value: 'import-workflows' }
162
- ]
163
- });
164
- const active = (search?.items || search?.taskRuns || []).find((r) =>
165
- ['Submitted', 'Running'].includes(r.taskStatus || r.status)
166
- );
167
- if (!active) {
168
- log.warn('No active import found to terminate');
169
- return;
170
- }
171
- const id = active.id || active.taskInstanceId;
172
- log.info('Terminating active import', { taskId: id });
173
- await client.post(`/api/v1/imports/${id}:terminate`, {});
174
- }
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 (mappingDoc.useFqn === undefined) {
64
+ mappingDoc.useFqn = !!rawOpts.useFqn;
65
+ }
66
+
67
+ const submitParts = {
68
+ file: {
69
+ buffer: bundleBuffer,
70
+ filename: path.basename(bundlePath),
71
+ contentType: 'application/zip'
72
+ },
73
+ mapping: { json: mappingDoc }
74
+ };
75
+
76
+ const importPath = `/api/v1/imports/${rawOpts.kind}`;
77
+ log.info('Submitting import', { kind: rawOpts.kind, bundle: bundlePath, mapping: mappingPath });
78
+
79
+ let submitResp;
80
+ try {
81
+ submitResp = await client.postMultipart(importPath, submitParts);
82
+ } catch (err) {
83
+ if (err instanceof ApiError && err.isConstraintViolation()) {
84
+ const result = {
85
+ command: 'import',
86
+ status: 'ConstraintViolation',
87
+ hardErrors: err.messages.map(
88
+ (m) => `${m.fieldName ? m.fieldName + ': ' : ''}${m.violation || m.message || ''}`
89
+ )
90
+ };
91
+ new Reporter(rawOpts.output || 'text').emit(result);
92
+ throw err;
93
+ }
94
+ if (err instanceof ApiError && err.status === 409 && rawOpts.terminateOnConflict) {
95
+ log.warn('Active import job conflicts; locating and terminating');
96
+ await terminateActiveImport(client);
97
+ submitResp = await client.postMultipart(importPath, submitParts);
98
+ } else {
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ const taskId = submitResp.taskInstanceId;
104
+ if (!taskId) throw new CliError(`Import submit did not return taskInstanceId: ${JSON.stringify(submitResp)}`);
105
+ process.stderr.write(`task-id: ${taskId}\n`);
106
+
107
+ const start = Date.now();
108
+ const { status, elapsedMs, attempts } = await pollTask(client, 'imports', taskId, {
109
+ timeoutSec: cfg.timeoutSec,
110
+ onTick: ({ status: s, attempt }) => log.debug('tick', { status: s, attempt })
111
+ });
112
+
113
+ let logText = '';
114
+ try {
115
+ logText = await client.get(`/api/v1/imports/${taskId}/log`);
116
+ if (typeof logText !== 'string') logText = JSON.stringify(logText);
117
+ } catch (err) {
118
+ log.warn('Could not retrieve import log', { error: err.message });
119
+ }
120
+
121
+ if (rawOpts.retainLog && logText) {
122
+ const logPath = path.resolve(rawOpts.retainLog);
123
+ try {
124
+ await writeFile(logPath, logText, 'utf8');
125
+ log.info('Import log retained', { logPath });
126
+ } catch (err) {
127
+ log.warn('Could not write retained log', { logPath, error: err.message });
128
+ }
129
+ }
130
+
131
+ const parsed = parseImportLog(logText);
132
+ const result = {
133
+ command: 'import',
134
+ taskId,
135
+ status,
136
+ attempts,
137
+ durationMs: elapsedMs,
138
+ skippedCount: parsed.skippedCount,
139
+ skippedRules: parsed.skippedRules,
140
+ hardErrors: parsed.hardErrors,
141
+ elapsedSinceSubmitMs: Date.now() - start
142
+ };
143
+ new Reporter(rawOpts.output || 'text').emit(result);
144
+
145
+ if (!isSuccess(status)) {
146
+ throw new TaskFailedError(taskId, 'import', status);
147
+ }
148
+ if (rawOpts.strict && parsed.skippedCount > 0) {
149
+ const e = new CliError(
150
+ `--strict: import completed but ${parsed.skippedCount} rule(s) skipped. See log for details.`
151
+ );
152
+ e.exitCode = 1;
153
+ throw e;
154
+ }
155
+ }
156
+
157
+ async function terminateActiveImport(client) {
158
+ const search = await client.post('/api/v1/taskruns/search', {
159
+ filter: [
160
+ { attribute: 'type', operator: 'In', value: 'import-rules' },
161
+ { attribute: 'type', operator: 'In', value: 'import-workflows' }
162
+ ]
163
+ });
164
+ const active = (search?.items || search?.taskRuns || []).find((r) =>
165
+ ['Submitted', 'Running'].includes(r.taskStatus || r.status)
166
+ );
167
+ if (!active) {
168
+ log.warn('No active import found to terminate');
169
+ return;
170
+ }
171
+ const id = active.id || active.taskInstanceId;
172
+ log.info('Terminating active import', { taskId: id });
173
+ await client.post(`/api/v1/imports/${id}:terminate`, {});
174
+ }