camunda-cli 0.2.0 → 0.3.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 CHANGED
@@ -63,7 +63,15 @@ Sequence flows
63
63
  Gateway_1jk90v6 Activity_retry no ${paid == false}
64
64
  ```
65
65
 
66
- **Find the bugs before an instance does.** `lint` checks the deployed model statically.
66
+ **Find the bugs before an instance does.** `lint` checks a model statically. It takes a
67
+ deployed key or a local file, so the usual loop is to check while editing and only then
68
+ deploy:
69
+
70
+ ```bash
71
+ camunda lint ./order-process.bpmn # no engine and no login needed
72
+ camunda lint order-process # or the deployed version
73
+ ```
74
+
67
75
  Every rule exists because that failure was reproduced against a real engine first:
68
76
 
69
77
  | Rule | What it catches |
@@ -132,10 +140,28 @@ engine's key-based endpoints answer *"no matching process definition ... and no
132
140
  which reads like the process is missing when it is not. Every command taking a key accepts
133
141
  `--tenant`, and an ambiguous key lists the candidates rather than guessing.
134
142
 
135
- **`start` waits before reporting success.** A start returning HTTP 200 only means the engine
136
- accepted it; anything marked async runs after the response. `start` looks for a failure a
137
- moment later and reports it, instead of leaving you with a green message and a broken
138
- instance. `--no-wait` skips that.
143
+ **`start` and `complete` report what happened next.** A start returning HTTP 200 only means
144
+ the engine accepted it; anything marked async runs after the response. Both commands wait a
145
+ moment, then say whether it failed, whether the instance finished, or which task is now
146
+ waiting, along with the exact command to complete it:
147
+
148
+ ```
149
+ $ camunda start order-process --var amount=700:Integer
150
+ Started order-process v9 as instance 3435051
151
+
152
+ Now waiting at:
153
+ 3435058 check stock ops
154
+ camunda complete 3435058 --var quantity=<value>
155
+
156
+ $ camunda complete 3435058 --var quantity=3
157
+ Task 3435058 completed.
158
+ Instance 3435051 finished in 8.0s.
159
+ ```
160
+
161
+ `--no-wait` skips the follow-up.
162
+
163
+ **Cleaning up after a test run.** `cancel --key <key>` terminates every running instance of
164
+ a process in one go, which matters because testing a model leaves a trail of them behind.
139
165
 
140
166
  **Variable types matter.** `--var n=300` sends a string, and `"300" > 200` is a string
141
167
  comparison. Use `--var n=300:Integer` where a gateway compares numerically, or `name:=<json>`
package/bin/camunda.js CHANGED
@@ -24,7 +24,7 @@ program
24
24
  'Start with "camunda inspect <key>" to read a deployed model, and\n' +
25
25
  '"camunda diagnose <instanceId>" when an instance misbehaves.'
26
26
  )
27
- .version('0.2.0')
27
+ .version('0.3.0')
28
28
  .option('--json', 'print the raw API payload instead of a formatted view')
29
29
  .option('--no-color', 'never emit colour, even on a terminal')
30
30
  .showHelpAfterError()
@@ -66,7 +66,7 @@ withVersion(
66
66
  withTenant(
67
67
  program
68
68
  .command('inspect <keyOrId>')
69
- .description('Structure of a deployed model: elements, flow conditions, forms, integrations')
69
+ .description('Structure of a model: elements, flow conditions, forms, integrations. Accepts a deployed key or a local .bpmn file')
70
70
  .option('--no-lint', 'skip the static-check summary at the end')
71
71
  )
72
72
  ).action(inspectCommand);
@@ -75,7 +75,7 @@ withVersion(
75
75
  withTenant(
76
76
  program
77
77
  .command('lint <keyOrId>')
78
- .description('Static checks over a deployed model, before an instance has to find the bugs')
78
+ .description('Static checks over a model, before an instance has to find the bugs. Accepts a deployed key or a local .bpmn file')
79
79
  .option('-a, --all', 'include informational findings')
80
80
  .addOption(new Option('-s, --severity <level>', 'only one severity').choices(['error', 'warning', 'info']))
81
81
  )
@@ -120,12 +120,15 @@ withVersion(
120
120
  )
121
121
  ).action(startCommand);
122
122
 
123
- program
124
- .command('cancel <id>')
125
- .description('Force-terminate a running instance; it ends as EXTERNALLY_TERMINATED')
126
- .option('-r, --reason <text>', 'recorded against the instance')
127
- .option('-y, --yes', 'skip the confirmation prompt')
128
- .action(cancelCommand);
123
+ withTenant(
124
+ program
125
+ .command('cancel [id]')
126
+ .description('Force-terminate one instance, or every instance of a process with --key')
127
+ .option('-k, --key <definitionKey>', 'cancel every running instance of this process')
128
+ .option('-b, --business-key <key>', 'with --key, only instances carrying this business key')
129
+ .option('-r, --reason <text>', 'recorded against the instances')
130
+ .option('-y, --yes', 'skip the confirmation prompt')
131
+ ).action(cancelCommand);
129
132
 
130
133
  program
131
134
  .command('vars <instanceId>')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camunda-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line client for self-hosted Camunda 7: inspect and lint deployed BPMN models, and diagnose why an instance is stuck",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,10 +1,28 @@
1
- import { writeFileSync } from 'node:fs';
1
+ import { writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
2
2
  import { Client, resolveDefinition } from '../client.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { readProcess } from '../bpmn.js';
5
5
  import { lintProcess } from '../lint.js';
6
6
  import * as out from '../output.js';
7
7
 
8
+ // inspect and lint accept a local .bpmn file as well as a deployed key, because the point
9
+ // of checking a model is to do it while editing, before it reaches an engine. A local file
10
+ // needs no credentials either, so the config lookup only happens for the remote path.
11
+ function isLocalFile(target) {
12
+ return /\.(bpmn|xml|dmn)$/i.test(target) && existsSync(target) && statSync(target).isFile();
13
+ }
14
+
15
+ async function loadModel(target, options) {
16
+ if (isLocalFile(target)) {
17
+ const xml = readFileSync(target, 'utf8');
18
+ return { model: readProcess(xml), xml, source: { local: target } };
19
+ }
20
+ const client = new Client(requireConfig());
21
+ const def = await resolveDefinition(client, target, options);
22
+ const { bpmn20Xml } = await client.processDefinitionXml(def.id);
23
+ return { model: readProcess(bpmn20Xml), xml: bpmn20Xml, source: { definition: def }, client };
24
+ }
25
+
8
26
  export async function definitionsCommand(options) {
9
27
  const client = new Client(requireConfig());
10
28
  const query = { maxResults: options.limit ?? 50, sortBy: 'key', sortOrder: 'asc' };
@@ -27,32 +45,41 @@ export async function definitionsCommand(options) {
27
45
  // Structural view of a deployed model. None of this is available as a REST resource:
28
46
  // it is read out of the deployed BPMN XML, which is the only place the engine keeps it.
29
47
  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);
48
+ const { model, source, client } = await loadModel(keyOrId, options);
49
+ const def = source.definition;
34
50
 
35
51
  let stats = [];
36
- try {
37
- stats = await client.processDefinitionStatistics(def.id);
38
- } catch {
39
- /* statistics are optional context, not worth failing the command over */
52
+ if (client && def) {
53
+ try {
54
+ stats = await client.processDefinitionStatistics(def.id);
55
+ } catch {
56
+ /* statistics are optional context, not worth failing the command over */
57
+ }
40
58
  }
41
59
  const liveByActivity = new Map(stats.map((s) => [s.id, s]));
42
60
 
43
61
  if (out.isJsonMode()) {
44
- return out.json({ definition: def, process: stripRaw(model), statistics: stats });
62
+ return out.json({ definition: def ?? { file: source.local }, process: stripRaw(model), statistics: stats });
45
63
  }
46
64
 
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
- ]);
65
+ out.heading(def ? def.name || def.key : model.name || model.id);
66
+ out.kv(
67
+ def
68
+ ? [
69
+ ['key', def.key],
70
+ ['id', def.id],
71
+ ['version', def.version],
72
+ ['tenant', def.tenantId ?? '-'],
73
+ ['suspended', def.suspended ? 'yes' : 'no'],
74
+ ['executable', model.executable ? 'yes' : 'no'],
75
+ ]
76
+ : [
77
+ ['file', source.local],
78
+ ['process id', model.id],
79
+ ['executable', model.executable ? 'yes' : 'no'],
80
+ ['status', 'not deployed, read from disk'],
81
+ ]
82
+ );
56
83
 
57
84
  if (model.lanes.length > 0) {
58
85
  out.line('\nLanes');
@@ -114,24 +141,22 @@ export async function inspectCommand(keyOrId, options) {
114
141
  }
115
142
 
116
143
  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);
144
+ const { model, source } = await loadModel(keyOrId, options);
145
+ const label = source.definition ? `${source.definition.key} v${source.definition.version}` : source.local;
121
146
  const findings = lintProcess(model);
122
147
 
123
148
  const wanted = options.severity
124
149
  ? findings.filter((f) => f.severity === options.severity)
125
150
  : findings.filter((f) => options.all || f.severity !== 'info');
126
151
 
127
- if (out.isJsonMode()) return out.json({ definition: def.id, findings: wanted });
152
+ if (out.isJsonMode()) return out.json({ source: label, findings: wanted });
128
153
 
129
154
  if (wanted.length === 0) {
130
- out.line(`${def.key} v${def.version}: nothing to report.`);
155
+ out.line(`${label}: nothing to report.`);
131
156
  return;
132
157
  }
133
158
 
134
- out.heading(`${def.key} v${def.version}`);
159
+ out.heading(label);
135
160
  for (const f of wanted) {
136
161
  const tag = f.severity === 'error' ? 'ERROR ' : f.severity === 'warning' ? 'WARNING' : 'INFO ';
137
162
  out.line(`\n${tag} ${f.rule} ${f.element}`);
@@ -225,15 +225,18 @@ export async function stacktraceCommand(jobId, options) {
225
225
  if (omitted > 0) out.note(` ... ${omitted} engine-internal frame(s) hidden, use --full for everything`);
226
226
  }
227
227
 
228
- // The execution trace. Ordering matters here: several activities routinely share a
229
- // millisecond, so ties are broken by following the sequence flows rather than trusting
230
- // the timestamp alone.
228
+ // The execution trace, in the order the engine actually took the steps.
229
+ //
230
+ // Sorting by startTime looks right and is not: a gateway and the events either side of it
231
+ // routinely share a millisecond, and the resulting order is arbitrary within that tie, so
232
+ // a start event can appear halfway down the list. `occurrence` is the engine's own
233
+ // execution sequence and is the only field that reflects real order.
231
234
  export async function traceCommand(id, options) {
232
235
  const client = new Client(requireConfig());
233
236
  const activities = await client.historyActivityInstances({
234
237
  processInstanceId: id,
235
238
  maxResults: options.limit ?? 200,
236
- sortBy: 'startTime',
239
+ sortBy: 'occurrence',
237
240
  sortOrder: 'asc',
238
241
  });
239
242
 
@@ -2,6 +2,8 @@ import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
3
  import { Client, parseVariableFlags, resolveDefinition } from '../client.js';
4
4
  import { requireConfig } from '../config.js';
5
+ import { readProcess } from '../bpmn.js';
6
+ import { reportNextState, suggestCompletion } from '../followup.js';
5
7
  import * as out from '../output.js';
6
8
 
7
9
  export async function instancesCommand(options) {
@@ -117,46 +119,72 @@ export async function startCommand(keyOrId, options) {
117
119
  variables,
118
120
  });
119
121
 
120
- // A start returning 200 only means the engine accepted it. Anything marked async runs
121
- // after the response, so a failure there shows up as a job or incident a moment later
122
- // rather than in this reply.
123
- let trouble = { incidents: [], jobs: [] };
124
- if (!options.noWait) {
125
- await new Promise((r) => setTimeout(r, options.wait ?? 1200));
126
- const [incidents, jobs] = await Promise.all([
127
- client.incidents({ processInstanceId: result.id }).catch(() => []),
128
- client.jobs({ processInstanceId: result.id, withException: true }).catch(() => []),
129
- ]);
130
- trouble = { incidents, jobs };
131
- }
132
-
133
- if (out.isJsonMode()) return out.json({ ...result, ...trouble });
122
+ if (out.isJsonMode() && options.noWait) return out.json(result);
134
123
 
135
124
  out.line(`Started ${def.key} v${def.version} as instance ${result.id}`);
136
125
  if (result.businessKey) out.note(`business key ${result.businessKey}`);
126
+ if (options.noWait) return;
137
127
 
138
- if (trouble.incidents.length > 0 || trouble.jobs.length > 0) {
139
- const msg = trouble.incidents[0]?.incidentMessage || trouble.jobs[0]?.exceptionMessage;
140
- out.problem(`\nIt already failed after starting: ${msg}`);
141
- out.note(`Run: camunda diagnose ${result.id}`);
142
- process.exitCode = 1;
128
+ // A start returning 200 only means the engine accepted it. Anything marked async runs
129
+ // after the response, so both a failure and the first user task appear a moment later
130
+ // rather than in this reply.
131
+ const next = await reportNextState(client, result.id, { wait: options.wait ?? 1200 });
132
+ if (next.failed) process.exitCode = 1;
133
+ else if (next.tasks.length === 1) {
134
+ out.note(await suggestCompletion(client, next.tasks[0], readProcess));
143
135
  }
144
136
  }
145
137
 
138
+ // Takes either one id or a whole definition's worth of instances. Testing a model leaves
139
+ // a trail of instances behind, and clearing them one confirmation at a time is enough
140
+ // friction that they tend to get left running instead.
146
141
  export async function cancelCommand(id, options) {
147
142
  const client = new Client(requireConfig());
148
143
 
144
+ if (!id && !options.key) {
145
+ throw new Error('Give an instance id, or --key <definitionKey> to cancel every running instance of one process.');
146
+ }
147
+
148
+ let targets;
149
+ if (id) {
150
+ targets = [id];
151
+ } else {
152
+ const query = { processDefinitionKey: options.key, maxResults: 1000 };
153
+ if (options.tenant) query.tenantIdIn = options.tenant;
154
+ if (options.businessKey) query.businessKey = options.businessKey;
155
+ const found = await client.processInstances(query);
156
+ targets = found.map((i) => i.id);
157
+ if (targets.length === 0) return out.note(`No running instances of ${options.key}.`);
158
+ }
159
+
149
160
  if (!options.yes) {
150
161
  const rl = createInterface({ input: stdin, output: stdout });
162
+ const what =
163
+ targets.length === 1
164
+ ? `Force-terminating ${targets[0]}`
165
+ : `Force-terminating all ${targets.length} running instances of ${options.key}`;
166
+ const expect = targets.length === 1 ? targets[0] : String(targets.length);
151
167
  const answer = await rl.question(
152
- `Force-terminating ${id} cannot be undone (it ends as EXTERNALLY_TERMINATED, not COMPLETED).\nType the instance id to confirm: `
168
+ `${what} cannot be undone (they end as EXTERNALLY_TERMINATED, not COMPLETED).\nType "${expect}" to confirm: `
153
169
  );
154
170
  rl.close();
155
- if (answer.trim() !== id) return out.note('Aborted, the id did not match.');
171
+ if (answer.trim() !== expect) return out.note('Aborted, that did not match.');
172
+ }
173
+
174
+ let done = 0;
175
+ const failures = [];
176
+ for (const target of targets) {
177
+ try {
178
+ await client.deleteProcessInstance(target, { reason: options.reason });
179
+ done++;
180
+ } catch (err) {
181
+ failures.push(`${target}: ${err.message}`);
182
+ }
156
183
  }
157
184
 
158
- await client.deleteProcessInstance(id, { reason: options.reason });
159
- out.line(`Instance ${id} terminated.`);
185
+ out.line(`Terminated ${done} instance(s).`);
186
+ for (const f of failures) out.problem(` ${f}`);
187
+ if (failures.length > 0) process.exitCode = 1;
160
188
  }
161
189
 
162
190
  export async function varsCommand(id, options) {
@@ -1,6 +1,7 @@
1
1
  import { Client, parseVariableFlags } from '../client.js';
2
2
  import { requireConfig } from '../config.js';
3
3
  import { readProcess } from '../bpmn.js';
4
+ import { reportNextState, suggestCompletion } from '../followup.js';
4
5
  import { unwrapError, explain } from '../errors.js';
5
6
  import * as out from '../output.js';
6
7
 
@@ -84,6 +85,13 @@ export async function completeCommand(id, options) {
84
85
  const client = new Client(requireConfig());
85
86
  const variables = parseVariableFlags(options.var);
86
87
 
88
+ // The task is gone once it completes, so its instance has to be noted beforehand for
89
+ // the follow-up to have something to report on.
90
+ const instanceId = await client
91
+ .task(id)
92
+ .then((t) => t.processInstanceId)
93
+ .catch(() => null);
94
+
87
95
  try {
88
96
  await client.completeTask(id, variables);
89
97
  } catch (err) {
@@ -108,11 +116,12 @@ export async function completeCommand(id, options) {
108
116
  }
109
117
 
110
118
  out.line(`Task ${id} completed.`);
119
+ if (options.noWait || !instanceId) return;
111
120
 
112
- if (!options.noWait) {
113
- await new Promise((r) => setTimeout(r, options.wait ?? 1000));
114
- const task = await client.task(id).catch(() => null);
115
- void task;
121
+ const next = await reportNextState(client, instanceId, { wait: options.wait ?? 1000 });
122
+ if (next.failed) process.exitCode = 1;
123
+ else if (next.tasks.length === 1) {
124
+ out.note(await suggestCompletion(client, next.tasks[0], readProcess));
116
125
  }
117
126
  }
118
127
 
@@ -0,0 +1,60 @@
1
+ // What happened after an action that moves a process forward.
2
+ //
3
+ // Both starting an instance and completing a task leave the caller needing the same three
4
+ // answers: did it break, did it finish, and what is waiting now. Without them every step of
5
+ // driving a process costs an extra round trip to work out where it went, which is most of
6
+ // the effort in testing a model with more than two steps.
7
+
8
+ import { unwrapError } from './errors.js';
9
+ import * as out from './output.js';
10
+
11
+ export async function reportNextState(client, instanceId, { wait = 1000 } = {}) {
12
+ if (wait) await new Promise((r) => setTimeout(r, wait));
13
+
14
+ const [historic, incidents, jobs, tasks] = await Promise.all([
15
+ client.historyProcessInstance(instanceId).catch(() => null),
16
+ client.incidents({ processInstanceId: instanceId }).catch(() => []),
17
+ client.jobs({ processInstanceId: instanceId, withException: true }).catch(() => []),
18
+ client.tasks({ processInstanceId: instanceId }).catch(() => []),
19
+ ]);
20
+
21
+ const failure = incidents[0]?.incidentMessage || jobs[0]?.exceptionMessage || null;
22
+ const state = historic?.state ?? null;
23
+
24
+ if (failure) {
25
+ out.problem(`\nIt failed after this step: ${unwrapError(failure).message}`);
26
+ out.note(`camunda diagnose ${instanceId}`);
27
+ return { state, failed: true, tasks };
28
+ }
29
+
30
+ if (state && state !== 'ACTIVE') {
31
+ const label = state === 'COMPLETED' ? 'finished' : `ended as ${state}`;
32
+ out.line(`Instance ${instanceId} ${label}${historic.durationInMillis ? ` in ${out.formatDuration(historic.durationInMillis)}` : ''}.`);
33
+ return { state, failed: false, tasks: [] };
34
+ }
35
+
36
+ if (tasks.length > 0) {
37
+ out.line(tasks.length === 1 ? '\nNow waiting at:' : `\nNow waiting at ${tasks.length} tasks:`);
38
+ out.table(null, tasks.map((t) => [` ${t.id}`, out.truncate(t.name, 34), t.assignee ?? 'unassigned']));
39
+ return { state, failed: false, tasks };
40
+ }
41
+
42
+ out.note(`\nStill running, not sitting at a user task. camunda instance ${instanceId}`);
43
+ return { state, failed: false, tasks: [] };
44
+ }
45
+
46
+ // The form fields a task needs, read from the deployed model. AlurKerja keeps its form in
47
+ // an extension attribute the engine's own form endpoints do not read, so this goes through
48
+ // the XML instead.
49
+ export async function suggestCompletion(client, task, readProcess) {
50
+ try {
51
+ const { bpmn20Xml } = await client.processDefinitionXml(task.processDefinitionId);
52
+ const model = readProcess(bpmn20Xml);
53
+ const node = model.nodes.find((n) => n.id === task.taskDefinitionKey);
54
+ const writable = (node?.formFields ?? []).filter((f) => !f.disabled);
55
+ if (writable.length === 0) return `camunda complete ${task.id}`;
56
+ return `camunda complete ${task.id} ${writable.map((f) => `--var ${f.name}=<value>`).join(' ')}`;
57
+ } catch {
58
+ return `camunda complete ${task.id}`;
59
+ }
60
+ }