@get-dx/cli 0.3.2 → 0.3.4

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/dist/assets.js ADDED
@@ -0,0 +1,15 @@
1
+ import fs from "fs";
2
+ let assets = null;
3
+ export function getAssets() {
4
+ if (assets === null) {
5
+ assets = {
6
+ logoD: loadAsset("logo-d.txt"),
7
+ logoX: loadAsset("logo-x.txt"),
8
+ };
9
+ }
10
+ return assets;
11
+ }
12
+ function loadAsset(filename) {
13
+ const filePath = new URL(`../assets/${filename}`, import.meta.url);
14
+ return fs.readFileSync(filePath, "utf8");
15
+ }
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { scorecardsCommand } from "./commands/scorecards.js";
6
6
  import { snapshotsCommand } from "./commands/snapshots.js";
7
7
  import { studioCommand } from "./commands/studio.js";
8
8
  import { teamsCommand } from "./commands/teams.js";
9
+ import { workflowRunsCommand } from "./commands/workflowRuns.js";
9
10
  import { workflowsCommand } from "./commands/workflows.js";
10
11
  import { handleError } from "./commandHelpers.js";
11
12
  import cliPackage from "../package.json" with { type: "json" };
@@ -40,6 +41,7 @@ function createProgram() {
40
41
  program.addCommand(snapshotsCommand());
41
42
  program.addCommand(studioCommand());
42
43
  program.addCommand(teamsCommand());
44
+ program.addCommand(workflowRunsCommand());
43
45
  program.addCommand(workflowsCommand());
44
46
  applyExitOverride(program);
45
47
  return program;
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import { deleteToken, setToken } from "../secrets.js";
4
4
  import { renderJson } from "../renderers.js";
5
5
  import { renderAuthInfo, renderLoggedOut } from "./authRendering.js";
6
- import { getContext, wrapAction } from "../commandHelpers.js";
6
+ import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
7
7
  import { persistBaseUrls, resolveApiBaseUrl, deriveBaseUrlsFromEnv, } from "../config.js";
8
8
  import { CliError } from "../errors.js";
9
9
  import { request } from "../http.js";
@@ -17,6 +17,20 @@ export function authCommand() {
17
17
  auth
18
18
  .command("login")
19
19
  .option("--token <token>", "Account web API token or personal access token. Omit this option to login interactively by web browser or pasting.")
20
+ .addHelpText("afterAll", createExampleText([
21
+ {
22
+ label: "Log in interactively via browser or by pasting a token",
23
+ command: "dx auth login",
24
+ },
25
+ {
26
+ label: "Log in non-interactively with a token",
27
+ command: "dx auth login --token <token>",
28
+ },
29
+ {
30
+ label: "Log in to a managed deployment with custom base URLs",
31
+ command: "DX_API_BASE_URL=https://api.dx.example.com DX_WEB_BASE_URL=https://dx.example.com dx auth login --token <token>",
32
+ },
33
+ ]))
20
34
  .action(wrapAction(async (commandOptions, command) => {
21
35
  const context = getContext(command);
22
36
  const { apiBaseUrl, webBaseUrl } = deriveBaseUrlsFromEnv();
@@ -63,7 +77,15 @@ export function authCommand() {
63
77
  }
64
78
  renderAuthInfo(response, token, apiBaseUrl);
65
79
  }));
66
- auth.command("logout").action(wrapAction(async (_options, command) => {
80
+ auth
81
+ .command("logout")
82
+ .addHelpText("afterAll", createExampleText([
83
+ {
84
+ label: "Log out and remove the stored token",
85
+ command: "dx auth logout",
86
+ },
87
+ ]))
88
+ .action(wrapAction(async (_options, command) => {
67
89
  const context = getContext(command);
68
90
  const apiBaseUrl = resolveApiBaseUrl();
69
91
  deleteToken(apiBaseUrl);
@@ -77,6 +99,20 @@ export function authCommand() {
77
99
  auth
78
100
  .command("status")
79
101
  .alias("info")
102
+ .addHelpText("afterAll", createExampleText([
103
+ {
104
+ label: "Show the currently authenticated user and token details",
105
+ command: "dx auth status",
106
+ },
107
+ {
108
+ label: "Return auth details as JSON",
109
+ command: "dx auth status --json",
110
+ },
111
+ {
112
+ label: "Pipe auth details into `jq` and list scopes",
113
+ command: "dx auth status --json | jq --raw-output '.auth.scopes[]'",
114
+ },
115
+ ]))
80
116
  .action(wrapAction(async (_options, command) => {
81
117
  const runtime = buildRuntime(getContext(command));
82
118
  const response = await getAuthInfo(runtime);
@@ -497,7 +497,7 @@ function parseEntityAliases(entityTypeIdentifier, rawAliases, knownAliases) {
497
497
  result[key] = [];
498
498
  continue;
499
499
  }
500
- result[key] = [...(result[key] ?? []), { identifier: rawValue }];
500
+ result[key] = [...(result[key] ?? []), { lookup: rawValue }];
501
501
  }
502
502
  return result;
503
503
  }
@@ -8,34 +8,35 @@ import { buildLogger, buildRuntime, buildRuntimeSafe } from "../runtime.js";
8
8
  import { getAuthInfo } from "./auth.js";
9
9
  import { renderAuthInfo } from "./authRendering.js";
10
10
  import { loginViaBrowser } from "../loginViaBrowser.js";
11
- import { getContext } from "../commandHelpers.js";
12
- import { wrapAction } from "../commandHelpers.js";
11
+ import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
13
12
  import { renderRichText } from "../renderers.js";
14
13
  import * as ui from "../ui.js";
15
14
  import { CliError } from "../errors.js";
16
15
  import { deriveBaseUrlsFromEnv, persistBaseUrls } from "../config.js";
17
16
  import { setToken } from "../secrets.js";
18
- // FIXME: make this WAY more glam
19
- // Choose a more interesting ASCII art font from https://patorjk.com/software/taag/
20
- const WELCOME_BANNER = ui.indent(`\
21
- ▄▄▄▄▄ ▄▄▄ ▄▄▄
22
- ██▀▀▀██ ██▄▄██
23
- ██ ██ ████
24
- ██ ██ ██
25
- ██ ██ ████
26
- ██▄▄▄██ ██ ██
27
- ▀▀▀▀▀ ▀▀▀ ▀▀▀
28
- `, 2);
17
+ import { renderWelcomeAnimation } from "../welcomeAnimation.js";
29
18
  export function initCommand() {
30
19
  const init = new Command()
31
20
  .name("init")
32
21
  .description("Initialize the DX CLI")
22
+ .addHelpText("afterAll", createExampleText([
23
+ {
24
+ label: "Initialize the CLI",
25
+ command: "dx init",
26
+ },
27
+ {
28
+ label: "Initialize the CLI for a managed deployment with custom base URLs",
29
+ command: "DX_API_BASE_URL=https://api.dx.example.com DX_WEB_BASE_URL=https://dx.example.com dx init",
30
+ },
31
+ ]))
33
32
  .action(wrapAction(async (commandOptions, command) => {
34
33
  const context = getContext(command);
35
34
  let runtime = buildRuntimeSafe(context);
36
35
  ensureInteractive();
37
- showWelcomeBanner();
36
+ await renderWelcomeAnimation();
38
37
  runtime = await ensureLoggedIn(context, runtime);
38
+ // Sleep for a bit to help with visual chunking
39
+ await sleep(500);
39
40
  await optionallySetupSkill(runtime);
40
41
  renderRichText([
41
42
  ui.blankLine(),
@@ -49,9 +50,6 @@ function ensureInteractive() {
49
50
  throw new CliError("`dx init` must be run interactively");
50
51
  }
51
52
  }
52
- function showWelcomeBanner() {
53
- renderRichText([ui.p(ui.success(WELCOME_BANNER))]);
54
- }
55
53
  async function ensureLoggedIn(context, runtime) {
56
54
  renderRichText([ui.h1(`Checking if you are logged in...`), ui.blankLine()]);
57
55
  if (runtime) {
@@ -155,3 +153,6 @@ async function optionallySetupSkill(runtime) {
155
153
  throw new CliError(`Failed to setup the DX skill: ${result.stderr}`);
156
154
  }
157
155
  }
156
+ async function sleep(ms) {
157
+ return new Promise((resolve) => setTimeout(resolve, ms));
158
+ }
@@ -109,7 +109,7 @@ async function getTeamInfo(runtime, lookup) {
109
109
  });
110
110
  return response.body;
111
111
  }
112
- async function listTeams(runtime) {
112
+ export async function listTeams(runtime) {
113
113
  const response = await request(runtime, "/teams.list", {
114
114
  method: "GET",
115
115
  });
@@ -0,0 +1,46 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
3
+ import { buildRuntime } from "../../runtime.js";
4
+ import { renderJson, renderRichText } from "../../renderers.js";
5
+ import * as ui from "../../ui.js";
6
+ import { request } from "../../http.js";
7
+ export function addLinkCommand() {
8
+ return new Command()
9
+ .name("addLink")
10
+ .description("Add a link to a workflow run")
11
+ .argument("<workflow-run-id>", "The ID of the workflow run")
12
+ .requiredOption("--url <url>", "The URL of the link")
13
+ .requiredOption("--label <label>", "The label of the link")
14
+ .addHelpText("afterAll", createExampleText([
15
+ {
16
+ label: "Add a link to a workflow run",
17
+ command: 'dx workflowRuns addLink hvserjgz5lo7 --url https://www.example.com --label "Example Website"',
18
+ },
19
+ ]))
20
+ .action(wrapAction(async (workflowRunId, options, command) => {
21
+ const context = getContext(command);
22
+ const runtime = buildRuntime(context);
23
+ const response = await addLink(runtime, workflowRunId, options.url, options.label);
24
+ if (runtime.context.json) {
25
+ renderJson(response);
26
+ }
27
+ else {
28
+ renderRichText([
29
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Added link to workflow run ${ui.code(workflowRunId)}.`),
30
+ ui.p(`Web link: ${ui.link(ui.webLink(`/self-service/workflow-runs/${workflowRunId}`, runtime))}`),
31
+ ]);
32
+ }
33
+ }));
34
+ }
35
+ async function addLink(runtime, workflowRunId, url, label) {
36
+ return request(runtime, "/workflowRuns.addLink", {
37
+ method: "POST",
38
+ body: {
39
+ workflow_run_id: workflowRunId,
40
+ link: {
41
+ url,
42
+ label,
43
+ },
44
+ },
45
+ });
46
+ }
@@ -0,0 +1,53 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
3
+ import { buildRuntime } from "../../runtime.js";
4
+ import { renderJson, renderRichText } from "../../renderers.js";
5
+ import * as ui from "../../ui.js";
6
+ import { request } from "../../http.js";
7
+ import { CliError, EXIT_CODES } from "../../errors.js";
8
+ export function changeStatusCommand() {
9
+ return new Command()
10
+ .name("changeStatus")
11
+ .description("Change the status of a workflow run")
12
+ .argument("<workflow-run-id>", "The ID of the workflow run")
13
+ .requiredOption("--status <updated-status>", "The status to change to")
14
+ .addHelpText("afterAll", createExampleText([
15
+ {
16
+ label: "Mark a workflow run as succeeded",
17
+ command: "dx workflowRuns changeStatus hvserjgz5lo7 --status SUCCEEDED",
18
+ },
19
+ {
20
+ label: "Mark a workflow run as failed",
21
+ command: "dx workflowRuns changeStatus hvserjgz5lo7 --status FAILED",
22
+ },
23
+ ]))
24
+ .action(wrapAction(async (workflowRunId, options, command) => {
25
+ const context = getContext(command);
26
+ const runtime = buildRuntime(context);
27
+ if (!isValidStatus(options.status)) {
28
+ throw new CliError(`Invalid status: ${options.status}. Must be one of: SUCCEEDED, FAILED.`, EXIT_CODES.ARGUMENT_ERROR);
29
+ }
30
+ const response = await changeStatus(runtime, workflowRunId, options.status);
31
+ if (runtime.context.json) {
32
+ renderJson(response);
33
+ }
34
+ else {
35
+ renderRichText([
36
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Changed status of workflow run ${ui.code(workflowRunId)} to ${ui.code(options.status)}.`),
37
+ ui.p(`Web link: ${ui.link(ui.webLink(`/self-service/workflow-runs/${workflowRunId}`, runtime))}`),
38
+ ]);
39
+ }
40
+ }));
41
+ }
42
+ function isValidStatus(updatedStatus) {
43
+ return updatedStatus === "SUCCEEDED" || updatedStatus === "FAILED";
44
+ }
45
+ async function changeStatus(runtime, workflowRunId, status) {
46
+ return request(runtime, "/workflowRuns.changeStatus", {
47
+ method: "POST",
48
+ body: {
49
+ workflow_run_id: workflowRunId,
50
+ status,
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,58 @@
1
+ import * as ui from "../../ui.js";
2
+ export function workflowRunEventContent(event) {
3
+ const ts = ui.dim(event.occurred_at);
4
+ const message = formatEventMessage(event);
5
+ return ui.p(`${ts} ${message}`, false);
6
+ }
7
+ function formatEventMessage(event) {
8
+ const userName = event.user?.name ?? event.user?.email ?? "API";
9
+ switch (event.type) {
10
+ case "WORKFLOW_TRIGGERED":
11
+ return `Workflow run triggered by ${ui.bold(userName)}`;
12
+ case "WORKFLOW_RUN_REQUESTED":
13
+ return `Workflow run requested by ${ui.bold(userName)}`;
14
+ case "APPROVERS_NOTIFIED":
15
+ return "Approvers notified";
16
+ case "APPROVAL_PERFORMED":
17
+ return `Approved by ${ui.bold(userName)}`;
18
+ case "REJECTION_PERFORMED":
19
+ return `Rejected by ${ui.bold(userName)}`;
20
+ case "HTTP_REQUEST_COMPLETED": {
21
+ const method = event.data?.request?.method?.toUpperCase();
22
+ const url = event.data?.request?.url;
23
+ const status = event.data?.response?.status;
24
+ const reqSummary = method && url ? ` ${ui.code(`${method} ${url}`)}` : "";
25
+ const statusSummary = status ? ` → ${ui.code(String(status))}` : "";
26
+ return `Sent HTTP request${reqSummary}${statusSummary}`;
27
+ }
28
+ case "POST_MESSAGE": {
29
+ const text = event.message ?? "";
30
+ const truncated = text.length > 120 ? text.slice(0, 120) + "…" : text;
31
+ return truncated;
32
+ }
33
+ case "ADD_LINK": {
34
+ const link = event.data?.link;
35
+ if (link) {
36
+ const label = link.label ? `${link.label}: ` : "";
37
+ return `Added link ${label}${ui.link(link.url)}`;
38
+ }
39
+ return "Added a link";
40
+ }
41
+ case "CHANGE_STATUS": {
42
+ const status = event.data?.status;
43
+ return status
44
+ ? `Status changed to ${ui.bold(status.toLowerCase())}`
45
+ : "Status changed";
46
+ }
47
+ case "WORKFLOW_SUCCEEDED":
48
+ return `${ui.success(ui.GLYPHS.CHECK)} Workflow run succeeded`;
49
+ case "WORKFLOW_FAILED":
50
+ return `${ui.error(ui.GLYPHS.ERROR)} Workflow run failed`;
51
+ case "WORKFLOW_TIMEOUT":
52
+ return `${ui.error(ui.GLYPHS.ERROR)} Workflow run timed out`;
53
+ case "WORKFLOW_CANCELLED":
54
+ return `${ui.warning(ui.GLYPHS.WARNING)} Workflow run cancelled by ${ui.bold(userName)}`;
55
+ default:
56
+ return `Event: ${event.type}`;
57
+ }
58
+ }
@@ -0,0 +1,82 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
3
+ import { buildRuntime } from "../../runtime.js";
4
+ import { renderJson, renderRichText } from "../../renderers.js";
5
+ import * as ui from "../../ui.js";
6
+ import { request } from "../../http.js";
7
+ import { workflowRunEventContent } from "./events.js";
8
+ export function infoCommand() {
9
+ return new Command()
10
+ .name("info")
11
+ .description("Get info for a workflow run")
12
+ .argument("<workflow-run-id>", "The ID of the workflow run")
13
+ .addHelpText("afterAll", createExampleText([
14
+ {
15
+ label: "Get info for a workflow run",
16
+ command: "dx workflowRuns info hvserjgz5lo7",
17
+ },
18
+ ]))
19
+ .action(wrapAction(async (workflowRunId, options, command) => {
20
+ const context = getContext(command);
21
+ const runtime = buildRuntime(context);
22
+ const infoResponse = await getWorkflowRun(runtime, workflowRunId);
23
+ const workflowRun = infoResponse.body.workflow_run;
24
+ if (runtime.context.json) {
25
+ renderJson({ ok: true, workflow_run: workflowRun });
26
+ }
27
+ else {
28
+ renderWorkflowRunInfo(workflowRun, runtime);
29
+ }
30
+ }));
31
+ }
32
+ function renderWorkflowRunInfo(run, runtime) {
33
+ renderRichText([
34
+ ui.h2("Workflow run"),
35
+ ui.h3("Summary"),
36
+ ...summaryContent(run, runtime),
37
+ ui.h3("Events"),
38
+ ...eventsContent(run),
39
+ ]);
40
+ }
41
+ export function renderWorkflowRunSummary(run, runtime) {
42
+ renderRichText([ui.h3("Workflow run"), ...summaryContent(run, runtime)]);
43
+ }
44
+ function summaryContent(run, runtime) {
45
+ const items = [
46
+ ui.dli("Run ID", ui.code(run.id)),
47
+ ui.dli("Status", run.status),
48
+ ];
49
+ if (run.workflow) {
50
+ items.push(ui.dli("Workflow", `${run.workflow.name} (${ui.code(run.workflow.identifier)})`));
51
+ items.push(ui.dli("Web link", ui.link(ui.webLink(`/self-service/workflow-runs/${run.id}`, runtime))));
52
+ }
53
+ if (run.entity) {
54
+ items.push(ui.dli("Entity", `${run.entity.name} (${run.entity.identifier})`));
55
+ }
56
+ if (run.started_at) {
57
+ items.push(ui.dli("Started", ui.timestampSummary(run.started_at)));
58
+ }
59
+ if (run.started_at && run.completed_at) {
60
+ items.push(ui.dli("Completed", ui.timestampSummary(run.completed_at)));
61
+ items.push(ui.dli("Duration", ui.durationSummary(run.started_at, run.completed_at)));
62
+ }
63
+ if (run.links?.length) {
64
+ const linkSummaries = run.links.map((l) => ui.p(l.label ? `${ui.bold(l.label)} ${ui.link(l.url)}` : ui.link(l.url), false));
65
+ items.push(ui.dli("Links", linkSummaries));
66
+ }
67
+ return [ui.dl(items, { termWidth: 14 })];
68
+ }
69
+ function eventsContent(run) {
70
+ const items = [];
71
+ for (const event of run.events ?? []) {
72
+ items.push(workflowRunEventContent(event));
73
+ }
74
+ items.push(ui.blankLine());
75
+ return items;
76
+ }
77
+ export async function getWorkflowRun(runtime, id) {
78
+ return request(runtime, "/workflowRuns.info", {
79
+ method: "GET",
80
+ query: { id },
81
+ });
82
+ }
@@ -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,46 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
3
+ import { buildRuntime } from "../../runtime.js";
4
+ import { renderJson, renderRichText } from "../../renderers.js";
5
+ import * as ui from "../../ui.js";
6
+ import { request } from "../../http.js";
7
+ export function postMessageCommand() {
8
+ return new Command()
9
+ .name("postMessage")
10
+ .description("Post a message to a workflow run")
11
+ .argument("<workflow-run-id>", "The ID of the workflow run")
12
+ .requiredOption("--message <message>", "The message to post")
13
+ .addHelpText("afterAll", createExampleText([
14
+ {
15
+ label: "Post a message to a workflow run",
16
+ command: 'dx workflowRuns postMessage hvserjgz5lo7 --message "Hello from the CLI"',
17
+ },
18
+ {
19
+ label: "Post the contents of a markdown file to a workflow run",
20
+ command: 'dx workflowRuns postMessage hvserjgz5lo7 --message "$(cat message.md)"',
21
+ },
22
+ ]))
23
+ .action(wrapAction(async (workflowRunId, options, command) => {
24
+ const context = getContext(command);
25
+ const runtime = buildRuntime(context);
26
+ const response = await postMessage(runtime, workflowRunId, options.message);
27
+ if (runtime.context.json) {
28
+ renderJson(response);
29
+ }
30
+ else {
31
+ renderRichText([
32
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Posted message to workflow run ${ui.code(workflowRunId)}.`),
33
+ ui.p(`Web link: ${ui.link(ui.webLink(`/self-service/workflow-runs/${workflowRunId}`, runtime))}`),
34
+ ]);
35
+ }
36
+ }));
37
+ }
38
+ async function postMessage(runtime, workflowRunId, message) {
39
+ return request(runtime, "/workflowRuns.postMessage", {
40
+ method: "POST",
41
+ body: {
42
+ workflow_run_id: workflowRunId,
43
+ message,
44
+ },
45
+ });
46
+ }
@@ -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
+ }
@@ -0,0 +1,17 @@
1
+ import { Command } from "commander";
2
+ import { addLinkCommand } from "./workflowRuns/addLink.js";
3
+ import { changeStatusCommand } from "./workflowRuns/changeStatus.js";
4
+ import { infoCommand } from "./workflowRuns/info.js";
5
+ import { postMessageCommand } from "./workflowRuns/postMessage.js";
6
+ import { triggerCommand } from "./workflowRuns/trigger.js";
7
+ export function workflowRunsCommand() {
8
+ const workflowRuns = new Command()
9
+ .name("workflowRuns")
10
+ .description("Trigger and monitor Self-service workflow runs");
11
+ workflowRuns.addCommand(addLinkCommand());
12
+ workflowRuns.addCommand(changeStatusCommand());
13
+ workflowRuns.addCommand(infoCommand());
14
+ workflowRuns.addCommand(postMessageCommand());
15
+ workflowRuns.addCommand(triggerCommand());
16
+ return workflowRuns;
17
+ }
@@ -67,7 +67,7 @@ function parseOptionalScope(value) {
67
67
  return raw;
68
68
  }
69
69
  // --- API ---
70
- async function listWorkflows(runtime, params) {
70
+ export async function listWorkflows(runtime, params) {
71
71
  const query = {};
72
72
  if (params.scope !== undefined)
73
73
  query.scope = params.scope;
package/dist/ui/blocks.js CHANGED
@@ -73,10 +73,12 @@ export class DescriptionListItemContainer extends AbstractBlock {
73
73
  }
74
74
  render() {
75
75
  if (!this.options) {
76
- throw new Error("Options are not set");
76
+ throw new Error("DescriptionListItemContainer: Options are not set");
77
77
  }
78
- return (pc.bold(padEnd(`${this.term}:`, this.options.termWidth)) +
79
- this.detailItems.map((item) => item.render()).join("\n"));
78
+ const termPart = pc.bold(padEnd(`${this.term}:`, this.options.termWidth));
79
+ const detailText = this.detailItems.map((item) => item.render()).join("\n");
80
+ const padding = " ".repeat(this.options.termWidth);
81
+ return termPart + detailText.replace(/\n/g, `\n${padding}`);
80
82
  }
81
83
  setOptions(options) {
82
84
  this.options = options;
package/dist/ui.js CHANGED
@@ -105,6 +105,16 @@ export function timestampSummary(timestamp) {
105
105
  const ts = dayjs(timestamp);
106
106
  return `${ts.fromNow()} ${dim(`(${timestamp})`)}`;
107
107
  }
108
+ export function durationSummary(start, end) {
109
+ const pluralizeWithCount = (word, n) => `${n} ${word}${n === 1 ? "" : "s"}`;
110
+ const duration = dayjs(end).diff(dayjs(start), "seconds");
111
+ if (duration < 60) {
112
+ return pluralizeWithCount("second", duration);
113
+ }
114
+ const minutes = Math.floor(duration / 60);
115
+ const seconds = duration % 60;
116
+ return `${pluralizeWithCount("minute", minutes)}, ${pluralizeWithCount("second", seconds)}`;
117
+ }
108
118
  // Other helpers
109
119
  export function indent(text, level) {
110
120
  return text
@@ -0,0 +1,115 @@
1
+ import { getAssets } from "./assets.js";
2
+ const FRAMES = 20;
3
+ const FRAME_DELAY_MS = 100;
4
+ const COLOR_START = [20, 20, 20];
5
+ const COLOR_HALFWAY = [95, 89, 237]; // Indigo
6
+ const COLOR_END = [255, 255, 255];
7
+ const COLOR_BACKGROUND = [0, 0, 0];
8
+ const SEQUENCE_CLEAR_SCREEN = "\x1b[2J\x1b[3J\x1b[H";
9
+ const SEQUENCE_CURSOR_HOME = "\x1b[H";
10
+ export async function renderWelcomeAnimation() {
11
+ if (process.env.DX_DISABLE_WELCOME_ANIMATION) {
12
+ return;
13
+ }
14
+ const assets = getAssets();
15
+ const LOGO_D_CONTENT = assets["logoD"];
16
+ const LOGO_X_CONTENT = assets["logoX"];
17
+ const canvasColumns = process.stdout.columns;
18
+ process.stderr.write(SEQUENCE_CLEAR_SCREEN);
19
+ await sleep(100);
20
+ const compositeWidth = getMaxColumns(LOGO_X_CONTENT);
21
+ const centeredLogoD = center(LOGO_D_CONTENT, Math.min(canvasColumns, 80), compositeWidth);
22
+ const centeredLogoX = center(LOGO_X_CONTENT, Math.min(canvasColumns, 80), compositeWidth);
23
+ for (let frame = 0; frame < FRAMES; frame += 1) {
24
+ const progress = frame / (FRAMES - 1);
25
+ const progressD = clamp(progress * 2, 0, 1);
26
+ const progressX = clamp((progress - 0.5) * 2, 0, 1);
27
+ const colorD = getThreeStageColor(progressD, COLOR_START, COLOR_HALFWAY, COLOR_END);
28
+ const colorX = getThreeStageColor(progressX, COLOR_START, COLOR_HALFWAY, COLOR_END);
29
+ const showX = progress >= 0.5;
30
+ process.stderr.write(SEQUENCE_CURSOR_HOME +
31
+ "\n\n" +
32
+ colorizeComposite(centeredLogoD, centeredLogoX, colorD, colorX, COLOR_BACKGROUND, showX) +
33
+ "\n\n\n");
34
+ await sleep(FRAME_DELAY_MS);
35
+ }
36
+ }
37
+ function center(text, canvasWidth, anchorWidth = getMaxColumns(text)) {
38
+ const maxTextWidth = Math.max(...text.split("\n").map((row) => row.length));
39
+ const safeCanvasWidth = Math.max(canvasWidth, anchorWidth);
40
+ const leftPaddingSize = Math.max(0, Math.floor((safeCanvasWidth - anchorWidth) / 2));
41
+ const lines = text.split("\n");
42
+ const centeredLines = [];
43
+ for (const line of lines) {
44
+ const leftPaddedLine = " ".repeat(leftPaddingSize) + line.padEnd(maxTextWidth);
45
+ const rightPadding = " ".repeat(Math.max(0, safeCanvasWidth - leftPaddedLine.length));
46
+ centeredLines.push(leftPaddedLine + rightPadding);
47
+ }
48
+ return centeredLines.join("\n") + "\n";
49
+ }
50
+ function colorizeComposite(textD, textX, colorD, colorX, colorBackground, showX) {
51
+ const linesD = textD.split("\n");
52
+ const linesX = textX.split("\n");
53
+ const rowCount = Math.max(linesD.length, linesX.length);
54
+ const [br, bg, bb] = colorBackground;
55
+ const background = `\x1b[48;2;${br};${bg};${bb}m`;
56
+ const reset = "\x1b[0m";
57
+ const outputLines = [];
58
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
59
+ const rowD = linesD[rowIndex] ?? "";
60
+ const rowX = linesX[rowIndex] ?? "";
61
+ const columnCount = Math.max(rowD.length, rowX.length);
62
+ let currentColor = null;
63
+ let rowOutput = background;
64
+ for (let col = 0; col < columnCount; col += 1) {
65
+ const charD = rowD[col] ?? " ";
66
+ const charX = showX ? (rowX[col] ?? " ") : " ";
67
+ const useX = charX !== " ";
68
+ const useD = !useX && charD !== " ";
69
+ const nextColor = useX ? colorX : useD ? colorD : null;
70
+ const char = useX ? charX : useD ? charD : " ";
71
+ if (nextColor === null) {
72
+ if (currentColor !== null) {
73
+ rowOutput += background;
74
+ currentColor = null;
75
+ }
76
+ }
77
+ else if (currentColor === null ||
78
+ currentColor[0] !== nextColor[0] ||
79
+ currentColor[1] !== nextColor[1] ||
80
+ currentColor[2] !== nextColor[2]) {
81
+ rowOutput += colorToAnsi(nextColor);
82
+ currentColor = nextColor;
83
+ }
84
+ rowOutput += char;
85
+ }
86
+ rowOutput += reset;
87
+ outputLines.push(rowOutput);
88
+ }
89
+ return outputLines.join("\n");
90
+ }
91
+ function colorToAnsi(colorForeground) {
92
+ const [fr, fg, fb] = colorForeground;
93
+ return `\x1b[38;2;${fr};${fg};${fb}m`;
94
+ }
95
+ function getMaxColumns(...texts) {
96
+ return Math.max(...texts.map((text) => Math.max(...text.split("\n").map((row) => row.length))));
97
+ }
98
+ function clamp(value, min, max) {
99
+ return Math.min(max, Math.max(min, value));
100
+ }
101
+ function sleep(ms) {
102
+ return new Promise((resolve) => setTimeout(resolve, ms));
103
+ }
104
+ function tweenLinear(start, end, percent) {
105
+ return start + (end - start) * percent;
106
+ }
107
+ function tweenColor(colorStart, colorEnd, percent) {
108
+ return colorStart.map((start, index) => Math.round(tweenLinear(start, colorEnd[index], percent)));
109
+ }
110
+ function getThreeStageColor(progress, colorStart, colorHalfway, colorEnd) {
111
+ if (progress <= 0.5) {
112
+ return tweenColor(colorStart, colorHalfway, progress * 2);
113
+ }
114
+ return tweenColor(colorHalfway, colorEnd, (progress - 0.5) * 2);
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {