@get-dx/cli 0.3.1 → 0.3.3

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
@@ -18,6 +18,13 @@ Interactively login and install the AI agent skill:
18
18
  dx init
19
19
  ```
20
20
 
21
+ For CI, Docker/dev containers, or other headless environments, pass the token
22
+ through the environment instead of storing it in the OS credential store:
23
+
24
+ ```shell
25
+ DX_API_TOKEN="$DX_TOKEN" dx auth status
26
+ ```
27
+
21
28
  Show help text:
22
29
 
23
30
  ```shell
@@ -42,8 +49,8 @@ Set the env vars once when initializing:
42
49
  # Interactive login
43
50
  DX_WEB_BASE_URL="https://mycompany.getdx.io" DX_API_BASE_URL="https://api.mycompany.getdx.io" dx init
44
51
 
45
- # Non-interactive login for CI or remote agents
46
- DX_WEB_BASE_URL="https://mycompany.getdx.io" DX_API_BASE_URL="https://api.mycompany.getdx.io" dx auth login --token $DX_TOKEN
52
+ # Non-interactive use for CI, containers, or remote agents
53
+ DX_WEB_BASE_URL="https://mycompany.getdx.io" DX_API_BASE_URL="https://api.mycompany.getdx.io" DX_API_TOKEN="$DX_TOKEN" dx auth status
47
54
  ```
48
55
 
49
56
  #### For managed deployments
@@ -54,8 +61,8 @@ Set the env vars once when initializing:
54
61
  # Interactive login
55
62
  DX_WEB_BASE_URL="https://dx.some-example-subdomain.example.com" DX_API_BASE_URL="https://api.dx.some-example-subdomain.example.com" dx init
56
63
 
57
- # Non-interactive login for CI or remote agents
58
- DX_WEB_BASE_URL="https://dx.some-example-subdomain.example.com" DX_API_BASE_URL="https://api.dx.some-example-subdomain.example.com" dx auth login --token $DX_TOKEN
64
+ # Non-interactive use for CI, containers, or remote agents
65
+ DX_WEB_BASE_URL="https://dx.some-example-subdomain.example.com" DX_API_BASE_URL="https://api.dx.some-example-subdomain.example.com" DX_API_TOKEN="$DX_TOKEN" dx auth status
59
66
  ```
60
67
 
