@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,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
+ }