camunda-cli 0.2.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/README.md +167 -0
- package/bin/camunda.js +292 -0
- package/package.json +36 -0
- package/src/bpmn.js +387 -0
- package/src/client.js +400 -0
- package/src/commands/definitions.js +194 -0
- package/src/commands/deploy.js +82 -0
- package/src/commands/diagnose.js +257 -0
- package/src/commands/instances.js +205 -0
- package/src/commands/ops.js +127 -0
- package/src/commands/session.js +112 -0
- package/src/commands/tasks.js +129 -0
- package/src/config.js +33 -0
- package/src/errors.js +138 -0
- package/src/lint.js +244 -0
- package/src/output.js +135 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// Camunda 7 Engine REST API client.
|
|
2
|
+
//
|
|
3
|
+
// Auth is plain HTTP Basic on every request; the engine REST API has no session or token
|
|
4
|
+
// exchange to reuse. Verified against a live 7.24 multi-tenant deployment.
|
|
5
|
+
//
|
|
6
|
+
// Two behaviours here are not obvious from the API docs and were added after watching a
|
|
7
|
+
// real deployment misbehave:
|
|
8
|
+
// * Transient 401/403 responses. A load-balanced engine will intermittently reject a
|
|
9
|
+
// perfectly good credential while a replica is unhealthy; observed five consecutive
|
|
10
|
+
// 401s followed by five 200s, same credential, half a second apart. Without a retry a
|
|
11
|
+
// caller concludes "the password is wrong" and goes looking in the wrong place.
|
|
12
|
+
// * Accept headers. Most endpoints speak JSON, but /job/{id}/stacktrace returns
|
|
13
|
+
// text/plain and answers 406 to an Accept: application/json request.
|
|
14
|
+
|
|
15
|
+
const RETRYABLE = new Set([401, 403, 500, 502, 503, 504]);
|
|
16
|
+
|
|
17
|
+
function normalizeBaseUrl(url) {
|
|
18
|
+
return String(url).replace(/\/+$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
|
|
23
|
+
export class Client {
|
|
24
|
+
constructor({ baseUrl, username, password, retries = 3, timeout = 60000 }) {
|
|
25
|
+
this.baseUrl = normalizeBaseUrl(baseUrl);
|
|
26
|
+
this.authHeader =
|
|
27
|
+
username || password ? 'Basic ' + Buffer.from(`${username ?? ''}:${password ?? ''}`).toString('base64') : null;
|
|
28
|
+
this.retries = retries;
|
|
29
|
+
this.timeout = timeout;
|
|
30
|
+
this.lastRetried = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
buildUrl(path, query) {
|
|
34
|
+
let url = this.baseUrl + path;
|
|
35
|
+
if (query) {
|
|
36
|
+
const params = new URLSearchParams();
|
|
37
|
+
for (const [k, v] of Object.entries(query)) {
|
|
38
|
+
if (v === undefined || v === null || v === '') continue;
|
|
39
|
+
params.set(k, String(v));
|
|
40
|
+
}
|
|
41
|
+
const qs = params.toString();
|
|
42
|
+
if (qs) url += '?' + qs;
|
|
43
|
+
}
|
|
44
|
+
return url;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async request(method, path, { query, body, accept = 'application/json' } = {}) {
|
|
48
|
+
const url = this.buildUrl(path, query);
|
|
49
|
+
const headers = { Accept: accept };
|
|
50
|
+
if (this.authHeader) headers.Authorization = this.authHeader;
|
|
51
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
52
|
+
|
|
53
|
+
let lastError;
|
|
54
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await fetch(url, {
|
|
58
|
+
method,
|
|
59
|
+
headers,
|
|
60
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
61
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
lastError = new Error(`${method} ${path} -> ${err.message}`);
|
|
65
|
+
if (attempt === this.retries) throw lastError;
|
|
66
|
+
await sleep(300 * (attempt + 1));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
if (attempt > 0) this.lastRetried = attempt;
|
|
72
|
+
if (res.status === 204) return null;
|
|
73
|
+
const text = await res.text();
|
|
74
|
+
if (accept !== 'application/json') return text;
|
|
75
|
+
return text ? JSON.parse(text) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A 4xx that carries a Camunda error body is a real answer, not a flaky replica:
|
|
79
|
+
// retrying it just delays the report. Bodyless 401/403 from a proxy is retryable.
|
|
80
|
+
const raw = await res.text();
|
|
81
|
+
let parsed = null;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
} catch {
|
|
85
|
+
/* html or empty */
|
|
86
|
+
}
|
|
87
|
+
const isDefinitive = parsed && (parsed.message || parsed.type);
|
|
88
|
+
const err = new Error(`${method} ${path} -> ${parsed?.message || `HTTP ${res.status}`}`);
|
|
89
|
+
err.status = res.status;
|
|
90
|
+
err.body = parsed ?? raw;
|
|
91
|
+
|
|
92
|
+
if (isDefinitive || !RETRYABLE.has(res.status) || attempt === this.retries) throw err;
|
|
93
|
+
lastError = err;
|
|
94
|
+
await sleep(300 * (attempt + 1));
|
|
95
|
+
}
|
|
96
|
+
throw lastError;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get(path, query, opts) {
|
|
100
|
+
return this.request('GET', path, { query, ...opts });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- engine ---------------------------------------------------------------
|
|
104
|
+
engines() {
|
|
105
|
+
return this.get('/engine');
|
|
106
|
+
}
|
|
107
|
+
version() {
|
|
108
|
+
return this.get('/version');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- process definitions --------------------------------------------------
|
|
112
|
+
processDefinitions(query) {
|
|
113
|
+
return this.get('/process-definition', query);
|
|
114
|
+
}
|
|
115
|
+
processDefinitionCount(query) {
|
|
116
|
+
return this.get('/process-definition/count', query);
|
|
117
|
+
}
|
|
118
|
+
processDefinition(id) {
|
|
119
|
+
return this.get(`/process-definition/${encodeURIComponent(id)}`);
|
|
120
|
+
}
|
|
121
|
+
processDefinitionXml(id) {
|
|
122
|
+
return this.get(`/process-definition/${encodeURIComponent(id)}/xml`);
|
|
123
|
+
}
|
|
124
|
+
// Instance and incident counts per activity: the data behind Cockpit's diagram badges,
|
|
125
|
+
// and the quickest answer to "where is everything piling up".
|
|
126
|
+
processDefinitionStatistics(id, query) {
|
|
127
|
+
return this.get(`/process-definition/${encodeURIComponent(id)}/statistics`, {
|
|
128
|
+
incidents: true,
|
|
129
|
+
failedJobs: true,
|
|
130
|
+
...query,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
calledProcessDefinitions(id) {
|
|
134
|
+
return this.get(`/process-definition/${encodeURIComponent(id)}/static-called-process-definitions`);
|
|
135
|
+
}
|
|
136
|
+
startByDefinitionId(id, body) {
|
|
137
|
+
return this.request('POST', `/process-definition/${encodeURIComponent(id)}/start`, { body });
|
|
138
|
+
}
|
|
139
|
+
suspendDefinition(id, suspended) {
|
|
140
|
+
return this.request('PUT', `/process-definition/${encodeURIComponent(id)}/suspended`, {
|
|
141
|
+
body: { suspended, includeProcessInstances: false },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- process instances ----------------------------------------------------
|
|
146
|
+
processInstances(query) {
|
|
147
|
+
return this.get('/process-instance', query);
|
|
148
|
+
}
|
|
149
|
+
processInstance(id) {
|
|
150
|
+
return this.get(`/process-instance/${id}`);
|
|
151
|
+
}
|
|
152
|
+
processInstanceVariables(id) {
|
|
153
|
+
return this.get(`/process-instance/${id}/variables`);
|
|
154
|
+
}
|
|
155
|
+
setProcessInstanceVariable(id, name, value) {
|
|
156
|
+
return this.request('PUT', `/process-instance/${id}/variables/${encodeURIComponent(name)}`, { body: value });
|
|
157
|
+
}
|
|
158
|
+
// Where the tokens are *right now*, as a tree. History tells you where they have been.
|
|
159
|
+
activityInstanceTree(id) {
|
|
160
|
+
return this.get(`/process-instance/${id}/activity-instances`);
|
|
161
|
+
}
|
|
162
|
+
deleteProcessInstance(id, { reason, skipCustomListeners = true, skipIoMappings = true } = {}) {
|
|
163
|
+
return this.request('DELETE', `/process-instance/${id}`, {
|
|
164
|
+
query: { skipCustomListeners, skipIoMappings, deleteReason: reason },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
suspendProcessInstance(id, suspended) {
|
|
168
|
+
return this.request('PUT', `/process-instance/${id}/suspended`, { body: { suspended } });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- history --------------------------------------------------------------
|
|
172
|
+
historyProcessInstances(query) {
|
|
173
|
+
return this.get('/history/process-instance', query);
|
|
174
|
+
}
|
|
175
|
+
historyProcessInstance(id) {
|
|
176
|
+
return this.get(`/history/process-instance/${id}`);
|
|
177
|
+
}
|
|
178
|
+
historyActivityInstances(query) {
|
|
179
|
+
return this.get('/history/activity-instance', query);
|
|
180
|
+
}
|
|
181
|
+
historyVariableInstances(query) {
|
|
182
|
+
return this.get('/history/variable-instance', query);
|
|
183
|
+
}
|
|
184
|
+
// Each individual write to a variable, with revision and timestamp. Requires the engine
|
|
185
|
+
// history level to be FULL; returns an empty list on lower levels rather than failing.
|
|
186
|
+
historyDetails(query) {
|
|
187
|
+
return this.get('/history/detail', query);
|
|
188
|
+
}
|
|
189
|
+
historyTasks(query) {
|
|
190
|
+
return this.get('/history/task', query);
|
|
191
|
+
}
|
|
192
|
+
// Survives the job itself. A failed job that was later deleted or retried away still has
|
|
193
|
+
// its exception recorded here, which /job alone cannot tell you.
|
|
194
|
+
historyJobLog(query) {
|
|
195
|
+
return this.get('/history/job-log', query);
|
|
196
|
+
}
|
|
197
|
+
historyJobLogStacktrace(id) {
|
|
198
|
+
return this.get(`/history/job-log/${id}/stacktrace`, undefined, { accept: 'text/plain' });
|
|
199
|
+
}
|
|
200
|
+
// Includes resolved incidents; /incident only lists open ones.
|
|
201
|
+
historyIncidents(query) {
|
|
202
|
+
return this.get('/history/incident', query);
|
|
203
|
+
}
|
|
204
|
+
historyUserOperations(query) {
|
|
205
|
+
return this.get('/history/user-operation', query);
|
|
206
|
+
}
|
|
207
|
+
historyExternalTaskLog(query) {
|
|
208
|
+
return this.get('/history/external-task-log', query);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- incidents and jobs ---------------------------------------------------
|
|
212
|
+
incidents(query) {
|
|
213
|
+
return this.get('/incident', query);
|
|
214
|
+
}
|
|
215
|
+
jobs(query) {
|
|
216
|
+
return this.get('/job', query);
|
|
217
|
+
}
|
|
218
|
+
job(id) {
|
|
219
|
+
return this.get(`/job/${id}`);
|
|
220
|
+
}
|
|
221
|
+
jobStacktrace(id) {
|
|
222
|
+
return this.get(`/job/${id}/stacktrace`, undefined, { accept: 'text/plain' });
|
|
223
|
+
}
|
|
224
|
+
setJobRetries(id, retries) {
|
|
225
|
+
return this.request('PUT', `/job/${id}/retries`, { body: { retries } });
|
|
226
|
+
}
|
|
227
|
+
executeJob(id) {
|
|
228
|
+
return this.request('POST', `/job/${id}/execute`);
|
|
229
|
+
}
|
|
230
|
+
jobDefinitions(query) {
|
|
231
|
+
return this.get('/job-definition', query);
|
|
232
|
+
}
|
|
233
|
+
externalTasks(query) {
|
|
234
|
+
return this.get('/external-task', query);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- deployments ----------------------------------------------------------
|
|
238
|
+
deployments(query) {
|
|
239
|
+
return this.get('/deployment', query);
|
|
240
|
+
}
|
|
241
|
+
deploymentResources(id) {
|
|
242
|
+
return this.get(`/deployment/${id}/resources`);
|
|
243
|
+
}
|
|
244
|
+
deleteDeployment(id, { cascade = false } = {}) {
|
|
245
|
+
return this.request('DELETE', `/deployment/${id}`, { query: { cascade } });
|
|
246
|
+
}
|
|
247
|
+
async deploy(name, files, { tenantId } = {}) {
|
|
248
|
+
const form = new FormData();
|
|
249
|
+
form.set('deployment-name', name);
|
|
250
|
+
form.set('deploy-changed-only', 'true');
|
|
251
|
+
if (tenantId) form.set('tenant-id', tenantId);
|
|
252
|
+
for (const { filename, buffer } of files) {
|
|
253
|
+
form.set(filename, new Blob([buffer]), filename);
|
|
254
|
+
}
|
|
255
|
+
const headers = { Accept: 'application/json' };
|
|
256
|
+
if (this.authHeader) headers.Authorization = this.authHeader;
|
|
257
|
+
const res = await fetch(`${this.baseUrl}/deployment/create`, { method: 'POST', headers, body: form });
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
const raw = await res.text();
|
|
260
|
+
let parsed = null;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(raw);
|
|
263
|
+
} catch {
|
|
264
|
+
/* ignore */
|
|
265
|
+
}
|
|
266
|
+
const err = new Error(`POST /deployment/create -> ${parsed?.message || `HTTP ${res.status}`}`);
|
|
267
|
+
err.status = res.status;
|
|
268
|
+
err.body = parsed ?? raw;
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
return res.json();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- tasks ----------------------------------------------------------------
|
|
275
|
+
tasks(query) {
|
|
276
|
+
return this.get('/task', query);
|
|
277
|
+
}
|
|
278
|
+
task(id) {
|
|
279
|
+
return this.get(`/task/${id}`);
|
|
280
|
+
}
|
|
281
|
+
taskVariables(id) {
|
|
282
|
+
return this.get(`/task/${id}/variables`);
|
|
283
|
+
}
|
|
284
|
+
taskIdentityLinks(id) {
|
|
285
|
+
return this.get(`/task/${id}/identity-links`);
|
|
286
|
+
}
|
|
287
|
+
completeTask(id, variables) {
|
|
288
|
+
return this.request('POST', `/task/${id}/complete`, { body: { variables } });
|
|
289
|
+
}
|
|
290
|
+
claimTask(id, userId) {
|
|
291
|
+
return this.request('POST', `/task/${id}/claim`, { body: { userId } });
|
|
292
|
+
}
|
|
293
|
+
unclaimTask(id) {
|
|
294
|
+
return this.request('POST', `/task/${id}/unclaim`);
|
|
295
|
+
}
|
|
296
|
+
setTaskAssignee(id, userId) {
|
|
297
|
+
return this.request('POST', `/task/${id}/assignee`, { body: { userId } });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- messages and signals -------------------------------------------------
|
|
301
|
+
correlateMessage(body) {
|
|
302
|
+
return this.request('POST', '/message', { body });
|
|
303
|
+
}
|
|
304
|
+
throwSignal(body) {
|
|
305
|
+
return this.request('POST', '/signal', { body });
|
|
306
|
+
}
|
|
307
|
+
eventSubscriptions(query) {
|
|
308
|
+
return this.get('/event-subscription', query);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- identity -------------------------------------------------------------
|
|
312
|
+
users(query) {
|
|
313
|
+
return this.get('/user', query);
|
|
314
|
+
}
|
|
315
|
+
groups(query) {
|
|
316
|
+
return this.get('/group', query);
|
|
317
|
+
}
|
|
318
|
+
tenants(query) {
|
|
319
|
+
return this.get('/tenant', query);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const VARIABLE_TYPES = ['String', 'Integer', 'Long', 'Short', 'Double', 'Boolean', 'Date', 'Json', 'Null'];
|
|
324
|
+
|
|
325
|
+
// Accepts name=value, name=value:Type, or name:=<json>. Type defaults to String, which is
|
|
326
|
+
// what Camunda assumes too, but an explicit type matters for gateway conditions that
|
|
327
|
+
// compare numerically: "300" > 200 is false as a string comparison.
|
|
328
|
+
export function parseVariableFlags(varArgs = []) {
|
|
329
|
+
const variables = {};
|
|
330
|
+
for (const raw of varArgs) {
|
|
331
|
+
const jsonEq = raw.indexOf(':=');
|
|
332
|
+
if (jsonEq > 0) {
|
|
333
|
+
const name = raw.slice(0, jsonEq);
|
|
334
|
+
variables[name] = { value: JSON.parse(raw.slice(jsonEq + 2)), type: 'Json' };
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const eq = raw.indexOf('=');
|
|
339
|
+
if (eq === -1) throw new Error(`Invalid --var "${raw}". Use name=value, name=value:Type, or name:=<json>.`);
|
|
340
|
+
const name = raw.slice(0, eq);
|
|
341
|
+
let rest = raw.slice(eq + 1);
|
|
342
|
+
let type = 'String';
|
|
343
|
+
|
|
344
|
+
const colon = rest.lastIndexOf(':');
|
|
345
|
+
if (colon !== -1) {
|
|
346
|
+
const candidate = rest.slice(colon + 1);
|
|
347
|
+
const match = VARIABLE_TYPES.find((t) => t.toLowerCase() === candidate.toLowerCase());
|
|
348
|
+
if (match) {
|
|
349
|
+
type = match;
|
|
350
|
+
rest = rest.slice(0, colon);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let value = rest;
|
|
355
|
+
if (type === 'Integer' || type === 'Long' || type === 'Short') {
|
|
356
|
+
value = parseInt(rest, 10);
|
|
357
|
+
if (Number.isNaN(value)) throw new Error(`--var ${name}: "${rest}" is not a whole number.`);
|
|
358
|
+
} else if (type === 'Double') {
|
|
359
|
+
value = parseFloat(rest);
|
|
360
|
+
if (Number.isNaN(value)) throw new Error(`--var ${name}: "${rest}" is not a number.`);
|
|
361
|
+
} else if (type === 'Boolean') {
|
|
362
|
+
if (rest !== 'true' && rest !== 'false') throw new Error(`--var ${name}: expected true or false, got "${rest}".`);
|
|
363
|
+
value = rest === 'true';
|
|
364
|
+
} else if (type === 'Null') {
|
|
365
|
+
value = null;
|
|
366
|
+
}
|
|
367
|
+
variables[name] = { value, type };
|
|
368
|
+
}
|
|
369
|
+
return variables;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Resolves a bare process definition key to a single definition, taking tenancy into
|
|
373
|
+
// account. A key repeated across tenants is the normal case in AlurKerja, and the engine's
|
|
374
|
+
// own key-based endpoints answer "no matching process definition ... and no tenant-id"
|
|
375
|
+
// in that situation, which reads like the process is missing when it is not.
|
|
376
|
+
export async function resolveDefinition(client, keyOrId, { tenant, version } = {}) {
|
|
377
|
+
if (keyOrId.includes(':')) return client.processDefinition(keyOrId);
|
|
378
|
+
|
|
379
|
+
const query = { key: keyOrId };
|
|
380
|
+
if (tenant) query.tenantIdIn = tenant;
|
|
381
|
+
if (version) query.version = version;
|
|
382
|
+
else query.latestVersion = true;
|
|
383
|
+
|
|
384
|
+
const matches = await client.processDefinitions(query);
|
|
385
|
+
if (matches.length === 0) {
|
|
386
|
+
const anywhere = await client.processDefinitions({ key: keyOrId, latestVersion: true });
|
|
387
|
+
if (anywhere.length > 0) {
|
|
388
|
+
const tenants = anywhere.map((d) => d.tenantId ?? '(no tenant)').join(', ');
|
|
389
|
+
throw new Error(`No definition "${keyOrId}" for tenant ${tenant}. It exists under: ${tenants}.`);
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`No process definition with key "${keyOrId}".`);
|
|
392
|
+
}
|
|
393
|
+
if (matches.length > 1) {
|
|
394
|
+
const list = matches.map((d) => ` ${d.id} tenant=${d.tenantId ?? '-'} version=${d.version}`).join('\n');
|
|
395
|
+
throw new Error(
|
|
396
|
+
`"${keyOrId}" exists in ${matches.length} tenants. Narrow it with --tenant <id>, or pass the full definition id:\n${list}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return matches[0];
|
|
400
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { Client, resolveDefinition } from '../client.js';
|
|
3
|
+
import { requireConfig } from '../config.js';
|
|
4
|
+
import { readProcess } from '../bpmn.js';
|
|
5
|
+
import { lintProcess } from '../lint.js';
|
|
6
|
+
import * as out from '../output.js';
|
|
7
|
+
|
|
8
|
+
export async function definitionsCommand(options) {
|
|
9
|
+
const client = new Client(requireConfig());
|
|
10
|
+
const query = { maxResults: options.limit ?? 50, sortBy: 'key', sortOrder: 'asc' };
|
|
11
|
+
if (options.key) query.keyLike = `%${options.key}%`;
|
|
12
|
+
if (options.name) query.nameLike = `%${options.name}%`;
|
|
13
|
+
if (options.tenant) query.tenantIdIn = options.tenant;
|
|
14
|
+
if (options.latest) query.latestVersion = true;
|
|
15
|
+
|
|
16
|
+
const defs = await client.processDefinitions(query);
|
|
17
|
+
if (out.isJsonMode()) return out.json(defs);
|
|
18
|
+
if (defs.length === 0) return out.note('No process definitions match.');
|
|
19
|
+
|
|
20
|
+
out.table(
|
|
21
|
+
['KEY', 'NAME', 'VER', 'TENANT', 'ID'],
|
|
22
|
+
defs.map((d) => [d.key, out.truncate(d.name ?? '', 34), String(d.version), d.tenantId ?? '-', d.id])
|
|
23
|
+
);
|
|
24
|
+
out.note(`\n${defs.length} definition(s)${options.latest ? ', latest version only' : ''}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Structural view of a deployed model. None of this is available as a REST resource:
|
|
28
|
+
// it is read out of the deployed BPMN XML, which is the only place the engine keeps it.
|
|
29
|
+
export async function inspectCommand(keyOrId, options) {
|
|
30
|
+
const client = new Client(requireConfig());
|
|
31
|
+
const def = await resolveDefinition(client, keyOrId, options);
|
|
32
|
+
const { bpmn20Xml } = await client.processDefinitionXml(def.id);
|
|
33
|
+
const model = readProcess(bpmn20Xml);
|
|
34
|
+
|
|
35
|
+
let stats = [];
|
|
36
|
+
try {
|
|
37
|
+
stats = await client.processDefinitionStatistics(def.id);
|
|
38
|
+
} catch {
|
|
39
|
+
/* statistics are optional context, not worth failing the command over */
|
|
40
|
+
}
|
|
41
|
+
const liveByActivity = new Map(stats.map((s) => [s.id, s]));
|
|
42
|
+
|
|
43
|
+
if (out.isJsonMode()) {
|
|
44
|
+
return out.json({ definition: def, process: stripRaw(model), statistics: stats });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
out.heading(`${def.name || def.key}`);
|
|
48
|
+
out.kv([
|
|
49
|
+
['key', def.key],
|
|
50
|
+
['id', def.id],
|
|
51
|
+
['version', def.version],
|
|
52
|
+
['tenant', def.tenantId ?? '-'],
|
|
53
|
+
['suspended', def.suspended ? 'yes' : 'no'],
|
|
54
|
+
['executable', model.executable ? 'yes' : 'no'],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
if (model.lanes.length > 0) {
|
|
58
|
+
out.line('\nLanes');
|
|
59
|
+
out.table(null, model.lanes.map((l) => [l.name || l.id, `${l.nodeRefs.length} element(s)`]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
out.line('\nFlow nodes');
|
|
63
|
+
const rows = model.nodes.map((n) => {
|
|
64
|
+
const live = liveByActivity.get(n.id);
|
|
65
|
+
const marks = [];
|
|
66
|
+
if (n.async) marks.push(`async:${n.async}`);
|
|
67
|
+
if (n.multiInstance) marks.push(`multi:${n.multiInstance}`);
|
|
68
|
+
if (n.event?.type) marks.push(n.event.timer ? `${n.event.type}:${n.event.timer.value}` : n.event.type);
|
|
69
|
+
if (n.assignee) marks.push(`assignee=${n.assignee}`);
|
|
70
|
+
if (n.calledElement) marks.push(`calls=${n.calledElement}`);
|
|
71
|
+
const addon = n.listeners.find((l) => l.addon)?.addon;
|
|
72
|
+
if (addon) marks.push(`addon=${addon.addon}/${addon.action}${addon.config ? `#${addon.config}` : ''}`);
|
|
73
|
+
if (n.formFields.length) marks.push(`${n.formFields.length} field(s)`);
|
|
74
|
+
return [n.type, n.id, out.truncate(n.name, 30), live?.instances ? `[${live.instances} here]` : '', marks.join(' ')];
|
|
75
|
+
});
|
|
76
|
+
out.table(['TYPE', 'ID', 'NAME', 'LIVE', 'DETAIL'], rows);
|
|
77
|
+
|
|
78
|
+
out.line('\nSequence flows');
|
|
79
|
+
out.table(
|
|
80
|
+
['FROM', 'TO', 'LABEL', 'CONDITION'],
|
|
81
|
+
model.flows.map((f) => [f.source, f.target, out.truncate(f.name, 14), f.condition ?? ''])
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const withForms = model.nodes.filter((n) => n.formFields.length > 0);
|
|
85
|
+
if (withForms.length > 0) {
|
|
86
|
+
out.line('\nForm fields');
|
|
87
|
+
for (const n of withForms) {
|
|
88
|
+
out.line(` ${n.id} (${n.name || n.type})`);
|
|
89
|
+
out.table(
|
|
90
|
+
null,
|
|
91
|
+
n.formFields.map((f) => [` ${f.name}`, f.type, f.required ? 'required' : '', f.disabled ? 'read-only' : '']),
|
|
92
|
+
{ indent: '' }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const addons = model.nodes.flatMap((n) => n.listeners.filter((l) => l.addon).map((l) => ({ node: n, l })));
|
|
98
|
+
if (addons.length > 0) {
|
|
99
|
+
out.line('\nIntegrations');
|
|
100
|
+
out.table(
|
|
101
|
+
['ELEMENT', 'EVENT', 'ADDON', 'ACTION', 'CONFIG'],
|
|
102
|
+
addons.map(({ node, l }) => [node.id, `${l.kind}[${l.event}]`, l.addon.addon ?? '-', l.addon.action ?? '-', l.addon.config ?? '-'])
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!options.noLint) {
|
|
107
|
+
const findings = lintProcess(model);
|
|
108
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
109
|
+
const warnings = findings.filter((f) => f.severity === 'warning').length;
|
|
110
|
+
if (findings.length > 0) {
|
|
111
|
+
out.note(`\n${errors} error(s), ${warnings} warning(s) from static checks. Run "camunda lint ${keyOrId}" for detail.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function lintCommand(keyOrId, options) {
|
|
117
|
+
const client = new Client(requireConfig());
|
|
118
|
+
const def = await resolveDefinition(client, keyOrId, options);
|
|
119
|
+
const { bpmn20Xml } = await client.processDefinitionXml(def.id);
|
|
120
|
+
const model = readProcess(bpmn20Xml);
|
|
121
|
+
const findings = lintProcess(model);
|
|
122
|
+
|
|
123
|
+
const wanted = options.severity
|
|
124
|
+
? findings.filter((f) => f.severity === options.severity)
|
|
125
|
+
: findings.filter((f) => options.all || f.severity !== 'info');
|
|
126
|
+
|
|
127
|
+
if (out.isJsonMode()) return out.json({ definition: def.id, findings: wanted });
|
|
128
|
+
|
|
129
|
+
if (wanted.length === 0) {
|
|
130
|
+
out.line(`${def.key} v${def.version}: nothing to report.`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
out.heading(`${def.key} v${def.version}`);
|
|
135
|
+
for (const f of wanted) {
|
|
136
|
+
const tag = f.severity === 'error' ? 'ERROR ' : f.severity === 'warning' ? 'WARNING' : 'INFO ';
|
|
137
|
+
out.line(`\n${tag} ${f.rule} ${f.element}`);
|
|
138
|
+
for (const l of out.wrap(f.message, 88)) out.line(` ${l}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const errors = wanted.filter((f) => f.severity === 'error').length;
|
|
142
|
+
out.note(`\n${wanted.length} finding(s)${options.all ? '' : ', info suppressed (use --all)'}`);
|
|
143
|
+
if (errors > 0) process.exitCode = 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function xmlCommand(keyOrId, options) {
|
|
147
|
+
const client = new Client(requireConfig());
|
|
148
|
+
const def = await resolveDefinition(client, keyOrId, options);
|
|
149
|
+
const { bpmn20Xml } = await client.processDefinitionXml(def.id);
|
|
150
|
+
|
|
151
|
+
if (options.output) {
|
|
152
|
+
writeFileSync(options.output, bpmn20Xml);
|
|
153
|
+
out.note(`Saved ${def.id} to ${options.output} (${bpmn20Xml.length} bytes).`);
|
|
154
|
+
} else {
|
|
155
|
+
console.log(bpmn20Xml);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Answers "where are all the instances of this process sitting, and what is broken",
|
|
160
|
+
// which is the diagram-badge view in Cockpit expressed as a table.
|
|
161
|
+
export async function statsCommand(keyOrId, options) {
|
|
162
|
+
const client = new Client(requireConfig());
|
|
163
|
+
const def = await resolveDefinition(client, keyOrId, options);
|
|
164
|
+
const [stats, xmlRes] = await Promise.all([
|
|
165
|
+
client.processDefinitionStatistics(def.id),
|
|
166
|
+
client.processDefinitionXml(def.id),
|
|
167
|
+
]);
|
|
168
|
+
const model = readProcess(xmlRes.bpmn20Xml);
|
|
169
|
+
const names = new Map(model.nodes.map((n) => [n.id, n.name || n.id]));
|
|
170
|
+
|
|
171
|
+
if (out.isJsonMode()) return out.json(stats);
|
|
172
|
+
if (stats.length === 0) {
|
|
173
|
+
out.note(`No running instances of ${def.key} v${def.version}.`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
out.heading(`${def.key} v${def.version}`);
|
|
178
|
+
out.table(
|
|
179
|
+
['ACTIVITY', 'NAME', 'INSTANCES', 'FAILED JOBS', 'INCIDENTS'],
|
|
180
|
+
stats.map((s) => [
|
|
181
|
+
s.id,
|
|
182
|
+
out.truncate(names.get(s.id) ?? '', 30),
|
|
183
|
+
String(s.instances),
|
|
184
|
+
String(s.failedJobs ?? 0),
|
|
185
|
+
String((s.incidents ?? []).reduce((a, i) => a + i.incidentCount, 0)),
|
|
186
|
+
])
|
|
187
|
+
);
|
|
188
|
+
const total = stats.reduce((a, s) => a + s.instances, 0);
|
|
189
|
+
out.note(`\n${total} instance(s) across ${stats.length} activity/activities`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function stripRaw(model) {
|
|
193
|
+
return { ...model, nodes: model.nodes.map(({ raw, ...rest }) => rest) };
|
|
194
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { Client } from '../client.js';
|
|
4
|
+
import { requireConfig } from '../config.js';
|
|
5
|
+
import { readProcess } from '../bpmn.js';
|
|
6
|
+
import { lintProcess } from '../lint.js';
|
|
7
|
+
import * as out from '../output.js';
|
|
8
|
+
|
|
9
|
+
export async function deploymentsCommand(options) {
|
|
10
|
+
const client = new Client(requireConfig());
|
|
11
|
+
const query = { maxResults: options.limit ?? 20, sortBy: 'deploymentTime', sortOrder: 'desc' };
|
|
12
|
+
if (options.tenant) query.tenantIdIn = options.tenant;
|
|
13
|
+
if (options.name) query.nameLike = `%${options.name}%`;
|
|
14
|
+
|
|
15
|
+
const deployments = await client.deployments(query);
|
|
16
|
+
if (out.isJsonMode()) return out.json(deployments);
|
|
17
|
+
if (deployments.length === 0) return out.note('No deployments found.');
|
|
18
|
+
|
|
19
|
+
out.table(
|
|
20
|
+
['ID', 'NAME', 'DEPLOYED', 'TENANT'],
|
|
21
|
+
deployments.map((d) => [d.id, out.truncate(d.name ?? '-', 34), out.formatDateTime(d.deploymentTime), d.tenantId ?? '-'])
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Lints before deploying by default. Pushing a model whose gateway conditions cannot all
|
|
26
|
+
// be satisfied only moves the failure to the first instance that runs it, and by then the
|
|
27
|
+
// stack trace is much further from the cause.
|
|
28
|
+
export async function deployCommand(files, options) {
|
|
29
|
+
const client = new Client(requireConfig());
|
|
30
|
+
|
|
31
|
+
const payload = files.map((file) => ({ filename: basename(file), buffer: readFileSync(file), path: file }));
|
|
32
|
+
|
|
33
|
+
if (!options.skipLint) {
|
|
34
|
+
let blocking = 0;
|
|
35
|
+
for (const f of payload) {
|
|
36
|
+
if (!/\.bpmn$/i.test(f.filename)) continue;
|
|
37
|
+
let findings;
|
|
38
|
+
try {
|
|
39
|
+
findings = lintProcess(readProcess(f.buffer.toString('utf8')));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
out.warn(`${f.filename}: could not be parsed for checks (${err.message})`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const errors = findings.filter((x) => x.severity === 'error');
|
|
45
|
+
if (errors.length === 0) continue;
|
|
46
|
+
blocking += errors.length;
|
|
47
|
+
out.problem(`${f.filename}`);
|
|
48
|
+
for (const e of errors) {
|
|
49
|
+
out.line(` ${e.rule} ${e.element}`);
|
|
50
|
+
for (const l of out.wrap(e.message, 90)) out.note(` ${l}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (blocking > 0) {
|
|
54
|
+
out.line('');
|
|
55
|
+
out.problem(`${blocking} blocking issue(s). Deploy anyway with --skip-lint.`);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const name = options.name || payload.map((p) => p.filename).join(', ');
|
|
62
|
+
const result = await client.deploy(name, payload, { tenantId: options.tenant });
|
|
63
|
+
|
|
64
|
+
if (out.isJsonMode()) return out.json(result);
|
|
65
|
+
|
|
66
|
+
out.line(`Deployed "${name}" as deployment ${result.id}`);
|
|
67
|
+
const defs = Object.values(result.deployedProcessDefinitions ?? {});
|
|
68
|
+
if (defs.length === 0) {
|
|
69
|
+
out.note('No new process definition version was created: the content is identical to what is already deployed.');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
out.table(
|
|
73
|
+
['KEY', 'VERSION', 'ID'],
|
|
74
|
+
defs.map((d) => [d.key, String(d.version), d.id])
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function undeployCommand(id, options) {
|
|
79
|
+
const client = new Client(requireConfig());
|
|
80
|
+
await client.deleteDeployment(id, { cascade: options.cascade });
|
|
81
|
+
out.line(`Deployment ${id} deleted${options.cascade ? ' along with its instances and history' : ''}.`);
|
|
82
|
+
}
|