@get-dx/cli 0.1.7 → 0.2.1

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,79 @@
1
+ import { createServer } from "node:http";
2
+ import { CliError } from "./errors.js";
3
+ export class AuthCodeCallbackServer {
4
+ server = null;
5
+ address = null;
6
+ async start() {
7
+ await new Promise((resolve, reject) => {
8
+ const srv = createServer();
9
+ srv.on("error", reject);
10
+ srv.listen(0, "127.0.0.1", () => {
11
+ this.server = srv;
12
+ const { address, port } = srv.address();
13
+ this.address = `http://${address}:${port}`;
14
+ resolve();
15
+ });
16
+ });
17
+ }
18
+ getAddress() {
19
+ if (!this.address) {
20
+ throw new Error("Unreachable: server not started");
21
+ }
22
+ return this.address;
23
+ }
24
+ async listenForCode(onCodeReceipt) {
25
+ return new Promise((resolve, reject) => {
26
+ if (!this.server) {
27
+ reject(new Error("Unreachable: server not started"));
28
+ return;
29
+ }
30
+ this.server.on("request", async (req, res) => {
31
+ this.stop();
32
+ const url = new URL(req.url ?? "/", this.address);
33
+ const code = url.searchParams.get("code");
34
+ const state = url.searchParams.get("state");
35
+ if (!code) {
36
+ 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) });
39
+ return;
40
+ }
41
+ let result;
42
+ try {
43
+ result = await onCodeReceipt(state ?? "", code);
44
+ }
45
+ catch (err) {
46
+ const message = err instanceof Error
47
+ ? err.message
48
+ : "Authentication failed: unknown error";
49
+ res.writeHead(500, { "Content-Type": "text/plain" }).end(message);
50
+ resolve({
51
+ type: "ERROR",
52
+ error: err instanceof Error ? err : new CliError(message),
53
+ });
54
+ return;
55
+ }
56
+ if (result.type === "SUCCESS") {
57
+ if (result.redirectUri) {
58
+ res.writeHead(302, { Location: result.redirectUri }).end();
59
+ }
60
+ else {
61
+ res
62
+ .writeHead(200, { "Content-Type": "text/plain" })
63
+ .end("Authentication successful");
64
+ }
65
+ }
66
+ else {
67
+ res
68
+ .writeHead(500, { "Content-Type": "text/plain" })
69
+ .end(result.error.message);
70
+ }
71
+ resolve(result);
72
+ });
73
+ });
74
+ }
75
+ stop() {
76
+ this.server?.close();
77
+ this.server = null;
78
+ }
79
+ }
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { initCommand } from "./commands/init.js";
5
5
  import { scorecardsCommand } from "./commands/scorecards.js";
6
6
  import { studioCommand } from "./commands/studio.js";
7
7
  import { teamsCommand } from "./commands/teams.js";
8
+ import { workflowsCommand } from "./commands/workflows.js";
8
9
  import { handleError } from "./commandHelpers.js";
9
10
  import cliPackage from "../package.json" with { type: "json" };
10
11
  export async function run(argv = process.argv) {
@@ -35,6 +36,7 @@ function createProgram() {
35
36
  program.addCommand(scorecardsCommand());
36
37
  program.addCommand(studioCommand());
37
38
  program.addCommand(teamsCommand());
39
+ program.addCommand(workflowsCommand());
38
40
  applyExitOverride(program);
39
41
  return program;
40
42
  }
@@ -1,3 +1,4 @@
1
+ import pc from "picocolors";
1
2
  import { CommanderError } from "commander";
2
3
  import { CliError, EXIT_CODES, HttpError } from "./errors.js";
3
4
  export function getContext(command) {
@@ -56,14 +57,14 @@ export function handleError(error, command, argv) {
56
57
  }
57
58
  else {
58
59
  if (error instanceof HttpError) {
59
- process.stderr.write(`The API returned an error with status code ${error.status}:\n\n${JSON.stringify(error.body, null, 2)}\n`);
60
+ process.stderr.write(pc.red(`The API returned an error with status code ${error.status}:\n\n${JSON.stringify(error.body, null, 2)}\n`));
60
61
  }
61
62
  else if (error instanceof CommanderError &&
62
63
  (error.code === "commander.help" || error.message === "(outputHelp)")) {
63
64
  // Commander already printed the relevant help text.
64
65
  }
65
66
  else {
66
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
67
+ process.stderr.write(pc.red(`${error instanceof Error ? error.message : String(error)}\n`));
67
68
  }
68
69
  }
69
70
  const exitCode = (() => {
@@ -1,11 +1,13 @@
1
+ import { password, select } from "@inquirer/prompts";
1
2
  import { Command } from "commander";
2
3
  import { deleteToken, setToken } from "../secrets.js";
3
4
  import { renderJson } from "../renderers.js";
4
5
  import { renderAuthInfo, renderLoggedOut } from "./authRendering.js";
5
- import { wrapAction } from "../commandHelpers.js";
6
- import { getContext } from "../commandHelpers.js";
7
- import { persistBaseUrl, resolveBaseUrl } from "../config.js";
6
+ import { getContext, wrapAction } from "../commandHelpers.js";
7
+ import { persistBaseUrl, resolveBaseUrl, resolveUiUrl } from "../config.js";
8
+ import { CliError } from "../errors.js";
8
9
  import { request } from "../http.js";
10
+ import { loginViaBrowser } from "../loginViaBrowser.js";
9
11
  import { buildLogger, buildRuntime } from "../runtime.js";
10
12
  import cliPackage from "../../package.json" with { type: "json" };
11
13
  import { maskToken } from "../ui.js";
@@ -15,25 +17,50 @@ export function authCommand() {
15
17
  .description("Manage DX authentication");
16
18
  auth
17
19
  .command("login")
18
- .requiredOption("--token <token>", "Account web API token or personal access token")
20
+ .option("--token <token>", "Account web API token or personal access token. Omit this option to login interactively by web browser or pasting.")
19
21
  .action(wrapAction(async (commandOptions, command) => {
20
22
  const context = getContext(command);
21
23
  const baseUrl = resolveBaseUrl();
24
+ let token = commandOptions.token;
25
+ if (!token) {
26
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
27
+ throw new CliError("`dx auth login` without `--token` requires an interactive terminal; pass `--token` for non-interactive use");
28
+ }
29
+ const method = await select({
30
+ message: "How would you like to log in?",
31
+ choices: [
32
+ { name: "Open browser", value: "browser" },
33
+ { name: "Paste API token", value: "token" },
34
+ ],
35
+ });
36
+ if (method === "browser") {
37
+ token = await loginViaBrowser(resolveUiUrl(baseUrl));
38
+ }
39
+ else {
40
+ token = await password({
41
+ message: "Paste your account web API token here:",
42
+ mask: true,
43
+ });
44
+ }
45
+ if (!token) {
46
+ throw new CliError("Account web API token or personal access token is required");
47
+ }
48
+ }
22
49
  const runtime = {
23
50
  baseUrl,
24
- token: commandOptions.token,
51
+ token,
25
52
  context,
26
53
  version: cliPackage.version,
27
54
  logger: buildLogger(context),
28
55
  };
29
56
  const response = await getAuthInfo(runtime);
30
57
  persistBaseUrl(baseUrl);
31
- setToken(baseUrl, commandOptions.token);
58
+ setToken(baseUrl, token);
32
59
  if (context.json) {
33
60
  renderJson({ ...response, base_url: baseUrl });
34
61
  return;
35
62
  }
36
- renderAuthInfo(response, commandOptions.token, baseUrl);
63
+ renderAuthInfo(response, token, baseUrl);
37
64
  }));
38
65
  auth.command("logout").action(wrapAction(async (_options, command) => {
39
66
  const context = getContext(command);
@@ -21,6 +21,7 @@ export function entitiesCommand() {
21
21
  .option("--owner-team-ids <ids>", "Comma-separated owner team IDs")
22
22
  .option("--owner-user-ids <ids>", "Comma-separated owner user IDs")
23
23
  .option("--property <kv>", "Set a property value as key=value. Repeat for multiple properties.", (val, prev) => [...prev, val], [])
24
+ .option("--alias <kv>", "Set an alias value as key=value. Repeat for multiple aliases.", (val, prev) => [...prev, val], [])
24
25
  .addHelpText("afterAll", createExampleText([
25
26
  {
26
27
  label: "Create a new service entity",
@@ -38,6 +39,10 @@ export function entitiesCommand() {
38
39
  label: "Create with properties",
39
40
  command: 'dx catalog entities create --type service --identifier my-service --property tier=Tier-1 --property "languages=Ruby,TypeScript"',
40
41
  },
42
+ {
43
+ label: "Create with aliases",
44
+ command: "dx catalog entities create --type service --identifier my-service --alias github_repo=12345",
45
+ },
41
46
  ]))
42
47
  .action(wrapAction(async (options, command) => {
43
48
  if (!options.identifier) {
@@ -47,7 +52,7 @@ export function entitiesCommand() {
47
52
  throw new CliError("--type is required", EXIT_CODES.ARGUMENT_ERROR);
48
53
  }
49
54
  const runtime = buildRuntime(getContext(command));
50
- const properties = await resolvePropertiesForEntityType(runtime, options.type, options.property);
55
+ const resolvedValues = await resolveValuesForEntityType(runtime, options.type, options.property, options.alias);
51
56
  const response = await createEntity(runtime, options.identifier, {
52
57
  type: options.type,
53
58
  name: options.name,
@@ -58,7 +63,7 @@ export function entitiesCommand() {
58
63
  owner_user_ids: options.ownerUserIds
59
64
  ?.split(",")
60
65
  .map((s) => s.trim()),
61
- properties,
66
+ ...resolvedValues,
62
67
  });
63
68
  if (runtime.context.json) {
64
69
  renderJson(response);
@@ -100,6 +105,7 @@ export function entitiesCommand() {
100
105
  .option("--owner-team-ids <ids>", "Comma-separated owner team IDs")
101
106
  .option("--owner-user-ids <ids>", "Comma-separated owner user IDs")
102
107
  .option("--property <kv>", "Set a property value as key=value. Repeat for multiple properties. Use value null to remove a property.", (val, prev) => [...prev, val], [])
108
+ .option("--alias <kv>", "Set an alias value as key=value. Repeat for multiple aliases. Use value null to remove an alias.", (val, prev) => [...prev, val], [])
103
109
  .addHelpText("afterAll", createExampleText([
104
110
  {
105
111
  label: "Rename an entity",
@@ -113,13 +119,17 @@ export function entitiesCommand() {
113
119
  label: "Update properties",
114
120
  command: 'dx catalog entities update my-service --property tier=Tier-1 --property "languages=Ruby,TypeScript"',
115
121
  },
122
+ {
123
+ label: "Update aliases",
124
+ command: "dx catalog entities update my-service --alias github_repo=12345",
125
+ },
116
126
  ]))
117
127
  .action(wrapAction(async (identifier, options, command) => {
118
128
  const runtime = buildRuntime(getContext(command));
119
- const properties = await resolvePropertiesForExistingEntity(runtime, identifier, options.property);
129
+ const resolvedValues = await resolveValuesForExistingEntity(runtime, identifier, options.property, options.alias);
120
130
  const response = await updateEntity(runtime, identifier, {
121
131
  ...getEntityMutationOptionValues(options),
122
- properties,
132
+ ...resolvedValues,
123
133
  });
124
134
  if (runtime.context.json) {
125
135
  renderJson(response);
@@ -138,6 +148,7 @@ export function entitiesCommand() {
138
148
  .option("--owner-team-ids <ids>", "Comma-separated owner team IDs")
139
149
  .option("--owner-user-ids <ids>", "Comma-separated owner user IDs")
140
150
  .option("--property <kv>", "Set a property value as key=value. Repeat for multiple properties. Use value null to remove a property.", (val, prev) => [...prev, val], [])
151
+ .option("--alias <kv>", "Set an alias value as key=value. Repeat for multiple aliases. Use value null to remove an alias.", (val, prev) => [...prev, val], [])
141
152
  .addHelpText("afterAll", createExampleText([
142
153
  {
143
154
  label: "Create a new service entity if it does not exist",
@@ -151,6 +162,10 @@ export function entitiesCommand() {
151
162
  label: "Set properties while preserving omitted fields",
152
163
  command: 'dx catalog entities upsert --type service --identifier my-service --property tier=Tier-1 --property "languages=Ruby,TypeScript"',
153
164
  },
165
+ {
166
+ label: "Set aliases while preserving omitted fields",
167
+ command: "dx catalog entities upsert --type service --identifier my-service --alias github_repo=12345",
168
+ },
154
169
  ]))
155
170
  .action(wrapAction(async (options, command) => {
156
171
  if (!options.type) {
@@ -160,11 +175,11 @@ export function entitiesCommand() {
160
175
  throw new CliError("--identifier is required", EXIT_CODES.ARGUMENT_ERROR);
161
176
  }
162
177
  const runtime = buildRuntime(getContext(command));
163
- const properties = await resolvePropertiesForEntityType(runtime, options.type, options.property);
178
+ const resolvedValues = await resolveValuesForEntityType(runtime, options.type, options.property, options.alias);
164
179
  const response = await upsertEntity(runtime, options.identifier, {
165
180
  type: options.type,
166
181
  ...getEntityMutationOptionValues(options),
167
- properties,
182
+ ...resolvedValues,
168
183
  });
169
184
  if (runtime.context.json) {
170
185
  renderJson(response);
@@ -459,6 +474,33 @@ export function parsePropertyValue(prop, rawValue) {
459
474
  return rawValue;
460
475
  }
461
476
  }
477
+ function parseEntityAliases(entityTypeIdentifier, rawAliases, knownAliases) {
478
+ const aliasKeys = Object.keys(knownAliases);
479
+ const aliasSet = new Set(aliasKeys);
480
+ const result = {};
481
+ for (const kv of rawAliases) {
482
+ const eqIndex = kv.indexOf("=");
483
+ if (eqIndex === -1) {
484
+ throw new CliError(`Invalid --alias "${kv}": expected format key=value`, EXIT_CODES.ARGUMENT_ERROR);
485
+ }
486
+ const key = kv.slice(0, eqIndex);
487
+ const rawValue = kv.slice(eqIndex + 1);
488
+ if (!aliasSet.has(key)) {
489
+ const availableAliases = aliasKeys
490
+ .sort()
491
+ .map((k) => `\`${k}\``)
492
+ .join(", ");
493
+ const availableAliasesText = availableAliases.length > 0 ? availableAliases : "(none)";
494
+ throw new CliError(`Unknown alias \`${key}\` for entity type \`${entityTypeIdentifier}\`. Available aliases: ${availableAliasesText}`, EXIT_CODES.ARGUMENT_ERROR);
495
+ }
496
+ if (rawValue === "null") {
497
+ result[key] = [];
498
+ continue;
499
+ }
500
+ result[key] = [...(result[key] ?? []), { identifier: rawValue }];
501
+ }
502
+ return result;
503
+ }
462
504
  // --- Include helpers ---
463
505
  const ENTITY_INCLUDE_SECTIONS = [
464
506
  "core",
@@ -487,22 +529,31 @@ function parseIncludeSections(include) {
487
529
  }
488
530
  return results;
489
531
  }
490
- async function resolvePropertiesForEntityType(runtime, entityTypeIdentifier, rawProperties) {
491
- if (rawProperties.length === 0) {
492
- return undefined;
532
+ async function resolveValuesForEntityType(runtime, entityTypeIdentifier, rawProperties, rawAliases) {
533
+ if (rawProperties.length === 0 && rawAliases.length === 0) {
534
+ return {};
493
535
  }
494
536
  let entityTypeResponse;
495
537
  try {
496
538
  entityTypeResponse = await getEntityType(runtime, entityTypeIdentifier);
497
539
  }
498
540
  catch (err) {
499
- throw wrapEntityTypeLookupError(entityTypeIdentifier, err);
541
+ throw wrapEntityTypeLookupError(entityTypeIdentifier, err, rawAliases.length > 0
542
+ ? "resolve property and alias values"
543
+ : "resolve property types");
544
+ }
545
+ const values = {};
546
+ if (rawProperties.length > 0) {
547
+ values.properties = parseEntityProperties(entityTypeIdentifier, rawProperties, entityTypeResponse.entity_type.properties);
500
548
  }
501
- return parseEntityProperties(entityTypeIdentifier, rawProperties, entityTypeResponse.entity_type.properties);
549
+ if (rawAliases.length > 0) {
550
+ values.aliases = parseEntityAliases(entityTypeIdentifier, rawAliases, entityTypeResponse.entity_type.aliases);
551
+ }
552
+ return values;
502
553
  }
503
- async function resolvePropertiesForExistingEntity(runtime, identifier, rawProperties) {
504
- if (rawProperties.length === 0) {
505
- return undefined;
554
+ async function resolveValuesForExistingEntity(runtime, identifier, rawProperties, rawAliases) {
555
+ if (rawProperties.length === 0 && rawAliases.length === 0) {
556
+ return {};
506
557
  }
507
558
  let entityResponse;
508
559
  try {
@@ -512,9 +563,23 @@ async function resolvePropertiesForExistingEntity(runtime, identifier, rawProper
512
563
  const exitCode = err instanceof HttpError && err.status !== undefined && err.status < 500
513
564
  ? EXIT_CODES.ARGUMENT_ERROR
514
565
  : EXIT_CODES.RETRY_RECOMMENDED;
515
- throw new CliError(`Failed to fetch entity "${identifier}" to resolve property types: ${err instanceof Error ? err.message : String(err)}`, exitCode);
566
+ throw new CliError(`Failed to fetch entity "${identifier}" to resolve property and alias values: ${err instanceof Error ? err.message : String(err)}`, exitCode);
567
+ }
568
+ let entityTypeResponse;
569
+ try {
570
+ entityTypeResponse = await getEntityType(runtime, entityResponse.entity.type);
571
+ }
572
+ catch (err) {
573
+ throw wrapEntityTypeLookupError(entityResponse.entity.type, err, "resolve property and alias values");
574
+ }
575
+ const values = {};
576
+ if (rawProperties.length > 0) {
577
+ values.properties = parseEntityProperties(entityResponse.entity.type, rawProperties, entityTypeResponse.entity_type.properties);
578
+ }
579
+ if (rawAliases.length > 0) {
580
+ values.aliases = parseEntityAliases(entityResponse.entity.type, rawAliases, entityTypeResponse.entity_type.aliases);
516
581
  }
517
- return resolvePropertiesForEntityType(runtime, entityResponse.entity.type, rawProperties);
582
+ return values;
518
583
  }
519
584
  function getEntityMutationOptionValues(options) {
520
585
  return {
@@ -549,13 +614,15 @@ function buildEntityMutationBody(identifier, params) {
549
614
  body.owner_user_ids = params.owner_user_ids;
550
615
  if (params.properties !== undefined)
551
616
  body.properties = params.properties;
617
+ if (params.aliases !== undefined)
618
+ body.aliases = params.aliases;
552
619
  return body;
553
620
  }
554
- function wrapEntityTypeLookupError(entityTypeIdentifier, err) {
621
+ function wrapEntityTypeLookupError(entityTypeIdentifier, err, purpose = "resolve property types") {
555
622
  const exitCode = err instanceof HttpError && err.status !== undefined && err.status < 500
556
623
  ? EXIT_CODES.ARGUMENT_ERROR
557
624
  : EXIT_CODES.RETRY_RECOMMENDED;
558
- return new CliError(`Failed to fetch entity type "${entityTypeIdentifier}" to resolve property types: ${err instanceof Error ? err.message : String(err)}`, exitCode);
625
+ return new CliError(`Failed to fetch entity type "${entityTypeIdentifier}" to ${purpose}: ${err instanceof Error ? err.message : String(err)}`, exitCode);
559
626
  }
560
627
  function uniqueSectionsInOrder(sections) {
561
628
  const seen = new Set();
@@ -238,6 +238,9 @@ function propertyValueContent(identifier, value) {
238
238
  const matchText = value.match_count === 1 ? "match" : "matches";
239
239
  return `File matching rule: ${ui.code(value.status)} (${value.match_count} ${matchText})`;
240
240
  }
241
+ else if (isOpenApiSpec(value)) {
242
+ return `OpenAPI spec`;
243
+ }
241
244
  else if (isUrl(value)) {
242
245
  return ui.link(value);
243
246
  }
@@ -284,3 +287,11 @@ function isFileMatchingRule(value) {
284
287
  typeof value.status === "string" &&
285
288
  ["FOUND_MATCHES", "NO_MATCHES", "FILE_NOT_FOUND"].includes(value.status));
286
289
  }
290
+ function isOpenApiSpec(value) {
291
+ // TODO: consider switching to zod
292
+ return (typeof value === "object" &&
293
+ value !== null &&
294
+ typeof value === "object" &&
295
+ "info" in value &&
296
+ "paths" in value);
297
+ }
@@ -1,12 +1,13 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { input, password, confirm } from "@inquirer/prompts";
4
+ import { input, password, confirm, select } from "@inquirer/prompts";
5
5
  import { Command } from "commander";
6
6
  import { execa } from "execa";
7
7
  import { buildRuntime, buildRuntimeSafe } from "../runtime.js";
8
8
  import { getAuthInfo } from "./auth.js";
9
9
  import { renderAuthInfo } from "./authRendering.js";
10
+ import { loginViaBrowser } from "../loginViaBrowser.js";
10
11
  import { getContext } from "../commandHelpers.js";
11
12
  import { wrapAction } from "../commandHelpers.js";
12
13
  import { renderRichText } from "../renderers.js";
@@ -112,11 +113,24 @@ async function ensureLoggedIn(runtime, host) {
112
113
  }
113
114
  }
114
115
  }
115
- async function attemptLogin(apiBaseUrl, _uiBaseUrl) {
116
- const token = await password({
117
- message: "Paste your account web API token here:",
118
- mask: true,
116
+ async function attemptLogin(apiBaseUrl, uiBaseUrl) {
117
+ const method = await select({
118
+ message: "How would you like to log in?",
119
+ choices: [
120
+ { name: "Open browser", value: "browser" },
121
+ { name: "Paste API token", value: "token" },
122
+ ],
119
123
  });
124
+ let token;
125
+ if (method === "browser") {
126
+ token = await loginViaBrowser(uiBaseUrl);
127
+ }
128
+ else {
129
+ token = await password({
130
+ message: "Paste your account web API token here:",
131
+ mask: true,
132
+ });
133
+ }
120
134
  if (!token) {
121
135
  throw new CliError("Account web API token is required");
122
136
  }
@@ -97,7 +97,7 @@ function entityFilterContent(scorecard) {
97
97
  else if (scorecard.entity_filter_type === "sql") {
98
98
  blocks.push(ui.p(`Filter: ${ui.code("sql")}`));
99
99
  if (scorecard.entity_filter_sql) {
100
- blocks.push(truncatedSqlBlock(scorecard.entity_filter_sql));
100
+ blocks.push(ui.truncatedSqlBlock(scorecard.entity_filter_sql));
101
101
  }
102
102
  else {
103
103
  blocks.push(ui.p(ui.dim("(No SQL provided)")));
@@ -190,7 +190,7 @@ function checkContent(check, scorecardType) {
190
190
  }
191
191
  // Query
192
192
  blocks.push(ui.p(ui.bold("Query:")));
193
- blocks.push(truncatedSqlBlock(check.sql));
193
+ blocks.push(ui.truncatedSqlBlock(check.sql));
194
194
  // Output settings
195
195
  if (check.output_enabled) {
196
196
  const outputParts = [`Output: ${ui.code(check.output_type ?? "unknown")}`];
@@ -202,18 +202,10 @@ function checkContent(check, scorecardType) {
202
202
  // Entity filter
203
203
  if (check.filter_sql) {
204
204
  blocks.push(ui.p(ui.bold("Entity filter:")));
205
- blocks.push(truncatedSqlBlock(check.filter_sql));
205
+ blocks.push(ui.truncatedSqlBlock(check.filter_sql));
206
206
  if (check.filter_message) {
207
207
  blocks.push(ui.p(`Excluded message: ${ui.dim(check.filter_message)}`, false));
208
208
  }
209
209
  }
210
210
  return blocks;
211
211
  }
212
- function truncatedSqlBlock(sql) {
213
- const lines = sql.split("\n");
214
- const firstLine = lines[0];
215
- const isLong = firstLine.length > 80 || lines.length > 1;
216
- const truncated = firstLine.length > 80 ? firstLine.slice(0, 80) : firstLine;
217
- const display = isLong ? truncated + ui.dim(" …") : truncated;
218
- return ui.p(ui.code(display));
219
- }
@@ -5,10 +5,10 @@ import { Command } from "commander";
5
5
  import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
6
6
  import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
7
7
  import { request } from "../../http.js";
8
- import { renderJson } from "../../renderers.js";
8
+ import { AsyncProgressReporter, renderJson, renderRichText, } from "../../renderers.js";
9
9
  import { buildRuntime } from "../../runtime.js";
10
10
  import * as ui from "../../ui.js";
11
- import { QueryProgressReporter, renderCsvSaved, renderQueryResultsTable, } from "./queryRendering.js";
11
+ import { renderCsvSaved, renderQueryResultsTable } from "./queryRendering.js";
12
12
  const DEFAULT_RETRY_AFTER_MS = 1000;
13
13
  const PENDING_QUERY_RUN_STATUSES = new Set([
14
14
  "queued",
@@ -51,7 +51,7 @@ export function queryCommand() {
51
51
  }
52
52
  const variables = parseVariables(options.variable);
53
53
  const runtime = buildRuntime(context);
54
- const progress = new QueryProgressReporter();
54
+ const progress = new AsyncProgressReporter();
55
55
  const queryRun = await executeQuery(runtime, progress, sql, variables);
56
56
  await waitForQuery(runtime, progress, queryRun.id);
57
57
  if (options.output) {
@@ -70,7 +70,9 @@ export function queryCommand() {
70
70
  }
71
71
  async function executeQuery(runtime, progress, sql, variables) {
72
72
  try {
73
- renderRunningQuery(sql);
73
+ renderRichText([ui.p(`${ui.bold("Running:")} ${ui.code(sql)}`)], {
74
+ useStderr: true,
75
+ });
74
76
  progress.start(ui.bold("Submitting query"));
75
77
  const executeResponse = await executeStudioQuery(runtime, sql, variables);
76
78
  const queryRun = executeResponse.body.query_run;
@@ -119,9 +121,6 @@ async function waitForQuery(runtime, progress, queryRunId) {
119
121
  }
120
122
  }
121
123
  }
122
- function renderRunningQuery(sql) {
123
- process.stderr.write(`${ui.bold("Running:")} ${ui.code(sql)}\n`);
124
- }
125
124
  function renderPendingQueryStatus(queryRunId) {
126
125
  return `${ui.bold("Query running")} ${ui.dim(`(${queryRunId})`)}`;
127
126
  }
@@ -1,4 +1,3 @@
1
- import { clearLine, cursorTo } from "node:readline";
2
1
  import { renderRichText } from "../../renderers.js";
3
2
  import * as ui from "../../ui.js";
4
3
  export function renderQueryResultsTable(results) {
@@ -7,65 +6,6 @@ export function renderQueryResultsTable(results) {
7
6
  export function renderCsvSaved(filename) {
8
7
  renderRichText([ui.p(`Saved CSV results to ${ui.code(filename)}.`)]);
9
8
  }
10
- export class QueryProgressReporter {
11
- enabled = Boolean(process.stderr.isTTY);
12
- frames = [
13
- "⠋",
14
- "⠙",
15
- "⠹",
16
- "⠸",
17
- "⠼",
18
- "⠴",
19
- "⠦",
20
- "⠧",
21
- "⠇",
22
- "⠏",
23
- ].map((frame) => ui.dim(frame));
24
- timer;
25
- frameIndex = 0;
26
- currentMessage = "";
27
- start(message) {
28
- if (!this.enabled) {
29
- return;
30
- }
31
- this.currentMessage = message;
32
- this.render();
33
- this.timer = setInterval(() => this.render(), 80);
34
- this.timer.unref?.();
35
- }
36
- update(message) {
37
- if (!this.enabled) {
38
- return;
39
- }
40
- this.currentMessage = message;
41
- this.render();
42
- }
43
- stop(finalMessage) {
44
- if (!this.enabled) {
45
- return;
46
- }
47
- if (this.timer) {
48
- clearInterval(this.timer);
49
- this.timer = undefined;
50
- }
51
- if (finalMessage) {
52
- this.clearCurrentLine();
53
- process.stderr.write(`${finalMessage}\n`);
54
- return;
55
- }
56
- this.clearCurrentLine();
57
- }
58
- render() {
59
- const frame = this.frames[this.frameIndex % this.frames.length];
60
- this.frameIndex += 1;
61
- this.clearCurrentLine();
62
- process.stderr.write(`${frame} ${this.currentMessage}`);
63
- }
64
- clearCurrentLine() {
65
- clearLine(process.stderr, 0);
66
- cursorTo(process.stderr, 0);
67
- }
68
- }
69
9
  function formatAsciiTable(columns, rows) {
70
10
  if (columns.length === 0) {
71
11
  return "(Query returned no columns)";
@@ -0,0 +1,82 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
3
+ import { CliError, EXIT_CODES } from "../errors.js";
4
+ import { request } from "../http.js";
5
+ import { renderJson } from "../renderers.js";
6
+ import { buildRuntime } from "../runtime.js";
7
+ import { renderWorkflowList } from "./workflowsRendering.js";
8
+ const WORKFLOW_SCOPES = ["GLOBAL", "ENTITY"];
9
+ export function workflowsCommand() {
10
+ const workflows = new Command()
11
+ .name("workflows")
12
+ .description("Manage Self-service workflow definitions");
13
+ workflows
14
+ .command("list")
15
+ .description("List Self-service workflows for the account")
16
+ .option("--scope <scope>", "Filter by workflow scope: GLOBAL or ENTITY (omit for both)")
17
+ .option("--entity-identifier <id>", "Catalog entity identifier; must be used with --scope ENTITY")
18
+ .addHelpText("afterAll", createExampleText([
19
+ {
20
+ label: "List workflows the token can trigger",
21
+ command: "dx workflows list",
22
+ },
23
+ {
24
+ label: "Entity-scoped workflows for a catalog entity",
25
+ command: "dx workflows list --scope ENTITY --entity-identifier acme-app",
26
+ },
27
+ {
28
+ label: "Same request as JSON",
29
+ command: "dx --json workflows list --scope ENTITY --entity-identifier acme-app",
30
+ },
31
+ ]))
32
+ .action(wrapAction(async (options, command) => {
33
+ const scope = parseOptionalScope(options.scope);
34
+ const entityIdentifier = parseOptionalTrimmed(options.entityIdentifier);
35
+ if (entityIdentifier && scope !== "ENTITY") {
36
+ throw new CliError("--entity-identifier requires --scope ENTITY", EXIT_CODES.ARGUMENT_ERROR);
37
+ }
38
+ const runtime = buildRuntime(getContext(command));
39
+ const response = await listWorkflows(runtime, {
40
+ scope,
41
+ entity_identifier: entityIdentifier,
42
+ });
43
+ if (runtime.context.json) {
44
+ renderJson(response);
45
+ }
46
+ else {
47
+ renderWorkflowList(response.workflows);
48
+ }
49
+ }));
50
+ return workflows;
51
+ }
52
+ function parseOptionalTrimmed(value) {
53
+ if (typeof value !== "string") {
54
+ return undefined;
55
+ }
56
+ const t = value.trim();
57
+ return t.length > 0 ? t : undefined;
58
+ }
59
+ function parseOptionalScope(value) {
60
+ const raw = parseOptionalTrimmed(value);
61
+ if (raw === undefined) {
62
+ return undefined;
63
+ }
64
+ if (!WORKFLOW_SCOPES.includes(raw)) {
65
+ throw new CliError(`--scope must be one of: ${WORKFLOW_SCOPES.join(", ")}`, EXIT_CODES.ARGUMENT_ERROR);
66
+ }
67
+ return raw;
68
+ }
69
+ // --- API ---
70
+ async function listWorkflows(runtime, params) {
71
+ const query = {};
72
+ if (params.scope !== undefined)
73
+ query.scope = params.scope;
74
+ if (params.entity_identifier !== undefined) {
75
+ query.entity_identifier = params.entity_identifier;
76
+ }
77
+ const response = await request(runtime, "/workflows.list", {
78
+ method: "GET",
79
+ query,
80
+ });
81
+ return response.body;
82
+ }
@@ -0,0 +1,136 @@
1
+ import { renderRichText } from "../renderers.js";
2
+ import * as ui from "../ui.js";
3
+ const WORKFLOW_SCOPE_LABELS = {
4
+ GLOBAL: "Global",
5
+ ENTITY: "Catalog entity",
6
+ };
7
+ const WORKFLOW_EXECUTION_TYPE_LABELS = {
8
+ SIMPLE: "Simple",
9
+ EVENT_DRIVEN: "Event-driven",
10
+ };
11
+ const WORKFLOW_TRIGGER_TYPE_LABELS = {
12
+ ANY: "All users",
13
+ APPROVAL: "All users with approval",
14
+ RUNNERS: "Selected teams and users",
15
+ ENTITY: "Entity owners only",
16
+ };
17
+ const WORKFLOW_TRIGGER_TYPE_DESCRIPTIONS = {
18
+ ANY: "All users can trigger this workflow run without approval.",
19
+ APPROVAL: "All users can request a workflow run, but a designated approver must accept before it runs.",
20
+ RUNNERS: "Only selected teams or users can trigger a workflow run.",
21
+ ENTITY: "Only users who directly own the entity, or belong to an owning team, can trigger a run.",
22
+ };
23
+ function workflowScopeLabel(scope) {
24
+ return WORKFLOW_SCOPE_LABELS[scope] ?? scope;
25
+ }
26
+ function workflowExecutionTypeLabel(executionType) {
27
+ return WORKFLOW_EXECUTION_TYPE_LABELS[executionType] ?? executionType;
28
+ }
29
+ function workflowTriggerTypeLabel(triggerType) {
30
+ return WORKFLOW_TRIGGER_TYPE_LABELS[triggerType] ?? triggerType;
31
+ }
32
+ function workflowTriggerTypeDescription(triggerType) {
33
+ return WORKFLOW_TRIGGER_TYPE_DESCRIPTIONS[triggerType] ?? "";
34
+ }
35
+ export function renderWorkflowList(workflows) {
36
+ const blocks = [
37
+ ui.h1("Workflows"),
38
+ ui.p(`Displaying ${ui.bold(workflows.length.toString())} workflows.`),
39
+ ];
40
+ for (const workflow of workflows) {
41
+ blocks.push(ui.h2(`${workflow.name} (${ui.code(workflow.identifier)})`));
42
+ blocks.push(ui.h3("Core attributes"));
43
+ blocks.push(...coreContent(workflow));
44
+ if (workflow.scope === "ENTITY") {
45
+ blocks.push(ui.h3("Entity filter"));
46
+ blocks.push(...entityFilterContent(workflow));
47
+ }
48
+ blocks.push(ui.h3("Parameters"));
49
+ blocks.push(...parametersContent(workflow));
50
+ }
51
+ renderRichText(blocks);
52
+ }
53
+ function coreContent(workflow) {
54
+ const blocks = [];
55
+ const description = workflow.description?.trim()
56
+ ? workflow.description
57
+ : ui.dim("(None)");
58
+ blocks.push(ui.dl([
59
+ ui.dli("Description", description),
60
+ ui.dli("Scope", workflowScopeLabel(workflow.scope)),
61
+ ui.dli("Execution type", workflowExecutionTypeLabel(workflow.execution_type)),
62
+ ui.dli("Who can run", `${workflowTriggerTypeLabel(workflow.trigger_type)} ${ui.dim(workflowTriggerTypeDescription(workflow.trigger_type))}`),
63
+ ], { termWidth: 16 }));
64
+ return blocks;
65
+ }
66
+ function entityFilterContent(workflow) {
67
+ const blocks = [];
68
+ if (workflow.entity_filter_type === "ENTITY_TYPES") {
69
+ blocks.push(ui.p(`Filter type: ${ui.code("Entity types")}`));
70
+ if (workflow.entity_filter_type_identifiers &&
71
+ workflow.entity_filter_type_identifiers.length > 0) {
72
+ blocks.push(ui.ul(workflow.entity_filter_type_identifiers.map((identifier) => ui.li([ui.p(ui.code(identifier), false)]))));
73
+ }
74
+ }
75
+ else if (workflow.entity_filter_type === "SQL") {
76
+ blocks.push(ui.p(`Filter type: ${ui.code("SQL")}`));
77
+ if (workflow.entity_filter_sql) {
78
+ blocks.push(ui.truncatedSqlBlock(workflow.entity_filter_sql));
79
+ }
80
+ }
81
+ return blocks;
82
+ }
83
+ function parametersContent(workflow) {
84
+ if (workflow.parameters.length === 0) {
85
+ return [ui.p(ui.dim("(No parameters)"))];
86
+ }
87
+ return [
88
+ ui.ul(workflow.parameters.map((parameter) => ui.li(singleParameterContent(parameter)))),
89
+ ];
90
+ }
91
+ function singleParameterContent(parameter) {
92
+ const requiredPart = parameter.is_required ? ` ${ui.dim("required")}` : "";
93
+ const headline = `${ui.bold(parameter.name)} (${ui.code(parameter.identifier)}) ${ui.code(parameter.type)}${requiredPart}`;
94
+ const blocks = [ui.p(headline, false)];
95
+ const desc = parameter.description?.trim();
96
+ if (desc) {
97
+ blocks.push(ui.p(desc, false));
98
+ }
99
+ if (parameter.default_value !== null &&
100
+ parameter.default_value !== undefined &&
101
+ parameter.default_value !== "") {
102
+ blocks.push(ui.p(`Default: ${ui.code(formatParameterDefault(parameter.default_value))}`, false));
103
+ }
104
+ if (parameter.type === "SELECT") {
105
+ const options = selectParameterOptions(parameter.definition);
106
+ if (options.length > 0) {
107
+ blocks.push(ui.p("Options:", false));
108
+ blocks.push(ui.ul(options.map((value) => ui.li([ui.p(ui.code(value), false)]))));
109
+ }
110
+ }
111
+ return blocks;
112
+ }
113
+ function selectParameterOptions(definition) {
114
+ if (!definition || typeof definition !== "object") {
115
+ return [];
116
+ }
117
+ const raw = definition.options;
118
+ if (!Array.isArray(raw)) {
119
+ return [];
120
+ }
121
+ return raw.filter((item) => typeof item === "string");
122
+ }
123
+ function formatParameterDefault(value) {
124
+ if (typeof value === "string") {
125
+ return value;
126
+ }
127
+ if (typeof value === "number" || typeof value === "boolean") {
128
+ return String(value);
129
+ }
130
+ try {
131
+ return JSON.stringify(value);
132
+ }
133
+ catch {
134
+ return String(value);
135
+ }
136
+ }
package/dist/config.js CHANGED
@@ -30,6 +30,23 @@ export function resolveBaseUrl() {
30
30
  }
31
31
  return normalizeBaseUrl(readConfig().baseUrl || DEFAULT_BASE_URL);
32
32
  }
33
+ /**
34
+ * Web UI origin for browser OAuth (`/cli/auth`), inferred from the API base URL.
35
+ * Dedicated and cloud hostnames map to their app origins; otherwise the API URL origin is used.
36
+ */
37
+ export function resolveUiUrl(apiBaseUrl) {
38
+ const normalized = normalizeBaseUrl(apiBaseUrl);
39
+ const url = new URL(normalized);
40
+ const host = url.hostname;
41
+ if (host === "api.getdx.com") {
42
+ return "https://app.getdx.com";
43
+ }
44
+ const dedicated = host.match(/^api\.(.+)\.getdx\.io$/);
45
+ if (dedicated) {
46
+ return `https://${dedicated[1]}.getdx.io`;
47
+ }
48
+ return url.origin;
49
+ }
33
50
  export function persistBaseUrl(baseUrl) {
34
51
  writeConfig({ ...readConfig(), baseUrl: normalizeBaseUrl(baseUrl) });
35
52
  }
@@ -0,0 +1,86 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import open from "open";
3
+ import { AuthCodeCallbackServer, } from "./authCodeCallbackServer.js";
4
+ import { CliError } from "./errors.js";
5
+ import { renderRichText } from "./renderers.js";
6
+ import * as ui from "./ui.js";
7
+ export async function loginViaBrowser(uiBaseUrl) {
8
+ const server = new AuthCodeCallbackServer();
9
+ await server.start();
10
+ const callbackUri = server.getAddress();
11
+ const codeVerifier = generateCodeVerifier();
12
+ const codeChallenge = generateCodeChallenge(codeVerifier);
13
+ const state = generateState();
14
+ const params = new URLSearchParams({
15
+ callback_uri: callbackUri,
16
+ state,
17
+ code_challenge: codeChallenge,
18
+ code_challenge_method: "S256",
19
+ });
20
+ const authUrl = `${uiBaseUrl}/cli/auth?${params.toString()}`;
21
+ renderRichText([
22
+ ui.p(`Opening your browser to complete authentication...`),
23
+ ui.blankLine(),
24
+ ]);
25
+ try {
26
+ await open(authUrl);
27
+ }
28
+ catch {
29
+ renderRichText([
30
+ ui.p(`Failed to open browser automatically. Visit this URL to continue:`),
31
+ ui.blankLine(),
32
+ ui.codeBlock(authUrl),
33
+ ui.blankLine(),
34
+ ]);
35
+ }
36
+ renderRichText([ui.p(`Waiting for authentication in your browser...`)]);
37
+ const handleCodeReceipt = async (stateFromResponse, code) => {
38
+ const valid = validateState(stateFromResponse, state);
39
+ if (!valid) {
40
+ return {
41
+ type: "ERROR",
42
+ error: new CliError("Authentication failed: state mismatch"),
43
+ };
44
+ }
45
+ const tokenExchangeResponse = await exchangeCodeForToken(uiBaseUrl, code, codeVerifier);
46
+ if (!tokenExchangeResponse.ok) {
47
+ return {
48
+ type: "ERROR",
49
+ error: new CliError(`Authentication failed: token exchange failed with status ${tokenExchangeResponse.status}`),
50
+ };
51
+ }
52
+ const body = (await tokenExchangeResponse.json());
53
+ return {
54
+ type: "SUCCESS",
55
+ token: body.access_token,
56
+ redirectUri: body.redirect_uri,
57
+ };
58
+ };
59
+ const serverResult = await server.listenForCode(handleCodeReceipt);
60
+ if (serverResult.type === "ERROR")
61
+ throw serverResult.error;
62
+ return serverResult.token;
63
+ }
64
+ function validateState(stateFromResponse, originalState) {
65
+ return stateFromResponse === originalState;
66
+ }
67
+ async function exchangeCodeForToken(baseUrl, code, codeVerifier) {
68
+ return fetch(`${baseUrl}/cli/token`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
71
+ body: new URLSearchParams({ code, code_verifier: codeVerifier }),
72
+ });
73
+ }
74
+ // RFC 8252 §8.9: random string used to prevent CSRF-style attacks
75
+ function generateState() {
76
+ return randomBytes(16).toString("base64url");
77
+ }
78
+ // RFC 7636 §4.1 — 32 random bytes base64url-encoded = 43 unreserved chars
79
+ function generateCodeVerifier() {
80
+ return randomBytes(32).toString("base64url");
81
+ }
82
+ // RFC 7636 §4.2 — SHA256 hex digest, base64-encoded without padding
83
+ function generateCodeChallenge(codeVerifier) {
84
+ const hexDigest = createHash("sha256").update(codeVerifier).digest("hex");
85
+ return Buffer.from(hexDigest).toString("base64").replace(/=/g, "");
86
+ }
package/dist/renderers.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { clearLine, cursorTo } from "node:readline";
2
+ import { dim } from "./ui.js";
1
3
  /**
2
4
  * Render an object in plain JSON formatting.
3
5
  *
@@ -11,7 +13,7 @@ export function renderJson(value) {
11
13
  *
12
14
  * Should be used when the `--flag` is NOT present.
13
15
  */
14
- export function renderRichText(blocks) {
16
+ export function renderRichText(blocks, options) {
15
17
  const normalizedBlocks = blocks
16
18
  .filter((block) => block !== null)
17
19
  .flatMap((block) => {
@@ -25,5 +27,78 @@ export function renderRichText(blocks) {
25
27
  const blockContent = block.render();
26
28
  renderedBlocks.push(blockContent);
27
29
  }
28
- process.stdout.write(renderedBlocks.join("\n"));
30
+ if (options?.useStderr) {
31
+ process.stderr.write(renderedBlocks.join("\n"));
32
+ }
33
+ else {
34
+ process.stdout.write(renderedBlocks.join("\n"));
35
+ }
36
+ }
37
+ /**
38
+ * Renders a TTY spinner and status on stderr for long-running async work. Does not
39
+ * use `renderRichText` because stdout is reserved for command results; stderr keeps
40
+ * auxiliary output separate from the primary stream.
41
+ */
42
+ export class AsyncProgressReporter {
43
+ enabled = Boolean(process.stderr.isTTY);
44
+ intervalMs;
45
+ frames = [
46
+ "⠋",
47
+ "⠙",
48
+ "⠹",
49
+ "⠸",
50
+ "⠼",
51
+ "⠴",
52
+ "⠦",
53
+ "⠧",
54
+ "⠇",
55
+ "⠏",
56
+ ].map((frame) => dim(frame));
57
+ timer;
58
+ frameIndex = 0;
59
+ currentMessage = "";
60
+ constructor(options) {
61
+ this.intervalMs = options?.intervalMs ?? 80;
62
+ }
63
+ start(message) {
64
+ if (!this.enabled) {
65
+ return;
66
+ }
67
+ this.currentMessage = message;
68
+ this.render();
69
+ this.timer = setInterval(() => this.render(), this.intervalMs);
70
+ this.timer.unref?.();
71
+ }
72
+ update(message) {
73
+ if (!this.enabled) {
74
+ return;
75
+ }
76
+ this.currentMessage = message;
77
+ this.render();
78
+ }
79
+ stop(finalMessage) {
80
+ if (!this.enabled) {
81
+ return;
82
+ }
83
+ if (this.timer) {
84
+ clearInterval(this.timer);
85
+ this.timer = undefined;
86
+ }
87
+ if (finalMessage) {
88
+ this.clearCurrentLine();
89
+ process.stderr.write(`${finalMessage}\n`);
90
+ return;
91
+ }
92
+ this.clearCurrentLine();
93
+ }
94
+ render() {
95
+ const frame = this.frames[this.frameIndex % this.frames.length];
96
+ this.frameIndex += 1;
97
+ this.clearCurrentLine();
98
+ process.stderr.write(`${frame} ${this.currentMessage}`);
99
+ }
100
+ clearCurrentLine() {
101
+ clearLine(process.stderr, 0);
102
+ cursorTo(process.stderr, 0);
103
+ }
29
104
  }
package/dist/ui.js CHANGED
@@ -58,6 +58,14 @@ export function json(value) {
58
58
  export function blankLine() {
59
59
  return new ParagraphBlock("", false);
60
60
  }
61
+ export function truncatedSqlBlock(sql) {
62
+ const lines = sql.split("\n");
63
+ const firstLine = lines[0];
64
+ const isLong = firstLine.length > 80 || lines.length > 1;
65
+ const truncated = firstLine.length > 80 ? firstLine.slice(0, 80) : firstLine;
66
+ const display = isLong ? truncated + dim(" …") : truncated;
67
+ return p(code(display));
68
+ }
61
69
  // Inline elements
62
70
  export function bold(text) {
63
71
  return pc.bold(text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {
@@ -42,6 +42,7 @@
42
42
  "commander": "^14.0.1",
43
43
  "dayjs": "^1.11.20",
44
44
  "execa": "^9.6.1",
45
+ "open": "^11.0.0",
45
46
  "picocolors": "^1.1.1",
46
47
  "yaml": "^2.8.3"
47
48
  },