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/bpmn.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Minimal BPMN 2.0 reader.
|
|
2
|
+
//
|
|
3
|
+
// Everything here comes from the deployed XML rather than the REST API, because the
|
|
4
|
+
// things a developer actually needs while debugging a model — which variable a gateway
|
|
5
|
+
// condition reads, whether a service task is wired to an addon, whether a step is async —
|
|
6
|
+
// are not exposed as REST resources at all. Camunda will hand you the XML and nothing more.
|
|
7
|
+
//
|
|
8
|
+
// The tokenizer is hand-rolled but quote-aware, so attribute values containing '>' (legal
|
|
9
|
+
// in XML, and produced by some modellers) don't truncate a tag.
|
|
10
|
+
|
|
11
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
12
|
+
|
|
13
|
+
export function decodeXml(s) {
|
|
14
|
+
if (!s) return '';
|
|
15
|
+
return String(s).replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos|nbsp);/g, (m, g) => {
|
|
16
|
+
if (g[0] === '#') {
|
|
17
|
+
const code = g[1] === 'x' || g[1] === 'X' ? parseInt(g.slice(2), 16) : parseInt(g.slice(1), 10);
|
|
18
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
|
|
19
|
+
}
|
|
20
|
+
return ENTITIES[g] ?? m;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Parses into a tree of { name, local, attrs, children, text }.
|
|
25
|
+
export function parseXml(xml) {
|
|
26
|
+
const root = { name: '#root', local: '#root', attrs: {}, children: [], text: '' };
|
|
27
|
+
const stack = [root];
|
|
28
|
+
let i = 0;
|
|
29
|
+
const n = xml.length;
|
|
30
|
+
|
|
31
|
+
while (i < n) {
|
|
32
|
+
const lt = xml.indexOf('<', i);
|
|
33
|
+
if (lt === -1) break;
|
|
34
|
+
|
|
35
|
+
if (lt > i) {
|
|
36
|
+
const text = xml.slice(i, lt);
|
|
37
|
+
// Element text carries the entity-escaped form of conditions such as
|
|
38
|
+
// ${a > 300}; decoding here is what makes them readable downstream.
|
|
39
|
+
if (text.trim()) stack[stack.length - 1].text += decodeXml(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Comments, CDATA, processing instructions, doctype.
|
|
43
|
+
if (xml.startsWith('<!--', lt)) {
|
|
44
|
+
i = xml.indexOf('-->', lt) + 3;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (xml.startsWith('<![CDATA[', lt)) {
|
|
48
|
+
const end = xml.indexOf(']]>', lt);
|
|
49
|
+
stack[stack.length - 1].text += xml.slice(lt + 9, end);
|
|
50
|
+
i = end + 3;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (xml.startsWith('<?', lt) || xml.startsWith('<!', lt)) {
|
|
54
|
+
i = xml.indexOf('>', lt) + 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Closing tag.
|
|
59
|
+
if (xml[lt + 1] === '/') {
|
|
60
|
+
const end = xml.indexOf('>', lt);
|
|
61
|
+
if (stack.length > 1) stack.pop();
|
|
62
|
+
i = end + 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Opening tag: walk to the matching '>' while respecting quoted attribute values.
|
|
67
|
+
let j = lt + 1;
|
|
68
|
+
let quote = null;
|
|
69
|
+
while (j < n) {
|
|
70
|
+
const c = xml[j];
|
|
71
|
+
if (quote) {
|
|
72
|
+
if (c === quote) quote = null;
|
|
73
|
+
} else if (c === '"' || c === "'") {
|
|
74
|
+
quote = c;
|
|
75
|
+
} else if (c === '>') {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
j++;
|
|
79
|
+
}
|
|
80
|
+
const rawTag = xml.slice(lt + 1, j);
|
|
81
|
+
const selfClosing = rawTag.endsWith('/');
|
|
82
|
+
const body = selfClosing ? rawTag.slice(0, -1) : rawTag;
|
|
83
|
+
|
|
84
|
+
const nameMatch = body.match(/^([^\s/>]+)/);
|
|
85
|
+
const name = nameMatch ? nameMatch[1] : '';
|
|
86
|
+
const attrs = {};
|
|
87
|
+
const attrRe = /([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
88
|
+
let am;
|
|
89
|
+
while ((am = attrRe.exec(body)) !== null) {
|
|
90
|
+
attrs[am[1]] = decodeXml(am[3] !== undefined ? am[3] : am[4]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const node = { name, local: name.includes(':') ? name.split(':')[1] : name, attrs, children: [], text: '' };
|
|
94
|
+
stack[stack.length - 1].children.push(node);
|
|
95
|
+
if (!selfClosing) stack.push(node);
|
|
96
|
+
i = j + 1;
|
|
97
|
+
}
|
|
98
|
+
return root;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function findAll(node, localName, out = []) {
|
|
102
|
+
for (const c of node.children) {
|
|
103
|
+
if (c.local === localName) out.push(c);
|
|
104
|
+
findAll(c, localName, out);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const FLOW_NODE_TYPES = new Set([
|
|
110
|
+
'startEvent', 'endEvent', 'userTask', 'serviceTask', 'scriptTask', 'sendTask', 'receiveTask',
|
|
111
|
+
'manualTask', 'businessRuleTask', 'task', 'callActivity', 'subProcess', 'transaction',
|
|
112
|
+
'exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway', 'complexGateway',
|
|
113
|
+
'intermediateCatchEvent', 'intermediateThrowEvent', 'boundaryEvent',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
// Pulls the AlurKerja integration call out of a listener expression. These are always
|
|
117
|
+
// shaped as integrationContext.getInstance(..).addonName("x").addonAction("y").config("n")...
|
|
118
|
+
function parseAddonCall(expression) {
|
|
119
|
+
if (!expression || !expression.includes('addonName')) return null;
|
|
120
|
+
const pick = (fn) => {
|
|
121
|
+
const m = expression.match(new RegExp(`${fn}\\(\\s*["']([^"']*)["']`));
|
|
122
|
+
return m ? m[1] : null;
|
|
123
|
+
};
|
|
124
|
+
const payload = pick('payload');
|
|
125
|
+
let decodedPayload = null;
|
|
126
|
+
if (payload) {
|
|
127
|
+
try {
|
|
128
|
+
decodedPayload = Buffer.from(payload, 'base64').toString('utf8');
|
|
129
|
+
} catch {
|
|
130
|
+
decodedPayload = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
addon: pick('addonName'),
|
|
135
|
+
action: pick('addonAction'),
|
|
136
|
+
config: pick('config'),
|
|
137
|
+
saveResult: pick('saveResult'),
|
|
138
|
+
payload: decodedPayload,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readListeners(node) {
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const kind of ['executionListener', 'taskListener']) {
|
|
145
|
+
for (const l of findAll(node, kind)) {
|
|
146
|
+
// expression and delegateExpression are not interchangeable: the first is
|
|
147
|
+
// evaluated against process variables, the second must resolve to a registered
|
|
148
|
+
// bean. Keeping them apart stops a bean name being reported as a missing variable.
|
|
149
|
+
const expression = l.attrs.expression || '';
|
|
150
|
+
const delegateExpression = l.attrs.delegateExpression || '';
|
|
151
|
+
out.push({
|
|
152
|
+
kind,
|
|
153
|
+
event: l.attrs.event || null,
|
|
154
|
+
expression,
|
|
155
|
+
delegateExpression,
|
|
156
|
+
class: l.attrs.class || null,
|
|
157
|
+
addon: parseAddonCall(expression || delegateExpression),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// AlurKerja stores its form definition as a JSON blob in an alurkerja:form attribute.
|
|
165
|
+
// Camunda's own camunda:formData is also read, since plain Camunda models use that.
|
|
166
|
+
function readFormFields(node) {
|
|
167
|
+
const fields = [];
|
|
168
|
+
|
|
169
|
+
const raw = node.attrs['alurkerja:form'];
|
|
170
|
+
if (raw) {
|
|
171
|
+
try {
|
|
172
|
+
const walk = (nodes) => {
|
|
173
|
+
for (const f of nodes || []) {
|
|
174
|
+
if (f.name) {
|
|
175
|
+
const label = typeof f.label === 'object' && f.label ? f.label.id || f.label.en : f.label;
|
|
176
|
+
fields.push({
|
|
177
|
+
name: f.name,
|
|
178
|
+
type: f.form_field_type || f.ui_type || '',
|
|
179
|
+
label: label || '',
|
|
180
|
+
required: Boolean(f.constraints?.required),
|
|
181
|
+
disabled: Boolean(f.disabled),
|
|
182
|
+
source: 'alurkerja',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
walk(f.children);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
walk(JSON.parse(raw));
|
|
189
|
+
} catch {
|
|
190
|
+
/* malformed form JSON is reported by lint, not thrown here */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const fd of findAll(node, 'formField')) {
|
|
195
|
+
fields.push({
|
|
196
|
+
name: fd.attrs.id,
|
|
197
|
+
type: fd.attrs.type || '',
|
|
198
|
+
label: fd.attrs.label || '',
|
|
199
|
+
required: false,
|
|
200
|
+
disabled: false,
|
|
201
|
+
source: 'camunda',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return fields;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readTimer(node) {
|
|
208
|
+
for (const kind of ['timeCycle', 'timeDuration', 'timeDate']) {
|
|
209
|
+
const [t] = findAll(node, kind);
|
|
210
|
+
if (t) return { kind, value: (t.text || '').trim() };
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function eventDetail(node) {
|
|
216
|
+
const defs = node.children.filter((c) => c.local.endsWith('EventDefinition'));
|
|
217
|
+
if (defs.length === 0) return null;
|
|
218
|
+
const d = defs[0];
|
|
219
|
+
const type = d.local.replace('EventDefinition', '');
|
|
220
|
+
const detail = { type };
|
|
221
|
+
if (type === 'timer') detail.timer = readTimer(d);
|
|
222
|
+
if (type === 'message') detail.ref = d.attrs.messageRef || null;
|
|
223
|
+
if (type === 'signal') detail.ref = d.attrs.signalRef || null;
|
|
224
|
+
if (type === 'error') detail.ref = d.attrs.errorRef || null;
|
|
225
|
+
if (type === 'conditional') {
|
|
226
|
+
const [c] = findAll(d, 'condition');
|
|
227
|
+
detail.condition = c ? (c.text || '').trim() : null;
|
|
228
|
+
}
|
|
229
|
+
return detail;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Builds a flat, tool-friendly model of one process definition.
|
|
233
|
+
export function readProcess(xml) {
|
|
234
|
+
const root = parseXml(xml);
|
|
235
|
+
const processes = findAll(root, 'process');
|
|
236
|
+
if (processes.length === 0) throw new Error('No <process> element found in this XML.');
|
|
237
|
+
|
|
238
|
+
// A collaboration can hold several processes; the executable one is what runs.
|
|
239
|
+
const proc = processes.find((p) => p.attrs.isExecutable === 'true') || processes[0];
|
|
240
|
+
|
|
241
|
+
const nodes = [];
|
|
242
|
+
const collect = (parent, scope) => {
|
|
243
|
+
for (const c of parent.children) {
|
|
244
|
+
if (FLOW_NODE_TYPES.has(c.local)) {
|
|
245
|
+
const isSubProcess = c.local === 'subProcess' || c.local === 'transaction';
|
|
246
|
+
const [mi] = findAll(c, 'multiInstanceLoopCharacteristics');
|
|
247
|
+
nodes.push({
|
|
248
|
+
id: c.attrs.id,
|
|
249
|
+
name: c.attrs.name || '',
|
|
250
|
+
type: c.local,
|
|
251
|
+
scope,
|
|
252
|
+
async:
|
|
253
|
+
c.attrs['camunda:asyncBefore'] === 'true'
|
|
254
|
+
? 'before'
|
|
255
|
+
: c.attrs['camunda:asyncAfter'] === 'true'
|
|
256
|
+
? 'after'
|
|
257
|
+
: null,
|
|
258
|
+
assignee: c.attrs['camunda:assignee'] || null,
|
|
259
|
+
candidateGroups: c.attrs['camunda:candidateGroups'] || null,
|
|
260
|
+
expression: c.attrs['camunda:expression'] || null,
|
|
261
|
+
delegateExpression: c.attrs['camunda:delegateExpression'] || null,
|
|
262
|
+
class: c.attrs['camunda:class'] || null,
|
|
263
|
+
topic: c.attrs['camunda:topic'] || null,
|
|
264
|
+
calledElement: c.attrs.calledElement || null,
|
|
265
|
+
defaultFlow: c.attrs.default || null,
|
|
266
|
+
multiInstance: mi ? (mi.attrs.isSequential === 'true' ? 'sequential' : 'parallel') : null,
|
|
267
|
+
attachedTo: c.attrs.attachedToRef || null,
|
|
268
|
+
event: eventDetail(c),
|
|
269
|
+
listeners: readListeners(c),
|
|
270
|
+
formFields: readFormFields(c),
|
|
271
|
+
raw: c,
|
|
272
|
+
});
|
|
273
|
+
if (isSubProcess) collect(c, c.attrs.id);
|
|
274
|
+
} else if (c.local === 'laneSet' || c.local === 'subProcess') {
|
|
275
|
+
collect(c, scope);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
collect(proc, null);
|
|
280
|
+
|
|
281
|
+
const flows = findAll(proc, 'sequenceFlow').map((f) => {
|
|
282
|
+
const [cond] = findAll(f, 'conditionExpression');
|
|
283
|
+
return {
|
|
284
|
+
id: f.attrs.id,
|
|
285
|
+
name: f.attrs.name || '',
|
|
286
|
+
source: f.attrs.sourceRef,
|
|
287
|
+
target: f.attrs.targetRef,
|
|
288
|
+
condition: cond ? (cond.text || '').trim() : null,
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const lanes = findAll(proc, 'lane').map((l) => ({
|
|
293
|
+
id: l.attrs.id,
|
|
294
|
+
name: l.attrs.name || '',
|
|
295
|
+
nodeRefs: findAll(l, 'flowNodeRef').map((r) => (r.text || '').trim()),
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
id: proc.attrs.id,
|
|
300
|
+
name: proc.attrs.name || '',
|
|
301
|
+
executable: proc.attrs.isExecutable === 'true',
|
|
302
|
+
versionTag: proc.attrs['camunda:versionTag'] || null,
|
|
303
|
+
historyTTL: proc.attrs['camunda:historyTimeToLive'] || null,
|
|
304
|
+
nodes,
|
|
305
|
+
flows,
|
|
306
|
+
lanes,
|
|
307
|
+
processListeners: readListeners(proc).filter((l) => !nodes.some((nd) => findAll(nd.raw, l.kind).length)),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Every ${...} expression in the model, with where it came from. This is what makes it
|
|
312
|
+
// possible to tell which variables a process reads before it is ever run.
|
|
313
|
+
export function collectExpressions(model) {
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const f of model.flows) {
|
|
316
|
+
if (f.condition) out.push({ where: `flow ${f.id}${f.name ? ` (${f.name})` : ''}`, expression: f.condition, kind: 'condition' });
|
|
317
|
+
}
|
|
318
|
+
for (const n of model.nodes) {
|
|
319
|
+
if (n.assignee) out.push({ where: `${n.id} assignee`, expression: n.assignee, kind: 'assignee' });
|
|
320
|
+
if (n.candidateGroups) out.push({ where: `${n.id} candidateGroups`, expression: n.candidateGroups, kind: 'assignee' });
|
|
321
|
+
if (n.expression) out.push({ where: `${n.id} expression`, expression: n.expression, kind: 'implementation' });
|
|
322
|
+
if (n.event?.condition) out.push({ where: `${n.id} condition event`, expression: n.event.condition, kind: 'condition' });
|
|
323
|
+
for (const l of n.listeners) {
|
|
324
|
+
if (l.expression) out.push({ where: `${n.id} ${l.kind}[${l.event}]`, expression: l.expression, kind: 'listener' });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Identifiers read directly (${foo}) versus defensively (${execution.getVariable('foo')}).
|
|
331
|
+
// The distinction matters: the first throws when unset, the second yields null.
|
|
332
|
+
const SAFE_ACCESS = /execution\.getVariable\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
333
|
+
const RESERVED = new Set([
|
|
334
|
+
'execution', 'task', 'authenticatedUserId', 'true', 'false', 'null', 'empty', 'now',
|
|
335
|
+
'currentUser', 'currentUserGroups', 'dateTime', 'alurkerjaParams', 'integrationContext',
|
|
336
|
+
'and', 'or', 'not', 'div', 'mod', 'instanceof', 'eq', 'ne', 'lt', 'gt', 'le', 'ge',
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
export function readVariables(expression) {
|
|
340
|
+
const direct = new Set();
|
|
341
|
+
const safe = new Set();
|
|
342
|
+
const text = String(expression || '');
|
|
343
|
+
|
|
344
|
+
// Plenty of these attributes hold plain literals rather than expressions; an assignee
|
|
345
|
+
// of "me@example.com" is not three variables called me, example and com.
|
|
346
|
+
if (!text.includes('${') && !text.includes('#{')) return { direct: [], safe: [] };
|
|
347
|
+
|
|
348
|
+
let m;
|
|
349
|
+
const safeRe = new RegExp(SAFE_ACCESS);
|
|
350
|
+
while ((m = safeRe.exec(text)) !== null) safe.add(m[1]);
|
|
351
|
+
|
|
352
|
+
// Strip the safe accessors and string literals, then take what is left that looks
|
|
353
|
+
// like a bare identifier not followed by '(' (which would make it a function call).
|
|
354
|
+
const stripped = text.replace(SAFE_ACCESS, ' ').replace(/'[^']*'|"[^"]*"/g, ' ');
|
|
355
|
+
const idRe = /\b([A-Za-z_$][A-Za-z0-9_$]*)\b(\s*\()?/g;
|
|
356
|
+
while ((m = idRe.exec(stripped)) !== null) {
|
|
357
|
+
const [, id, isCall] = m;
|
|
358
|
+
if (isCall || RESERVED.has(id)) continue;
|
|
359
|
+
// Property access (a.b) reads `a`, not `b`.
|
|
360
|
+
const before = stripped[m.index - 1];
|
|
361
|
+
if (before === '.') continue;
|
|
362
|
+
// A method call somewhere down the chain means this is a bean, not a variable:
|
|
363
|
+
// ${myBean.doThing()} resolves through the bean resolver, ${order.total} does not.
|
|
364
|
+
const after = stripped.slice(m.index + id.length);
|
|
365
|
+
if (/^\s*\.[A-Za-z_$][\w$]*\s*\(/.test(after)) continue;
|
|
366
|
+
direct.add(id);
|
|
367
|
+
}
|
|
368
|
+
return { direct: [...direct], safe: [...safe] };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Variables the process itself writes: form fields submitted by users, and addon
|
|
372
|
+
// results saved back onto the instance.
|
|
373
|
+
export function collectWrittenVariables(model) {
|
|
374
|
+
const written = new Map();
|
|
375
|
+
const add = (name, source) => {
|
|
376
|
+
if (!name) return;
|
|
377
|
+
if (!written.has(name)) written.set(name, []);
|
|
378
|
+
written.get(name).push(source);
|
|
379
|
+
};
|
|
380
|
+
for (const n of model.nodes) {
|
|
381
|
+
for (const f of n.formFields) add(f.name, `${n.id} form`);
|
|
382
|
+
for (const l of n.listeners) {
|
|
383
|
+
if (l.addon?.saveResult === 'true') add(`${l.addon.action} result`, `${n.id} addon`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return written;
|
|
387
|
+
}
|