@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.
- package/LICENSE +201 -201
- package/NOTICE +16 -16
- package/package.json +48 -48
- package/src/bin/icedq.js +84 -84
- package/src/commands/export.js +102 -102
- package/src/commands/generate-mapping.js +217 -217
- package/src/commands/import.js +174 -174
- package/src/core/client.js +183 -183
|
@@ -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
|
+
}
|