@icedq/cli 0.2.0 → 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.
@@ -1,217 +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
+ 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
+ }