61
68
  ## Logging
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
+ }
@@ -3,6 +3,7 @@ import { CliError } from "./errors.js";
3
3
  export class AuthCodeCallbackServer {
4
4
  server = null;
5
5
  address = null;
6
+ requestReceived = false;
6
7
  async start() {
7
8
  await new Promise((resolve, reject) => {
8
9
  const srv = createServer();
@@ -23,19 +24,34 @@ export class AuthCodeCallbackServer {
23
24
  }
24
25
  async listenForCode(onCodeReceipt) {
25
26
  return new Promise((resolve, reject) => {
26
- if (!this.server) {
27
+ const srv = this.server;
28
+ if (!srv) {
27
29
  reject(new Error("Unreachable: server not started"));
28
30
  return;
29
31
  }
30
- this.server.on("request", async (req, res) => {
31
- this.stop();
32
+ srv.on("request", async (req, res) => {
33
+ if (this.requestReceived) {
34
+ res.writeHead(409, { Connection: "close" }).end();
35
+ return;
36
+ }
37
+ this.requestReceived = true;
32
38
  const url = new URL(req.url ?? "/", this.address);
33
39
  const code = url.searchParams.get("code");
34
40
  const state = url.searchParams.get("state");
41
+ const stopServerAndResolve = (result) => {
42
+ res.once("close", () => {
43
+ this.stop();
44
+ resolve(result);
45
+ });
46
+ };
35
47
  if (!code) {
36
48
  const message = "Authentication failed: no code received";
37
- res.writeHead(500, { "Content-Type": "text/plain" }).end(message);
38
- resolve({ type: "ERROR", error: new CliError(message) });
49
+ res.writeHead(500, {
50
+ Connection: "close",
51
+ "Content-Type": "text/plain",
52
+ });
53
+ stopServerAndResolve({ type: "ERROR", error: new CliError(message) });
54
+ res.end(message);
39
55
  return;
40
56
  }
41
57
  let result;
@@ -46,34 +62,52 @@ export class AuthCodeCallbackServer {
46
62
  const message = err instanceof Error
47
63
  ? err.message
48
64
  : "Authentication failed: unknown error";
49
- res.writeHead(500, { "Content-Type": "text/plain" }).end(message);
50
- resolve({
65
+ res.writeHead(500, {
66
+ Connection: "close",
67
+ "Content-Type": "text/plain",
68
+ });
69
+ stopServerAndResolve({
51
70
  type: "ERROR",
52
71
  error: err instanceof Error ? err : new CliError(message),
53
72
  });
73
+ res.end(message);
54
74
  return;
55
75
  }
56
76
  if (result.type === "SUCCESS") {
57
77
  if (result.redirectUri) {
58
- res.writeHead(302, { Location: result.redirectUri }).end();
78
+ res.writeHead(302, {
79
+ Connection: "close",
80
+ Location: result.redirectUri,
81
+ });
82
+ stopServerAndResolve(result);
83
+ res.end();
59
84
  }
60
85
  else {
61
- res
62
- .writeHead(200, { "Content-Type": "text/plain" })
63
- .end("Authentication successful");
86
+ res.writeHead(200, {
87
+ Connection: "close",
88
+ "Content-Type": "text/plain",
89
+ });
90
+ stopServerAndResolve(result);
91
+ res.end("Authentication successful");
64
92
  }
65
93
  }
66
94
  else {
67
- res
68
- .writeHead(500, { "Content-Type": "text/plain" })
69
- .end(result.error.message);
95
+ res.writeHead(500, {
96
+ Connection: "close",
97
+ "Content-Type": "text/plain",
98
+ });
99
+ stopServerAndResolve(result);
100
+ res.end(result.error.message);
70
101
  }
71
- resolve(result);
72
102
  });
73
103
  });
74
104
  }
75
105
  stop() {
76
- this.server?.close();
106
+ const srv = this.server;
107
+ if (!srv)
108
+ return;
109
+ srv.closeAllConnections();
110
+ srv.close();
77
111
  this.server = null;
78
112
  }
79
113
  }
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,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,15 @@
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
+ export function workflowRunsCommand() {
7
+ const workflowRuns = new Command()
8
+ .name("workflowRuns")
9
+ .description("Trigger and monitor Self-service workflow runs");
10
+ workflowRuns.addCommand(addLinkCommand());
11
+ workflowRuns.addCommand(changeStatusCommand());
12
+ workflowRuns.addCommand(infoCommand());
13
+ workflowRuns.addCommand(postMessageCommand());
14
+ return workflowRuns;
15
+ }
@@ -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/secrets.js CHANGED
@@ -40,16 +40,28 @@ function macosSecretStore() {
40
40
  }
41
41
  },
42
42
  set(baseUrl, token) {
43
- execaSync("security", [
44
- "add-generic-password",
45
- "-U",
46
- "-s",
47
- SERVICE,
48
- "-a",
49
- baseUrl,
50
- "-w",
51
- token,
52
- ]);
43
+ try {
44
+ execaSync("security", [
45
+ "add-generic-password",
46
+ "-U",
47
+ "-s",
48
+ SERVICE,
49
+ "-a",
50
+ baseUrl,
51
+ "-w",
52
+ token,
53
+ ]);
54
+ }
55
+ catch (error) {
56
+ throw new CliError([
57
+ "Unable to save the DX API token securely using macOS Keychain.",
58
+ "",
59
+ "Set DX_API_TOKEN in the environment, or try again from a macOS session with Keychain access.",
60
+ formatErrorDetails(error),
61
+ ]
62
+ .filter(Boolean)
63
+ .join("\n"));
64
+ }
53
65
  },
54
66
  delete(baseUrl) {
55
67
  try {
@@ -84,10 +96,78 @@ function linuxSecretStore() {
84
96
  }
85
97
  },
86
98
  set(baseUrl, token) {
87
- execaSync("secret-tool", ["store", "--label=dx-cli", "service", SERVICE, "account", baseUrl], { input: token });
99
+ try {
100
+ execaSync("secret-tool", ["store", "--label=dx-cli", "service", SERVICE, "account", baseUrl], { input: token });
101
+ }
102
+ catch (error) {
103
+ throw buildLinuxTokenStorageError(error);
104
+ }
88
105
  },
89
106
  delete(_baseUrl) {
90
107
  throw new CliError("Linux token deletion is not supported by secret-tool in this implementation");
91
108
  },
92
109
  };
93
110
  }
111
+ function buildLinuxTokenStorageError(error) {
112
+ if (hasErrorCode(error, "ENOENT")) {
113
+ return new CliError([
114
+ "Unable to save the DX API token securely because `secret-tool` is not installed.",
115
+ "",
116
+ "Install `libsecret-tools`, or set DX_API_TOKEN in the environment instead of using `dx auth login` or `dx init` to store credentials.",
117
+ ].join("\n"));
118
+ }
119
+ if (isLinuxSecretServiceUnavailable(error)) {
120
+ return new CliError([
121
+ "Unable to save the DX API token securely because the Linux Secret Service/keyring is not available.",
122
+ "",
123
+ "This often happens in Docker or dev containers, even when `secret-tool` is installed. Set DX_API_TOKEN in the container environment instead of using `dx auth login` or `dx init` to store credentials, or run the CLI in an environment with a working Secret Service provider such as GNOME Keyring.",
124
+ formatErrorDetails(error),
125
+ ]
126
+ .filter(Boolean)
127
+ .join("\n"));
128
+ }
129
+ return new CliError([
130
+ "Unable to save the DX API token securely using `secret-tool`.",
131
+ "",
132
+ "Set DX_API_TOKEN in the environment, or configure a working Linux Secret Service provider such as GNOME Keyring.",
133
+ formatErrorDetails(error),
134
+ ]
135
+ .filter(Boolean)
136
+ .join("\n"));
137
+ }
138
+ function hasErrorCode(error, code) {
139
+ if (!isRecord(error)) {
140
+ return false;
141
+ }
142
+ return error.code === code;
143
+ }
144
+ function isLinuxSecretServiceUnavailable(error) {
145
+ const text = errorText(error);
146
+ return [
147
+ "org.freedesktop.secrets",
148
+ "Cannot autolaunch D-Bus",
149
+ "No such secret service",
150
+ ].some((message) => text.includes(message));
151
+ }
152
+ function formatErrorDetails(error) {
153
+ if (!isRecord(error) || typeof error.stderr !== "string") {
154
+ return null;
155
+ }
156
+ const stderr = error.stderr.trim();
157
+ return stderr ? `Details: ${stderr}` : null;
158
+ }
159
+ function errorText(error) {
160
+ if (!isRecord(error)) {
161
+ return String(error);
162
+ }
163
+ return [
164
+ typeof error.stderr === "string" ? error.stderr : null,
165
+ typeof error.shortMessage === "string" ? error.shortMessage : null,
166
+ typeof error.message === "string" ? error.message : null,
167
+ ]
168
+ .filter(Boolean)
169
+ .join("\n");
170
+ }
171
+ function isRecord(value) {
172
+ return typeof value === "object" && value !== null;
173
+ }
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.1",
3
+ "version": "0.3.3",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {