@get-dx/cli 0.3.3 → 0.3.5

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.
@@ -0,0 +1,21 @@
1
+
2
+
3
+
4
+
5
+ ████████████████
6
+ █████████████████
7
+ ████████████████
8
+ ██████ ████████
9
+ ██████ ███████
10
+ ██████ ███████
11
+ ██████ ███████
12
+ ██████ ███████
13
+ ██████ ███████
14
+ ██████ ███████
15
+ ██████ ████████
16
+ ██████████████████████
17
+ ████████████████████
18
+ █████████████████
19
+
20
+
21
+
@@ -0,0 +1,21 @@
1
+
2
+
3
+
4
+
5
+ ███████ ████████
6
+ ███████ ███████
7
+ ███████ ███████
8
+ ████ ████████
9
+ ██ ███████
10
+ ███████
11
+ ████████
12
+ ███████
13
+ ███████
14
+ ████████ █
15
+ ████████ █████
16
+ ███████ ██████
17
+ ████████ ███████
18
+ ████████ ████████
19
+
20
+
21
+
@@ -0,0 +1,21 @@
1
+
2
+
3
+
4
+
5
+ ████████████████ ███████ ████████
6
+ █████████████████ ███████ ███████
7
+ ████████████████ ███████ ███████
8
+ ██████ ████████ ████ ████████
9
+ ██████ ███████ ██ ███████
10
+ ██████ ███████ ███████
11
+ ██████ ███████ ████████
12
+ ██████ ███████ ███████
13
+ ██████ ███████ ███████
14
+ ██████ ███████ ████████ █
15
+ ██████ ████████ ████████ █████
16
+ ██████████████████████ ███████ ██████
17
+ ████████████████████ ████████ ███████
18
+ █████████████████ ████████ ████████
19
+
20
+
21
+
@@ -0,0 +1,163 @@
1
+ import { input, select, confirm, search } from "@inquirer/prompts";
2
+ import { listTeams } from "../teams.js";
3
+ import { CliError, EXIT_CODES } from "../../errors.js";
4
+ import { renderRichText } from "../../renderers.js";
5
+ import * as ui from "../../ui.js";
6
+ export async function promptForParameterValue(runtime, param) {
7
+ const message = param.description
8
+ ? `${param.name}: ${param.description}`
9
+ : param.name;
10
+ switch (param.type) {
11
+ case "STRING": {
12
+ const rawValue = await input({
13
+ message,
14
+ default: param.default_value
15
+ ? param.default_value
16
+ : undefined,
17
+ validate: (value) => {
18
+ if (value === "" && !param.is_required) {
19
+ return true;
20
+ }
21
+ else if (value === "") {
22
+ return "Value is required";
23
+ }
24
+ return true;
25
+ },
26
+ });
27
+ return rawValue === "" ? undefined : rawValue;
28
+ }
29
+ case "INTEGER": {
30
+ const rawValue = await input({
31
+ message,
32
+ default: param.default_value
33
+ ? param.default_value
34
+ : undefined,
35
+ validate: (value) => {
36
+ if (value === "" && !param.is_required) {
37
+ return true;
38
+ }
39
+ else if (value === "") {
40
+ return "Value is required";
41
+ }
42
+ const num = Number(value);
43
+ if (isNaN(num)) {
44
+ return "Value must be a number";
45
+ }
46
+ else if (num % 1 !== 0) {
47
+ return "Value must be an integer";
48
+ }
49
+ else {
50
+ return true;
51
+ }
52
+ },
53
+ });
54
+ return rawValue === "" ? undefined : Number(rawValue);
55
+ }
56
+ case "FLOAT": {
57
+ const rawValue = await input({
58
+ message,
59
+ default: param.default_value
60
+ ? param.default_value
61
+ : undefined,
62
+ validate: (value) => {
63
+ if (value === "" && !param.is_required) {
64
+ return true;
65
+ }
66
+ else if (value === "") {
67
+ return "Value is required";
68
+ }
69
+ const num = Number(value);
70
+ if (isNaN(num)) {
71
+ return "Value must be a number";
72
+ }
73
+ else {
74
+ return true;
75
+ }
76
+ },
77
+ });
78
+ return rawValue === "" ? undefined : Number(rawValue);
79
+ }
80
+ case "BOOLEAN":
81
+ return await confirm({
82
+ message,
83
+ default: false,
84
+ });
85
+ case "SELECT": {
86
+ return await select({
87
+ message,
88
+ default: param.default_value
89
+ ? param.default_value
90
+ : undefined,
91
+ choices: param.definition.options.map((option) => ({
92
+ name: option,
93
+ value: option,
94
+ })),
95
+ });
96
+ }
97
+ case "USER":
98
+ throw new CliError("User-based parameters are not yet supported in the CLI", EXIT_CODES.ARGUMENT_ERROR);
99
+ case "TEAM": {
100
+ renderRichText([ui.p("Fetching teams...")]);
101
+ const teamsResponse = await listTeams(runtime);
102
+ const teams = teamsResponse.teams;
103
+ const teamId = await search({
104
+ message,
105
+ source: (term) => teams
106
+ .filter((team) => term === undefined ||
107
+ team.name.toLowerCase().includes(term.toLowerCase()))
108
+ .map((team) => ({
109
+ name: team.name,
110
+ value: team.id,
111
+ })),
112
+ });
113
+ // TODO: change the trigger endpoint to accept encoded IDs too
114
+ const decodedTeamId = decodeBase64(teamId);
115
+ return decodedTeamId;
116
+ }
117
+ case "EMAIL": {
118
+ const rawValue = await input({
119
+ message,
120
+ default: param.default_value
121
+ ? param.default_value
122
+ : undefined,
123
+ validate: (value) => {
124
+ if (value === "" && !param.is_required) {
125
+ return true;
126
+ }
127
+ else if (value === "") {
128
+ return "Value is required";
129
+ }
130
+ else if (!isValidEmail(value)) {
131
+ return "Value must be a valid email address";
132
+ }
133
+ return true;
134
+ },
135
+ });
136
+ return rawValue === "" ? undefined : rawValue;
137
+ }
138
+ default:
139
+ throw new CliError(`Unknown parameter type: ${param.type}`, EXIT_CODES.ARGUMENT_ERROR);
140
+ }
141
+ }
142
+ export function renderParameterData(parameters, data) {
143
+ const items = [];
144
+ for (const parameter of parameters) {
145
+ items.push(ui.p(parameter.description
146
+ ? `${ui.bold(parameter.name)} ${ui.dim(parameter.description)}`
147
+ : ui.bold(parameter.name), false));
148
+ if (data[parameter.identifier] !== undefined) {
149
+ items.push(ui.p(` ${ui.code(String(data[parameter.identifier]))}`));
150
+ }
151
+ else {
152
+ items.push(ui.p(` ${ui.dim("(none)")}`));
153
+ }
154
+ }
155
+ items.push(ui.blankLine());
156
+ renderRichText(items, { useStderr: true });
157
+ }
158
+ function decodeBase64(value) {
159
+ return Buffer.from(value, "base64").toString("utf-8");
160
+ }
161
+ function isValidEmail(value) {
162
+ return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value);
163
+ }
@@ -0,0 +1,286 @@
1
+ import { Command } from "commander";
2
+ import { confirm } from "@inquirer/prompts";
3
+ import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
4
+ import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
5
+ import { request } from "../../http.js";
6
+ import { renderJson, renderRichText } from "../../renderers.js";
7
+ import { buildRuntime } from "../../runtime.js";
8
+ import * as ui from "../../ui.js";
9
+ import { listWorkflows } from "../workflows.js";
10
+ import { promptForParameterValue, renderParameterData } from "./parameters.js";
11
+ import { getWorkflowRun, renderWorkflowRunSummary, } from "./info.js";
12
+ import { workflowRunEventContent } from "./events.js";
13
+ const POLL_INTERVAL_MS = 1000;
14
+ const PENDING_WORKFLOW_RUN_STATUSES = new Set([
15
+ "PENDING_RUN",
16
+ "IN_PROGRESS",
17
+ "WAITING_FOR_APPROVAL",
18
+ "WAITING_FOR_EVENTS",
19
+ ]);
20
+ const TERMINAL_SUCCESS = new Set(["SUCCEEDED"]);
21
+ const TERMINAL_FAILURE = new Set([
22
+ "FAILED",
23
+ "REJECTED",
24
+ "TIMEOUT",
25
+ "CANCELLED",
26
+ ]);
27
+ export function triggerCommand() {
28
+ return new Command()
29
+ .name("trigger")
30
+ .description("Trigger a Self-service workflow run and wait until it succeeds or fails. If the workflow requires approval, this command will submit a request and optionally notify approvers.")
31
+ .argument("<workflow-identifier>", "Workflow identifier (from dx workflows list)")
32
+ .option("--entity <identifier>", "Catalog entity identifier (required for entity-scoped workflows)")
33
+ .option("--param <key=value>", "Workflow parameter value (repeatable). Value after the first '=' is kept verbatim", (val, prev) => [...prev, val], [])
34
+ .option("--notify-approvers <boolean>", "For workflows that require approval, send a message to each approver requesting review")
35
+ .addHelpText("afterAll", createExampleText([
36
+ {
37
+ label: "Trigger a global workflow and wait for completion",
38
+ command: "dx workflowRuns trigger my-global-workflow",
39
+ },
40
+ {
41
+ label: "Trigger with an entity and parameters",
42
+ command: 'dx workflowRuns trigger provision-db --entity acme-api --param size=small --param "greeting=hello world"',
43
+ },
44
+ {
45
+ label: "Trigger with machine-readable output",
46
+ command: "dx workflowRuns trigger my-workflow --json --entity svc-1 --param count=3",
47
+ },
48
+ {
49
+ label: "Request approval to trigger a workflow but do not notify approvers",
50
+ command: "dx workflowRuns trigger workflow-requiring-approval --notify-approvers false",
51
+ },
52
+ ]))
53
+ .action(wrapAction(async (workflowIdentifier, options, command) => {
54
+ const context = getContext(command);
55
+ const runtime = buildRuntime(context);
56
+ // Fetch list of all workflows, find the one that applies
57
+ const workflowsResponse = await listWorkflows(runtime, {});
58
+ const workflow = workflowsResponse.workflows.find((w) => w.identifier === workflowIdentifier);
59
+ if (!workflow) {
60
+ throw new CliError(`Workflow \`${workflowIdentifier}\` not found`, EXIT_CODES.ARGUMENT_ERROR);
61
+ }
62
+ const requiresApproval = workflow.trigger_type === "APPROVAL";
63
+ const entityIdentifier = parseOptionalTrimmed(options.entity);
64
+ if (entityIdentifier === undefined && workflow.scope === "ENTITY") {
65
+ throw new CliError("--entity is required for entity-scoped workflows", EXIT_CODES.ARGUMENT_ERROR);
66
+ }
67
+ // Collect parameter data
68
+ const parameterData = parseParameterData(options.param);
69
+ if (isInteractive()) {
70
+ for (const param of workflow.parameters) {
71
+ if (parameterData[param.identifier] !== undefined) {
72
+ continue;
73
+ }
74
+ parameterData[param.identifier] = await promptForParameterValue(runtime, param);
75
+ }
76
+ }
77
+ let notifyApprovers = true;
78
+ if (requiresApproval && options.notifyApprovers !== undefined) {
79
+ notifyApprovers = options.notifyApprovers === "true";
80
+ }
81
+ else if (requiresApproval && isInteractive()) {
82
+ notifyApprovers = await confirm({
83
+ message: "Notify approvers?",
84
+ default: true,
85
+ });
86
+ }
87
+ if (isInteractive()) {
88
+ // Confirm everything looks right
89
+ renderRichText([ui.blankLine(), ui.h3("Confirm"), ui.blankLine()], {
90
+ useStderr: true,
91
+ });
92
+ if (entityIdentifier !== undefined) {
93
+ renderRichText([
94
+ ui.p(`${ui.bold("Entity")}`, false),
95
+ ui.p(` ${ui.code(entityIdentifier)}`),
96
+ ui.blankLine(),
97
+ ], {
98
+ useStderr: true,
99
+ });
100
+ }
101
+ if (workflow.parameters.length > 0) {
102
+ renderParameterData(workflow.parameters, parameterData);
103
+ }
104
+ const confirmationMessage = requiresApproval
105
+ ? "Request approval to trigger this workflow run with these settings?"
106
+ : "Trigger this workflow run with these settings?";
107
+ const isConfirmed = await confirm({
108
+ message: confirmationMessage,
109
+ default: true,
110
+ });
111
+ if (!isConfirmed) {
112
+ throw new CliError("Workflow run not confirmed", EXIT_CODES.RETRY_RECOMMENDED);
113
+ }
114
+ }
115
+ // Create the request body
116
+ const triggerRequestBody = {
117
+ workflow_identifier: workflowIdentifier.trim(),
118
+ };
119
+ if (entityIdentifier !== undefined) {
120
+ triggerRequestBody.entity_identifier = entityIdentifier;
121
+ }
122
+ if (Object.keys(parameterData).length > 0) {
123
+ triggerRequestBody.data = parameterData;
124
+ }
125
+ if (requiresApproval) {
126
+ triggerRequestBody.notify_approvers = notifyApprovers;
127
+ }
128
+ // Trigger the workflow
129
+ let runId;
130
+ try {
131
+ const triggerResponse = await triggerWorkflowRun(runtime, triggerRequestBody);
132
+ runId = triggerResponse.body.workflow_run.id;
133
+ }
134
+ catch (error) {
135
+ renderRichText([
136
+ ui.p(`${ui.error(ui.GLYPHS.ERROR)} Failed to trigger workflow run.`),
137
+ ], { useStderr: true });
138
+ throw error;
139
+ }
140
+ renderRichText([
141
+ ui.blankLine(),
142
+ ui.p(`Workflow run ${ui.code(runId)} triggered.`, false),
143
+ ui.p(`Web link: ${ui.link(ui.webLink(`/self-service/workflow-runs/${runId}`, runtime))}`),
144
+ ui.blankLine(),
145
+ ], { useStderr: true });
146
+ // Poll for updates
147
+ try {
148
+ await sleep(POLL_INTERVAL_MS);
149
+ renderRichText([ui.h3("Events"), ui.blankLine()], {
150
+ useStderr: true,
151
+ });
152
+ const finalDetail = await pollForWorkflowRunInfo(runtime, runId);
153
+ if (runtime.context.json) {
154
+ renderJson({ ok: true, workflow_run: finalDetail });
155
+ }
156
+ else {
157
+ renderRichText([
158
+ ui.blankLine(),
159
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Workflow run ${ui.code(finalDetail.id)} completed with status ${ui.bold(finalDetail.status)}.`),
160
+ ui.blankLine(),
161
+ ]);
162
+ renderWorkflowRunSummary(finalDetail, runtime);
163
+ }
164
+ }
165
+ catch (error) {
166
+ renderRichText([
167
+ ui.blankLine(),
168
+ ui.p(`${ui.error(ui.GLYPHS.ERROR)} Workflow run failed.`),
169
+ ], { useStderr: true });
170
+ throw error;
171
+ }
172
+ }));
173
+ }
174
+ // TODO: move somewhere central
175
+ function isInteractive() {
176
+ return process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY;
177
+ }
178
+ function parseOptionalTrimmed(value) {
179
+ if (typeof value !== "string") {
180
+ return undefined;
181
+ }
182
+ const t = value.trim();
183
+ return t.length > 0 ? t : undefined;
184
+ }
185
+ function parseParameterData(pairs) {
186
+ if (pairs.length === 0) {
187
+ return {};
188
+ }
189
+ const result = {};
190
+ for (const pair of pairs) {
191
+ const eqIdx = pair.indexOf("=");
192
+ if (eqIdx === -1) {
193
+ throw new CliError(`Invalid --param format: "${pair}". Expected key=value.`, EXIT_CODES.ARGUMENT_ERROR);
194
+ }
195
+ const key = pair.slice(0, eqIdx).trim();
196
+ if (!key) {
197
+ throw new CliError(`Invalid --param format: "${pair}". Parameter name cannot be empty.`, EXIT_CODES.ARGUMENT_ERROR);
198
+ }
199
+ const raw = pair.slice(eqIdx + 1);
200
+ result[key] = coerceParamValue(raw);
201
+ }
202
+ return result;
203
+ }
204
+ function coerceParamValue(raw) {
205
+ const trimmed = raw.trim();
206
+ if (trimmed === "true") {
207
+ return true;
208
+ }
209
+ if (trimmed === "false") {
210
+ return false;
211
+ }
212
+ if (/^-?\d+$/.test(trimmed)) {
213
+ return Number(trimmed);
214
+ }
215
+ if (/^-?\d+\.\d+$/.test(trimmed)) {
216
+ return Number(trimmed);
217
+ }
218
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
219
+ try {
220
+ return JSON.parse(trimmed);
221
+ }
222
+ catch {
223
+ throw new CliError(`Invalid JSON in --param value: ${trimmed.slice(0, 80)}${trimmed.length > 80 ? "…" : ""}`, EXIT_CODES.ARGUMENT_ERROR);
224
+ }
225
+ }
226
+ return raw;
227
+ }
228
+ async function triggerWorkflowRun(runtime, body) {
229
+ return request(runtime, "/workflowRuns.trigger", {
230
+ method: "POST",
231
+ body,
232
+ });
233
+ }
234
+ async function pollForWorkflowRunInfo(runtime, workflowRunId) {
235
+ const seenEventIds = new Set();
236
+ while (true) {
237
+ try {
238
+ const infoResponse = await getWorkflowRun(runtime, workflowRunId);
239
+ const workflowRun = infoResponse.body.workflow_run;
240
+ if (workflowRun.events) {
241
+ for (const event of workflowRun.events) {
242
+ if (!seenEventIds.has(event.id)) {
243
+ renderRichText([workflowRunEventContent(event), ui.blankLine()], {
244
+ useStderr: true,
245
+ });
246
+ seenEventIds.add(event.id);
247
+ }
248
+ }
249
+ }
250
+ if (TERMINAL_SUCCESS.has(workflowRun.status)) {
251
+ return workflowRun;
252
+ }
253
+ if (TERMINAL_FAILURE.has(workflowRun.status)) {
254
+ throw buildTerminalStatusError(workflowRun);
255
+ }
256
+ if (!PENDING_WORKFLOW_RUN_STATUSES.has(workflowRun.status)) {
257
+ throw new CliError(`Unexpected workflow run status: ${workflowRun.status}`, 1);
258
+ }
259
+ // progress.update(
260
+ // `${ui.bold("Workflow running")} ${ui.dim(`(${workflowRunId})`)} — ${workflowRun.status}`,
261
+ // );
262
+ await sleep(POLL_INTERVAL_MS);
263
+ }
264
+ catch (error) {
265
+ if (error instanceof HttpError && error.status === 429) {
266
+ // progress.update(
267
+ // `${ui.warning(ui.GLYPHS.WARNING)} Rate limited while polling ${ui.dim(`(${workflowRunId})`)}; retrying`,
268
+ // );
269
+ await sleep(POLL_INTERVAL_MS * 5);
270
+ continue;
271
+ }
272
+ throw error;
273
+ }
274
+ }
275
+ }
276
+ function buildTerminalStatusError(run) {
277
+ if (run.status === "FAILED") {
278
+ return new CliError(`Workflow run ${run.id} failed.`, 1);
279
+ }
280
+ return new CliError(`Workflow run ${run.id} ended with status ${run.status}.`, 1);
281
+ }
282
+ async function sleep(ms) {
283
+ await new Promise((resolve) => {
284
+ setTimeout(resolve, ms);
285
+ });
286
+ }
@@ -3,6 +3,7 @@ import { addLinkCommand } from "./workflowRuns/addLink.js";
3
3
  import { changeStatusCommand } from "./workflowRuns/changeStatus.js";
4
4
  import { infoCommand } from "./workflowRuns/info.js";
5
5
  import { postMessageCommand } from "./workflowRuns/postMessage.js";
6
+ import { triggerCommand } from "./workflowRuns/trigger.js";
6
7
  export function workflowRunsCommand() {
7
8
  const workflowRuns = new Command()
8
9
  .name("workflowRuns")
@@ -11,5 +12,6 @@ export function workflowRunsCommand() {
11
12
  workflowRuns.addCommand(changeStatusCommand());
12
13
  workflowRuns.addCommand(infoCommand());
13
14
  workflowRuns.addCommand(postMessageCommand());
15
+ workflowRuns.addCommand(triggerCommand());
14
16
  return workflowRuns;
15
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {
@@ -11,6 +11,7 @@
11
11
  "dx": "dist/index.js"
12
12
  },
13
13
  "files": [
14
+ "assets",
14
15
  "dist"
15
16
  ],
16
17
  "homepage": "https://github.com/get-dx/cli#readme",