@get-dx/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,31 +18,44 @@ Interactively login and install the AI agent skill:
18
18
  dx init
19
19
  ```
20
20
 
21
- The CLI uses `app.getdx.com` as the hostname and `api.getdx.com` as the api base url by default.
21
+ Show help text:
22
+
23
+ ```shell
24
+ dx --help
25
+ dx <subcommand> --help
26
+ ```
27
+
28
+ ### Customizing base URLs
22
29
 
23
- ### Dedicated or managed deployments
30
+ The CLI requires two base URLs to be configured. The default values are used for DX **cloud** deployments. Users of **dedicated** and **managed** deployments will need to specify these values explicitly when logging in.
24
31
 
25
- If your organization is on a dedicated or managed DX deployment, pass your custom hostname at init time:
32
+ | Value | How it is used | Env var | Default value |
33
+ | ---------------- | -------------------------------------------- | ----------------- | ----------------------- |
34
+ | **Web base URL** | Browser-based login and displaying web links | `DX_WEB_BASE_URL` | `https://app.getdx.com` |
35
+ | **API base URL** | Making each API request to DX | `DX_API_BASE_URL` | `https://api.getdx.com` |
36
+
37
+ #### For dedicated deployments
38
+
39
+ Set the env vars once when initializing:
26
40
 
27
41
  ```shell
28
- # Flag (dedicated instance at mycompany.getdx.io)
29
- dx init --host mycompany.getdx.io
42
+ # Interactive login
43
+ DX_WEB_BASE_URL="https://mycompany.getdx.io" DX_API_BASE_URL="https://api.mycompany.getdx.io" dx init
30
44
 
31
- # Env var — useful for CI or scripted setups
32
- DX_HOST=mycompany.getdx.io dx init
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
33
47
  ```
34
48
 
35
- The `DX_HOST` and `--host` values accept:
49
+ #### For managed deployments
36
50
 
37
- - `app.getdx.com` — DX Cloud (default)
38
- - `<account>.getdx.io` — DX dedicated (derives `https://api.<account>.getdx.io` automatically)
39
- - Any custom domain — you will be prompted for the separate API base URL
40
-
41
- Show help text:
51
+ Set the env vars once when initializing:
42
52
 
43
53
  ```shell
44
- dx --help
45
- dx <subcommand> --help
54
+ # Interactive login
55
+ 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
+
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
46
59
  ```
47
60
 
48
61
  ## Logging
package/dist/cli.js CHANGED
@@ -8,8 +8,10 @@ import { teamsCommand } from "./commands/teams.js";
8
8
  import { workflowsCommand } from "./commands/workflows.js";
9
9
  import { handleError } from "./commandHelpers.js";
10
10
  import cliPackage from "../package.json" with { type: "json" };
11
+ import { handleTemporaryBaseUrlMigration } from "./config.js";
11
12
  export async function run(argv = process.argv) {
12
13
  try {
14
+ handleTemporaryBaseUrlMigration(argv);
13
15
  const program = createProgram();
14
16
  if (argv.length <= 2) {
15
17
  program.outputHelp();
@@ -4,12 +4,11 @@ import { deleteToken, setToken } from "../secrets.js";
4
4
  import { renderJson } from "../renderers.js";
5
5
  import { renderAuthInfo, renderLoggedOut } from "./authRendering.js";
6
6
  import { getContext, wrapAction } from "../commandHelpers.js";
7
- import { persistBaseUrl, resolveBaseUrl, resolveUiUrl } from "../config.js";
7
+ import { persistBaseUrls, resolveApiBaseUrl, deriveBaseUrlsFromEnv, } from "../config.js";
8
8
  import { CliError } from "../errors.js";
9
9
  import { request } from "../http.js";
10
10
  import { loginViaBrowser } from "../loginViaBrowser.js";
11
- import { buildLogger, buildRuntime } from "../runtime.js";
12
- import cliPackage from "../../package.json" with { type: "json" };
11
+ import { buildRuntime } from "../runtime.js";
13
12
  import { maskToken } from "../ui.js";
14
13
  export function authCommand() {
15
14
  const auth = new Command()
@@ -20,7 +19,7 @@ export function authCommand() {
20
19
  .option("--token <token>", "Account web API token or personal access token. Omit this option to login interactively by web browser or pasting.")
21
20
  .action(wrapAction(async (commandOptions, command) => {
22
21
  const context = getContext(command);
23
- const baseUrl = resolveBaseUrl();
22
+ const { apiBaseUrl, webBaseUrl } = deriveBaseUrlsFromEnv();
24
23
  let token = commandOptions.token;
25
24
  if (!token) {
26
25
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
@@ -34,7 +33,7 @@ export function authCommand() {
34
33
  ],
35
34
  });
36
35
  if (method === "browser") {
37
- token = await loginViaBrowser(resolveUiUrl(baseUrl));
36
+ token = await loginViaBrowser(webBaseUrl);
38
37
  }
39
38
  else {
40
39
  token = await password({
@@ -46,45 +45,51 @@ export function authCommand() {
46
45
  throw new CliError("Account web API token or personal access token is required");
47
46
  }
48
47
  }
49
- const runtime = {
50
- baseUrl,
48
+ const runtime = buildRuntime(context, {
49
+ apiBaseUrl,
51
50
  token,
52
- context,
53
- version: cliPackage.version,
54
- logger: buildLogger(context),
55
- };
51
+ webBaseUrl,
52
+ });
56
53
  const response = await getAuthInfo(runtime);
57
- persistBaseUrl(baseUrl);
58
- setToken(baseUrl, token);
54
+ persistBaseUrls(apiBaseUrl, webBaseUrl);
55
+ setToken(apiBaseUrl, token);
59
56
  if (context.json) {
60
- renderJson({ ...response, base_url: baseUrl });
57
+ renderJson({
58
+ ...response,
59
+ api_base_url: apiBaseUrl,
60
+ web_base_url: webBaseUrl,
61
+ });
61
62
  return;
62
63
  }
63
- renderAuthInfo(response, token, baseUrl);
64
+ renderAuthInfo(response, token, apiBaseUrl);
64
65
  }));
65
66
  auth.command("logout").action(wrapAction(async (_options, command) => {
66
67
  const context = getContext(command);
67
- const baseUrl = resolveBaseUrl();
68
- deleteToken(baseUrl);
68
+ const apiBaseUrl = resolveApiBaseUrl();
69
+ deleteToken(apiBaseUrl);
69
70
  if (context.json) {
70
- renderJson({ ok: true, base_url: baseUrl, logged_out: true });
71
+ renderJson({ ok: true, api_base_url: apiBaseUrl, logged_out: true });
71
72
  }
72
73
  else {
73
- renderLoggedOut(baseUrl);
74
+ renderLoggedOut(apiBaseUrl);
74
75
  }
75
76
  }));
76
- auth.command("status").action(wrapAction(async (_options, command) => {
77
+ auth
78
+ .command("status")
79
+ .alias("info")
80
+ .action(wrapAction(async (_options, command) => {
77
81
  const runtime = buildRuntime(getContext(command));
78
82
  const response = await getAuthInfo(runtime);
79
83
  if (runtime.context.json) {
80
84
  renderJson({
81
85
  ...response,
82
86
  token: maskToken(runtime.token),
83
- base_url: runtime.baseUrl,
87
+ api_base_url: runtime.apiBaseUrl,
88
+ web_base_url: runtime.webBaseUrl,
84
89
  });
85
90
  }
86
91
  else {
87
- renderAuthInfo(response, runtime.token, runtime.baseUrl);
92
+ renderAuthInfo(response, runtime.token, runtime.apiBaseUrl);
88
93
  }
89
94
  }));
90
95
  return auth;
@@ -69,7 +69,7 @@ export function entitiesCommand() {
69
69
  renderJson(response);
70
70
  }
71
71
  else {
72
- renderEntity(response.entity);
72
+ renderEntity(response.entity, runtime);
73
73
  }
74
74
  }));
75
75
  entities
@@ -135,7 +135,7 @@ export function entitiesCommand() {
135
135
  renderJson(response);
136
136
  }
137
137
  else {
138
- renderEntity(response.entity, `${ui.success(ui.GLYPHS.CHECK)} Entity updated`);
138
+ renderEntity(response.entity, runtime, `${ui.success(ui.GLYPHS.CHECK)} Entity updated`);
139
139
  }
140
140
  }));
141
141
  entities
@@ -188,7 +188,7 @@ export function entitiesCommand() {
188
188
  const title = response.result === "created_new_entity"
189
189
  ? `${ui.success(ui.GLYPHS.CHECK)} Created new entity`
190
190
  : `${ui.success(ui.GLYPHS.CHECK)} Updated existing entity`;
191
- renderEntity(response.entity, title);
191
+ renderEntity(response.entity, runtime, title);
192
192
  }
193
193
  }));
194
194
  entities
@@ -217,7 +217,7 @@ export function entitiesCommand() {
217
217
  renderJson(processedResponse);
218
218
  }
219
219
  else {
220
- renderEntity(processedResponse.entity);
220
+ renderEntity(processedResponse.entity, runtime);
221
221
  }
222
222
  }));
223
223
  entities
@@ -260,7 +260,7 @@ export function entitiesCommand() {
260
260
  renderJson({ ...response, entities: processedEntities });
261
261
  }
262
262
  else {
263
- renderEntityList(processedEntities, response.response_metadata?.next_cursor ?? null);
263
+ renderEntityList(processedEntities, response.response_metadata?.next_cursor ?? null, runtime);
264
264
  }
265
265
  }));
266
266
  entities
@@ -326,7 +326,7 @@ export function entitiesCommand() {
326
326
  renderJson(response);
327
327
  }
328
328
  else {
329
- renderEntityScorecardList(response.scorecards, response.response_metadata?.next_cursor ?? null);
329
+ renderEntityScorecardList(response.scorecards, identifier, runtime, response.response_metadata?.next_cursor ?? null);
330
330
  }
331
331
  }));
332
332
  return entities;
@@ -1,7 +1,7 @@
1
1
  import dayjs from "dayjs";
2
2
  import { renderRichText } from "../../renderers.js";
3
3
  import * as ui from "../../ui.js";
4
- export function renderEntityList(entities, nextCursor) {
4
+ export function renderEntityList(entities, nextCursor, runtime) {
5
5
  const blocks = [ui.h1("Entities")];
6
6
  blocks.push(ui.p(`Displaying ${ui.bold(entities.length.toString())} entities.`));
7
7
  if (nextCursor) {
@@ -19,7 +19,7 @@ export function renderEntityList(entities, nextCursor) {
19
19
  }
20
20
  if (entity.identifier) {
21
21
  blocks.push(ui.h3("Core attributes"));
22
- blocks.push(...coreContent(entity));
22
+ blocks.push(...coreContent(entity, runtime));
23
23
  }
24
24
  if (entity.owner_teams || entity.owner_users) {
25
25
  blocks.push(ui.h3("Owners"));
@@ -36,11 +36,11 @@ export function renderEntityList(entities, nextCursor) {
36
36
  }
37
37
  renderRichText(blocks);
38
38
  }
39
- export function renderEntity(entity, title = "Entity Information") {
39
+ export function renderEntity(entity, runtime, title = "Entity Information") {
40
40
  renderRichText([
41
41
  ui.h1(title),
42
42
  entity.identifier
43
- ? [ui.h2("Core attributes"), ...coreContent(entity)]
43
+ ? [ui.h2("Core attributes"), ...coreContent(entity, runtime)]
44
44
  : null,
45
45
  entity.owner_teams ? [ui.h2("Owners"), ...ownersContent(entity)] : null,
46
46
  entity.properties
@@ -55,7 +55,7 @@ export function renderEntityDeleted(entity) {
55
55
  ui.p(`The entity ${ui.code(entity.identifier)} has been deleted.`),
56
56
  ]);
57
57
  }
58
- export function renderEntityScorecardList(scorecards, nextCursor) {
58
+ export function renderEntityScorecardList(scorecards, entityIdentifier, runtime, nextCursor) {
59
59
  const blocks = [ui.h1("Scorecards")];
60
60
  blocks.push(ui.p(`Displaying ${ui.bold(scorecards.length.toString())} scorecards.`));
61
61
  if (nextCursor) {
@@ -63,7 +63,7 @@ export function renderEntityScorecardList(scorecards, nextCursor) {
63
63
  }
64
64
  for (const scorecard of scorecards) {
65
65
  blocks.push(ui.h2(scorecard.name));
66
- blocks.push(...scorecardReportContent(scorecard));
66
+ blocks.push(...scorecardReportContent(scorecard, entityIdentifier, runtime));
67
67
  }
68
68
  renderRichText(blocks);
69
69
  }
@@ -79,8 +79,9 @@ export function renderEntityTaskList(tasks, nextCursor) {
79
79
  }
80
80
  renderRichText(blocks);
81
81
  }
82
- function scorecardReportContent(scorecard) {
82
+ function scorecardReportContent(scorecard, entityIdentifier, runtime) {
83
83
  const blocks = [];
84
+ blocks.push(ui.p(ui.link(ui.webLink(`/catalog/${entityIdentifier}/scorecards?expanded=${scorecard.id}`, runtime))));
84
85
  if (scorecard.type === "POINTS") {
85
86
  const total = scorecard.points_meta?.points_total ?? 0;
86
87
  const achieved = scorecard.points_meta?.points_achieved ?? 0;
@@ -179,7 +180,7 @@ function dueDateText(completeBy) {
179
180
  return formattedDate;
180
181
  }
181
182
  }
182
- function coreContent(entity) {
183
+ function coreContent(entity, runtime) {
183
184
  return [
184
185
  ui.dl([
185
186
  ui.dli("Name", [ui.p(entity.name ?? ui.dim("(None)"), false)]),
@@ -188,7 +189,10 @@ function coreContent(entity) {
188
189
  ui.dli("Created", [
189
190
  ui.p(ui.timestampSummary(entity.created_at), false),
190
191
  ]),
191
- ui.dli("Last updated", [ui.p(ui.timestampSummary(entity.updated_at))]),
192
+ ui.dli("Last updated", ui.timestampSummary(entity.updated_at)),
193
+ ui.dli("Web link", [
194
+ ui.p(ui.link(ui.webLink(`/catalog/${entity.identifier}`, runtime))),
195
+ ]),
192
196
  ui.dli("Description", [
193
197
  ui.p(entity.description ?? ui.dim("(None)"), false),
194
198
  ]),
@@ -53,7 +53,7 @@ export function entityTypesCommand() {
53
53
  renderJson({ ok: true, entity_type: response.entity_type });
54
54
  }
55
55
  else {
56
- renderEntityType(response.entity_type, `${ui.success(ui.GLYPHS.CHECK)} Entity type created`);
56
+ renderEntityType(response.entity_type, runtime, `${ui.success(ui.GLYPHS.CHECK)} Entity type created`);
57
57
  }
58
58
  }));
59
59
  entityTypes
@@ -106,7 +106,7 @@ export function entityTypesCommand() {
106
106
  renderJson(processedEntityType);
107
107
  }
108
108
  else {
109
- renderEntityType(processedEntityType);
109
+ renderEntityType(processedEntityType, runtime);
110
110
  }
111
111
  }));
112
112
  entityTypes
@@ -200,7 +200,7 @@ export function entityTypesCommand() {
200
200
  renderJson({ ...response, entity_types: processedEntityTypes });
201
201
  }
202
202
  else {
203
- renderEntityTypeList(processedEntityTypes, response.response_metadata?.next_cursor ?? null);
203
+ renderEntityTypeList(processedEntityTypes, response.response_metadata?.next_cursor ?? null, runtime);
204
204
  }
205
205
  }));
206
206
  entityTypes
@@ -245,7 +245,7 @@ export function entityTypesCommand() {
245
245
  renderJson({ ok: true, entity_type: response.entity_type });
246
246
  }
247
247
  else {
248
- renderEntityType(response.entity_type, `${ui.success(ui.GLYPHS.CHECK)} Entity type updated`);
248
+ renderEntityType(response.entity_type, runtime, `${ui.success(ui.GLYPHS.CHECK)} Entity type updated`);
249
249
  }
250
250
  }));
251
251
  return entityTypes;
@@ -1,6 +1,6 @@
1
1
  import { renderRichText } from "../../renderers.js";
2
2
  import * as ui from "../../ui.js";
3
- export function renderEntityTypeList(entityTypes, nextCursor) {
3
+ export function renderEntityTypeList(entityTypes, nextCursor, runtime) {
4
4
  const blocks = [ui.h1("Entity Types")];
5
5
  blocks.push(ui.p(`Displaying ${ui.bold(entityTypes.length.toString())} entity types.`));
6
6
  if (nextCursor) {
@@ -18,7 +18,7 @@ export function renderEntityTypeList(entityTypes, nextCursor) {
18
18
  }
19
19
  if (entityType.identifier) {
20
20
  blocks.push(ui.h3("Core attributes"));
21
- blocks.push(...coreContent(entityType));
21
+ blocks.push(...coreContent(entityType, runtime));
22
22
  }
23
23
  if (entityType.properties) {
24
24
  blocks.push(ui.h3("Properties"));
@@ -31,11 +31,11 @@ export function renderEntityTypeList(entityTypes, nextCursor) {
31
31
  }
32
32
  renderRichText(blocks);
33
33
  }
34
- export function renderEntityType(entityType, title = "Entity Type Information") {
34
+ export function renderEntityType(entityType, runtime, title = "Entity Type Information") {
35
35
  renderRichText([
36
36
  ui.h1(title),
37
37
  entityType.identifier
38
- ? [ui.h2("Core attributes"), ...coreContent(entityType)]
38
+ ? [ui.h2("Core attributes"), ...coreContent(entityType, runtime)]
39
39
  : null,
40
40
  entityType.properties
41
41
  ? [ui.h2("Properties"), ...propertiesContent(entityType.properties)]
@@ -51,25 +51,19 @@ export function renderEntityTypeDeleted(entityType) {
51
51
  ui.p(`The entity type ${ui.code(entityType.identifier)} has been deleted.`),
52
52
  ]);
53
53
  }
54
- function coreContent(entityType) {
54
+ function coreContent(entityType, runtime) {
55
55
  return [
56
56
  ui.dl([
57
- ui.dli("Name", [ui.p(entityType.name ?? ui.dim("(None)"), false)]),
58
- ui.dli("Identifier", [ui.p(ui.code(entityType.identifier), false)]),
59
- ui.dli("Description", [
60
- ui.p(entityType.description || ui.dim("(None)"), false),
61
- ]),
62
- ui.dli("Icon", [ui.p(entityType.icon ?? ui.dim("(None)"), false)]),
63
- ui.dli("Ordering", [
64
- ui.p(entityType.ordering?.toString() ?? ui.dim("(None)"), false),
65
- ]),
66
- ui.dli("Created", [
67
- ui.p(ui.timestampSummary(entityType.created_at), false),
68
- ]),
69
- ui.dli("Last updated", [
70
- ui.p(ui.timestampSummary(entityType.updated_at)),
71
- ]),
72
- ], { termWidth: 14 }),
57
+ ui.dli("Name", entityType.name ?? ui.dim("(None)")),
58
+ ui.dli("Identifier", ui.code(entityType.identifier)),
59
+ ui.dli("Description", entityType.description || ui.dim("(None)")),
60
+ ui.dli("Icon", entityType.icon ?? ui.dim("(None)")),
61
+ ui.dli("Ordering", entityType.ordering?.toString() ?? ui.dim("(None)")),
62
+ ui.dli("Created", ui.timestampSummary(entityType.created_at)),
63
+ ui.dli("Last updated", ui.timestampSummary(entityType.updated_at)),
64
+ ui.dli("Entities link", ui.link(ui.webLink(`/catalog?type=${entityType.identifier}`, runtime))),
65
+ ui.dli("Settings link", ui.link(ui.webLink(`/catalog/manage/entitytypes/${entityType.identifier}`, runtime))),
66
+ ], { termWidth: 15 }),
73
67
  ];
74
68
  }
75
69
  function propertiesContent(properties) {
@@ -1,10 +1,10 @@
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, select } from "@inquirer/prompts";
4
+ import { password, confirm, select } from "@inquirer/prompts";
5
5
  import { Command } from "commander";
6
6
  import { execa } from "execa";
7
- import { buildRuntime, buildRuntimeSafe } from "../runtime.js";
7
+ 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";
@@ -13,29 +13,8 @@ import { wrapAction } from "../commandHelpers.js";
13
13
  import { renderRichText } from "../renderers.js";
14
14
  import * as ui from "../ui.js";
15
15
  import { CliError } from "../errors.js";
16
- import { persistBaseUrl } from "../config.js";
16
+ import { deriveBaseUrlsFromEnv, persistBaseUrls } from "../config.js";
17
17
  import { setToken } from "../secrets.js";
18
- function parseHostname(raw) {
19
- const normalized = raw.trim().replace(/\/$/, "");
20
- if (!normalized || normalized === "app.getdx.com") {
21
- return { type: "cloud" };
22
- }
23
- try {
24
- const url = new URL(normalized.startsWith("http") ? normalized : `https://${normalized}`);
25
- const host = url.hostname;
26
- const dedicatedMatch = host.match(/^(.+)\.getdx\.io$/);
27
- if (dedicatedMatch) {
28
- return { type: "dedicated", accountName: dedicatedMatch[1] };
29
- }
30
- if (host) {
31
- return { type: "managed", uiBaseUrl: url.origin };
32
- }
33
- }
34
- catch {
35
- // fall through to invalid
36
- }
37
- return { type: "invalid" };
38
- }
39
18
  // FIXME: make this WAY more glam
40
19
  // Choose a more interesting ASCII art font from https://patorjk.com/software/taag/
41
20
  const WELCOME_BANNER = ui.indent(`\
@@ -51,14 +30,12 @@ export function initCommand() {
51
30
  const init = new Command()
52
31
  .name("init")
53
32
  .description("Initialize the DX CLI")
54
- .option("--host <hostname>", "DX hostname for dedicated or managed deployments (e.g. mycompany.getdx.io)")
55
33
  .action(wrapAction(async (commandOptions, command) => {
56
34
  const context = getContext(command);
57
35
  let runtime = buildRuntimeSafe(context);
58
36
  ensureInteractive();
59
37
  showWelcomeBanner();
60
- const host = commandOptions.host || process.env.DX_HOST;
61
- runtime = await ensureLoggedIn(runtime, host);
38
+ runtime = await ensureLoggedIn(context, runtime);
62
39
  await optionallySetupSkill(runtime);
63
40
  renderRichText([
64
41
  ui.blankLine(),
@@ -75,7 +52,7 @@ function ensureInteractive() {
75
52
  function showWelcomeBanner() {
76
53
  renderRichText([ui.p(ui.success(WELCOME_BANNER))]);
77
54
  }
78
- async function ensureLoggedIn(runtime, host) {
55
+ async function ensureLoggedIn(context, runtime) {
79
56
  renderRichText([ui.h1(`Checking if you are logged in...`), ui.blankLine()]);
80
57
  if (runtime) {
81
58
  let authInfo = null;
@@ -86,34 +63,17 @@ async function ensureLoggedIn(runtime, host) {
86
63
  // Do nothing, we don't mind if this fails
87
64
  }
88
65
  if (authInfo) {
89
- renderAuthInfo(authInfo, runtime.token, runtime.baseUrl);
66
+ renderAuthInfo(authInfo, runtime.token, runtime.apiBaseUrl);
90
67
  return runtime;
91
68
  }
92
69
  }
93
70
  renderRichText([ui.p(`You are not logged in yet.`), ui.blankLine()]);
94
- const parsed = host ? parseHostname(host) : { type: "cloud" };
95
- if (parsed.type === "invalid") {
96
- throw new CliError(`Could not recognize hostname "${host}". Expected app.getdx.com, <account>.getdx.io, or a custom domain.`);
97
- }
98
- switch (parsed.type) {
99
- case "cloud":
100
- return await attemptLogin("https://api.getdx.com", "https://app.getdx.com");
101
- case "dedicated": {
102
- const { accountName } = parsed;
103
- return await attemptLogin(`https://api.${accountName}.getdx.io`, `https://${accountName}.getdx.io`);
104
- }
105
- case "managed": {
106
- const apiBaseUrl = await input({
107
- message: "What is your API base URL?",
108
- });
109
- if (!apiBaseUrl) {
110
- throw new CliError("API base URL is required");
111
- }
112
- return await attemptLogin(apiBaseUrl, parsed.uiBaseUrl);
113
- }
114
- }
71
+ const { apiBaseUrl, webBaseUrl } = deriveBaseUrlsFromEnv();
72
+ return await attemptLogin(apiBaseUrl, webBaseUrl, context);
115
73
  }
116
- async function attemptLogin(apiBaseUrl, uiBaseUrl) {
74
+ async function attemptLogin(apiBaseUrl, webBaseUrl, context) {
75
+ const logger = buildLogger(context);
76
+ logger.debug("Attempting login", { apiBaseUrl, webBaseUrl });
117
77
  const method = await select({
118
78
  message: "How would you like to log in?",
119
79
  choices: [
@@ -123,7 +83,7 @@ async function attemptLogin(apiBaseUrl, uiBaseUrl) {
123
83
  });
124
84
  let token;
125
85
  if (method === "browser") {
126
- token = await loginViaBrowser(uiBaseUrl);
86
+ token = await loginViaBrowser(webBaseUrl);
127
87
  }
128
88
  else {
129
89
  token = await password({
@@ -134,17 +94,17 @@ async function attemptLogin(apiBaseUrl, uiBaseUrl) {
134
94
  if (!token) {
135
95
  throw new CliError("Account web API token is required");
136
96
  }
137
- const context = createEmptyContext();
138
97
  const runtime = buildRuntime(context, {
139
- baseUrl: apiBaseUrl,
98
+ apiBaseUrl,
140
99
  token,
100
+ webBaseUrl,
141
101
  });
142
102
  renderRichText([ui.blankLine(), ui.p(`Attempting login...`), ui.blankLine()]);
143
103
  const response = await getAuthInfo(runtime);
144
104
  if (!response.ok) {
145
105
  throw new CliError(`Login failed`);
146
106
  }
147
- persistBaseUrl(apiBaseUrl);
107
+ persistBaseUrls(apiBaseUrl, webBaseUrl);
148
108
  setToken(apiBaseUrl, token);
149
109
  renderAuthInfo(response, token, apiBaseUrl);
150
110
  return runtime;
@@ -195,8 +155,3 @@ async function optionallySetupSkill(runtime) {
195
155
  throw new CliError(`Failed to setup the DX skill: ${result.stderr}`);
196
156
  }
197
157
  }
198
- function createEmptyContext() {
199
- return {
200
- json: false,
201
- };
202
- }
@@ -53,7 +53,7 @@ export function scorecardsCommand() {
53
53
  renderJson({ ok: true, scorecard: response.scorecard });
54
54
  }
55
55
  else {
56
- renderScorecard(response.scorecard, null, `${ui.success(ui.GLYPHS.CHECK)} Scorecard created`);
56
+ renderScorecard(response.scorecard, null, runtime, `${ui.success(ui.GLYPHS.CHECK)} Scorecard created`);
57
57
  }
58
58
  }));
59
59
  scorecards
@@ -110,7 +110,7 @@ export function scorecardsCommand() {
110
110
  renderJson({ ok: true, scorecard: processedScorecard });
111
111
  }
112
112
  else {
113
- renderScorecard(response.scorecard, includeSections);
113
+ renderScorecard(response.scorecard, includeSections, runtime);
114
114
  }
115
115
  }));
116
116
  scorecards
@@ -211,7 +211,7 @@ export function scorecardsCommand() {
211
211
  renderJson({ ...response, scorecards: processedScorecards });
212
212
  }
213
213
  else {
214
- renderScorecardList(response.scorecards, response.response_metadata?.next_cursor ?? null, includeSections);
214
+ renderScorecardList(response.scorecards, response.response_metadata?.next_cursor ?? null, includeSections, runtime);
215
215
  }
216
216
  }));
217
217
  scorecards
@@ -256,7 +256,7 @@ export function scorecardsCommand() {
256
256
  renderJson({ ok: true, scorecard: response.scorecard });
257
257
  }
258
258
  else {
259
- renderScorecard(response.scorecard, null, `${ui.success(ui.GLYPHS.CHECK)} Scorecard updated`);
259
+ renderScorecard(response.scorecard, null, runtime, `${ui.success(ui.GLYPHS.CHECK)} Scorecard updated`);
260
260
  }
261
261
  }));
262
262
  return scorecards;
@@ -1,6 +1,6 @@
1
1
  import { renderRichText } from "../renderers.js";
2
2
  import * as ui from "../ui.js";
3
- export function renderScorecardList(scorecards, nextCursor, includeSections) {
3
+ export function renderScorecardList(scorecards, nextCursor, includeSections, runtime) {
4
4
  const shouldIncludeAll = includeSections === null;
5
5
  const blocks = [ui.h1("Scorecards")];
6
6
  blocks.push(ui.p(`Displaying ${ui.bold(scorecards.length.toString())} scorecards.`));
@@ -11,7 +11,7 @@ export function renderScorecardList(scorecards, nextCursor, includeSections) {
11
11
  blocks.push(ui.h2(`${scorecard.name} (${ui.code(scorecard.id)})`));
12
12
  if (shouldIncludeAll || includeSections.includes("core")) {
13
13
  blocks.push(ui.h3("Basic details"));
14
- blocks.push(...basicDetailsContent(scorecard));
14
+ blocks.push(...basicDetailsContent(scorecard, runtime));
15
15
  blocks.push(ui.h3("Entity filter"));
16
16
  blocks.push(...entityFilterContent(scorecard));
17
17
  if (scorecard.type === "LEVEL") {
@@ -34,12 +34,12 @@ export function renderScorecardList(scorecards, nextCursor, includeSections) {
34
34
  }
35
35
  renderRichText(blocks);
36
36
  }
37
- export function renderScorecard(scorecard, includeSections, title = "Scorecard Information") {
37
+ export function renderScorecard(scorecard, includeSections, runtime, title = "Scorecard Information") {
38
38
  const shouldIncludeAll = includeSections === null;
39
39
  const blocks = [ui.h1(title)];
40
40
  if (shouldIncludeAll || includeSections.includes("core")) {
41
41
  blocks.push(ui.h2("Basic details"));
42
- blocks.push(...basicDetailsContent(scorecard));
42
+ blocks.push(...basicDetailsContent(scorecard, runtime));
43
43
  blocks.push(ui.h2("Entity filter"));
44
44
  blocks.push(...entityFilterContent(scorecard));
45
45
  if (scorecard.type === "LEVEL") {
@@ -61,7 +61,7 @@ export function renderScorecard(scorecard, includeSections, title = "Scorecard I
61
61
  }
62
62
  renderRichText(blocks);
63
63
  }
64
- function basicDetailsContent(scorecard) {
64
+ function basicDetailsContent(scorecard, runtime) {
65
65
  const items = [
66
66
  ui.dli("ID", [ui.p(ui.code(scorecard.id), false)]),
67
67
  ui.dli("Name", [ui.p(scorecard.name, false)]),
@@ -74,6 +74,9 @@ function basicDetailsContent(scorecard) {
74
74
  ui.dli("Published", [
75
75
  ui.p(scorecard.published ? ui.success("Yes") : ui.dim("No (draft)"), false),
76
76
  ]),
77
+ ui.dli("Web link", [
78
+ ui.p(ui.link(ui.webLink(`/scorecards/${scorecard.id}`, runtime)), false),
79
+ ]),
77
80
  ];
78
81
  if (scorecard.tags && scorecard.tags.length > 0) {
79
82
  items.push(ui.dli("Tags", [
@@ -144,13 +144,13 @@ async function getStudioQueryResults(runtime, id) {
144
144
  return response.body;
145
145
  }
146
146
  async function downloadStudioQueryResultsCsv(runtime, id, filename) {
147
- const response = await fetchWithApiHeaders(runtime, `${runtime.baseUrl}/studio.queryRuns.results?id=${encodeURIComponent(id)}&format=csv`, { redirect: "manual" });
147
+ const response = await fetchWithApiHeaders(runtime, `${runtime.apiBaseUrl}/studio.queryRuns.results?id=${encodeURIComponent(id)}&format=csv`, { redirect: "manual" });
148
148
  if (response.status >= 300 && response.status < 400) {
149
149
  const location = response.headers.get("location");
150
150
  if (!location) {
151
151
  throw new HttpError("CSV download redirect did not include a Location header.", response.status);
152
152
  }
153
- const downloadResponse = await fetchResponse(new URL(location, runtime.baseUrl).toString(), {
153
+ const downloadResponse = await fetchResponse(new URL(location, runtime.apiBaseUrl).toString(), {
154
154
  method: "GET",
155
155
  headers: buildDownloadHeaders(runtime, "text/csv"),
156
156
  });
package/dist/config.js CHANGED
@@ -1,14 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- const DEFAULT_BASE_URL = "https://api.getdx.com";
5
- export function getConfigDir() {
6
- const xdg = process.env.XDG_CONFIG_HOME;
7
- if (xdg) {
8
- return path.join(xdg, "dx");
9
- }
10
- return path.join(os.homedir(), ".config", "dx");
11
- }
4
+ import { CliError, EXIT_CODES } from "./errors.js";
5
+ const DEFAULT_API_BASE_URL = "https://api.getdx.com";
6
+ const DEFAULT_WEB_BASE_URL = "https://app.getdx.com";
7
+ // --- Config file I/O ---------------------------------------------------------
12
8
  export function getConfigPath() {
13
9
  return path.join(getConfigDir(), "config.json");
14
10
  }
@@ -18,38 +14,184 @@ export function readConfig() {
18
14
  return {};
19
15
  }
20
16
  const content = fs.readFileSync(configPath, "utf8");
21
- return JSON.parse(content);
17
+ const raw = JSON.parse(content);
18
+ const api = raw.apiBaseUrl;
19
+ const web = raw.webBaseUrl;
20
+ const stored = {};
21
+ if (web) {
22
+ stored.webBaseUrl = web;
23
+ }
24
+ if (api) {
25
+ stored.apiBaseUrl = api;
26
+ }
27
+ return stored;
28
+ }
29
+ /**
30
+ * Responsible for handling the migration from `baseUrl` to `apiBaseUrl` and `webBaseUrl`,
31
+ * so the upgrade to 0.3.x will be non-breaking for `cloud` and `dedicated` deployments.
32
+ *
33
+ * `managed` deployments will encounter an error and will have to reauthenticate.
34
+ *
35
+ * TODO: delete after 2026-07-01.
36
+ */
37
+ export function handleTemporaryBaseUrlMigration(argv) {
38
+ if (!process.env.VITEST) {
39
+ // Running tests - we don't want our permanent tests to exercise this logic
40
+ return;
41
+ }
42
+ const COMMANDS_TO_SKIP_MIGRATION = ["login", "logout", "init"];
43
+ if (COMMANDS_TO_SKIP_MIGRATION.some((command) => argv.includes(command))) {
44
+ // Logging in or logging out: we need to ignore the current config file's contents
45
+ return;
46
+ }
47
+ const configPath = getConfigPath();
48
+ if (!fs.existsSync(configPath)) {
49
+ // No config file, no need to migrate
50
+ return;
51
+ }
52
+ const content = fs.readFileSync(configPath, "utf8");
53
+ const raw = JSON.parse(content);
54
+ const api = raw.apiBaseUrl;
55
+ const web = raw.webBaseUrl;
56
+ if (api !== undefined && web !== undefined) {
57
+ // Already migrated, no need to do anything
58
+ return;
59
+ }
60
+ if (raw.baseUrl) {
61
+ // Legacy baseUrl is present, migrate to apiBaseUrl and webBaseUrl
62
+ const apiBaseUrl = raw.baseUrl;
63
+ let webBaseUrl;
64
+ if (apiBaseUrl.endsWith(".getdx.com")) {
65
+ // Migrate `cloud`
66
+ webBaseUrl = "https://app.getdx.com";
67
+ writeConfig({
68
+ apiBaseUrl: normalizeUrl(apiBaseUrl),
69
+ webBaseUrl: normalizeUrl(webBaseUrl),
70
+ });
71
+ return;
72
+ }
73
+ const dedicatedMatch = apiBaseUrl.match(/^api\.(.+)\.getdx\.io$/);
74
+ if (dedicatedMatch) {
75
+ // Migrate `dedicated`
76
+ webBaseUrl = `https://${dedicatedMatch[1]}.getdx.io`;
77
+ writeConfig({
78
+ apiBaseUrl: normalizeUrl(apiBaseUrl),
79
+ webBaseUrl: normalizeUrl(webBaseUrl),
80
+ });
81
+ return;
82
+ }
83
+ // Unable to automatically migrate `managed`
84
+ throw new CliError("Your on-disk DX CLI config is missing apiBaseUrl and webBaseUrl. Run `dx auth logout`, then login again via `dx init` or `dx auth login`.");
85
+ }
22
86
  }
23
87
  export function writeConfig(config) {
24
88
  fs.mkdirSync(getConfigDir(), { recursive: true });
25
89
  fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + "\n", "utf8");
26
90
  }
27
- export function resolveBaseUrl() {
28
- if (process.env.DX_BASE_URL) {
29
- return normalizeBaseUrl(process.env.DX_BASE_URL);
91
+ export function persistBaseUrls(apiBaseUrl, webBaseUrl) {
92
+ writeConfig({
93
+ ...readConfig(),
94
+ apiBaseUrl: normalizeUrl(apiBaseUrl),
95
+ webBaseUrl: normalizeUrl(webBaseUrl),
96
+ });
97
+ }
98
+ /** Strips trailing slashes from an HTTP(S) base URL string. */
99
+ export function normalizeUrl(url) {
100
+ return url.replace(/\/+$/, "");
101
+ }
102
+ function getConfigDir() {
103
+ const xdg = process.env.XDG_CONFIG_HOME;
104
+ if (xdg) {
105
+ return path.join(xdg, "dx");
30
106
  }
31
- return normalizeBaseUrl(readConfig().baseUrl || DEFAULT_BASE_URL);
107
+ return path.join(os.homedir(), ".config", "dx");
32
108
  }
109
+ // --- Resolving URLs at command runtime ------------------------------------------------------
33
110
  /**
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.
111
+ * Resolved API base URL for HTTP requests.
112
+ * Precedence: `DX_API_BASE_URL`, then persisted `apiBaseUrl`, then DX Cloud default.
36
113
  */
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";
114
+ export function resolveApiBaseUrl() {
115
+ if (process.env.DX_API_BASE_URL) {
116
+ return normalizeUrl(process.env.DX_API_BASE_URL);
43
117
  }
44
- const dedicated = host.match(/^api\.(.+)\.getdx\.io$/);
45
- if (dedicated) {
46
- return `https://${dedicated[1]}.getdx.io`;
118
+ return normalizeUrl(readConfig().apiBaseUrl || DEFAULT_API_BASE_URL);
119
+ }
120
+ /**
121
+ * Resolved web-app base URL for CLI deep links and browser OAuth.
122
+ * Precedence: `DX_WEB_BASE_URL`, persisted `webBaseUrl`, then DX Cloud default.
123
+ */
124
+ export function resolveWebBaseUrl() {
125
+ if (process.env.DX_WEB_BASE_URL) {
126
+ return normalizeUrl(process.env.DX_WEB_BASE_URL);
47
127
  }
48
- return url.origin;
128
+ return normalizeUrl(readConfig().webBaseUrl || DEFAULT_WEB_BASE_URL);
49
129
  }
50
- export function persistBaseUrl(baseUrl) {
51
- writeConfig({ ...readConfig(), baseUrl: normalizeBaseUrl(baseUrl) });
130
+ // --- Deriving base URLs at login time -------------------------------------
131
+ /**
132
+ * Derive the appropriate `apiBaseUrl` and `webBaseUrl` to persist into config,
133
+ * considering the `DX_API_BASE_URL` and `DX_WEB_BASE_URL` env vars if they are present.
134
+ *
135
+ * If the env vars are not set, we assume the user is configuring for a `cloud` deployment.
136
+ *
137
+ * If they are set, we parse them to determine whether the deployment is `cloud`, `dedicated`, or `managed`.
138
+ *
139
+ * If we fail to derive the base URLs, a `CliError` is thrown.
140
+ */
141
+ export function deriveBaseUrlsFromEnv() {
142
+ const apiBaseUrlFromEnv = process.env.DX_API_BASE_URL;
143
+ const webBaseUrlFromEnv = process.env.DX_WEB_BASE_URL;
144
+ if (apiBaseUrlFromEnv && !webBaseUrlFromEnv) {
145
+ throw new CliError("DX_WEB_BASE_URL must be set when DX_API_BASE_URL is set", EXIT_CODES.ARGUMENT_ERROR);
146
+ }
147
+ const parsed = webBaseUrlFromEnv
148
+ ? parseWebBaseUrl(webBaseUrlFromEnv)
149
+ : { type: "cloud" };
150
+ switch (parsed.type) {
151
+ case "cloud":
152
+ return {
153
+ apiBaseUrl: "https://api.getdx.com",
154
+ webBaseUrl: "https://app.getdx.com",
155
+ };
156
+ case "dedicated": {
157
+ const { accountName } = parsed;
158
+ return {
159
+ apiBaseUrl: `https://api.${accountName}.getdx.io`,
160
+ webBaseUrl: `https://${accountName}.getdx.io`,
161
+ };
162
+ }
163
+ case "managed": {
164
+ const apiBaseUrl = process.env.DX_API_BASE_URL;
165
+ if (!apiBaseUrl) {
166
+ throw new CliError("DX_API_BASE_URL must be specified when authenticating with a managed deployment");
167
+ }
168
+ return {
169
+ apiBaseUrl,
170
+ webBaseUrl: parsed.webAppUrl,
171
+ };
172
+ }
173
+ case "invalid":
174
+ throw new CliError(`Could not recognize web base URL "${parsed.input}". Expected https://app.getdx.com, https://<account>.getdx.io, or a custom domain.`);
175
+ }
52
176
  }
53
- function normalizeBaseUrl(baseUrl) {
54
- return baseUrl.replace(/\/+$/, "");
177
+ function parseWebBaseUrl(raw) {
178
+ const normalized = raw.trim().replace(/\/$/, "");
179
+ if (!normalized || normalized === "app.getdx.com") {
180
+ return { type: "cloud" };
181
+ }
182
+ try {
183
+ const url = new URL(normalized.startsWith("http") ? normalized : `https://${normalized}`);
184
+ const host = url.hostname;
185
+ const dedicatedMatch = host.match(/^(.+)\.getdx\.io$/);
186
+ if (dedicatedMatch) {
187
+ return { type: "dedicated", accountName: dedicatedMatch[1] };
188
+ }
189
+ if (host) {
190
+ return { type: "managed", webAppUrl: url.origin };
191
+ }
192
+ }
193
+ catch {
194
+ // fall through to invalid
195
+ }
196
+ return { type: "invalid", input: raw };
55
197
  }
package/dist/http.js CHANGED
@@ -23,7 +23,7 @@ export async function request(runtime, route, options = {}) {
23
23
  query.set(key, String(value));
24
24
  }
25
25
  });
26
- const url = `${runtime.baseUrl}${route}${query.size > 0 ? `?${query.toString()}` : ""}`;
26
+ const url = `${runtime.apiBaseUrl}${route}${query.size > 0 ? `?${query.toString()}` : ""}`;
27
27
  const requestBody = options.body !== undefined ? JSON.stringify(options.body) : undefined;
28
28
  runtime.logger.debug("Sending HTTP request", {
29
29
  body: options.body ?? null,
@@ -4,7 +4,7 @@ import { AuthCodeCallbackServer, } from "./authCodeCallbackServer.js";
4
4
  import { CliError } from "./errors.js";
5
5
  import { renderRichText } from "./renderers.js";
6
6
  import * as ui from "./ui.js";
7
- export async function loginViaBrowser(uiBaseUrl) {
7
+ export async function loginViaBrowser(webBaseUrl) {
8
8
  const server = new AuthCodeCallbackServer();
9
9
  await server.start();
10
10
  const callbackUri = server.getAddress();
@@ -17,7 +17,7 @@ export async function loginViaBrowser(uiBaseUrl) {
17
17
  code_challenge: codeChallenge,
18
18
  code_challenge_method: "S256",
19
19
  });
20
- const authUrl = `${uiBaseUrl}/cli/auth?${params.toString()}`;
20
+ const authUrl = `${webBaseUrl}/cli/auth?${params.toString()}`;
21
21
  renderRichText([
22
22
  ui.p(`Opening your browser to complete authentication...`),
23
23
  ui.blankLine(),
@@ -42,7 +42,7 @@ export async function loginViaBrowser(uiBaseUrl) {
42
42
  error: new CliError("Authentication failed: state mismatch"),
43
43
  };
44
44
  }
45
- const tokenExchangeResponse = await exchangeCodeForToken(uiBaseUrl, code, codeVerifier);
45
+ const tokenExchangeResponse = await exchangeCodeForToken(webBaseUrl, code, codeVerifier);
46
46
  if (!tokenExchangeResponse.ok) {
47
47
  return {
48
48
  type: "ERROR",
@@ -64,8 +64,8 @@ export async function loginViaBrowser(uiBaseUrl) {
64
64
  function validateState(stateFromResponse, originalState) {
65
65
  return stateFromResponse === originalState;
66
66
  }
67
- async function exchangeCodeForToken(baseUrl, code, codeVerifier) {
68
- return fetch(`${baseUrl}/cli/token`, {
67
+ async function exchangeCodeForToken(webBaseUrl, code, codeVerifier) {
68
+ return fetch(`${webBaseUrl}/cli/token`, {
69
69
  method: "POST",
70
70
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
71
71
  body: new URLSearchParams({ code, code_verifier: codeVerifier }),
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { resolveBaseUrl } from "./config.js";
1
+ import { resolveApiBaseUrl, resolveWebBaseUrl } from "./config.js";
2
2
  import { CliError } from "./errors.js";
3
3
  import { getToken } from "./secrets.js";
4
4
  import { createLogger } from "./logger.js";
@@ -7,13 +7,15 @@ export function buildLogger(context) {
7
7
  return createLogger({ json: context.json || !process.stderr.isTTY });
8
8
  }
9
9
  export function buildRuntime(context, overrides) {
10
- const baseUrl = overrides?.baseUrl ?? resolveBaseUrl();
11
- const token = overrides?.token ?? getToken(baseUrl);
10
+ const apiBaseUrl = overrides?.apiBaseUrl ?? resolveApiBaseUrl();
11
+ const webBaseUrl = overrides?.webBaseUrl ?? resolveWebBaseUrl();
12
+ const token = overrides?.token ?? getToken(apiBaseUrl);
12
13
  if (!token) {
13
14
  throw new CliError("No API token configured. Run `dx auth login --token <token>` or set DX_API_TOKEN.");
14
15
  }
15
16
  return {
16
- baseUrl,
17
+ apiBaseUrl,
18
+ webBaseUrl,
17
19
  token,
18
20
  context,
19
21
  version: cliPackage.version,
@@ -21,13 +23,15 @@ export function buildRuntime(context, overrides) {
21
23
  };
22
24
  }
23
25
  export function buildRuntimeSafe(context, overrides) {
24
- const baseUrl = overrides?.baseUrl ?? resolveBaseUrl();
25
- const token = overrides?.token ?? getToken(baseUrl);
26
+ const apiBaseUrl = overrides?.apiBaseUrl ?? resolveApiBaseUrl();
27
+ const webBaseUrl = overrides?.webBaseUrl ?? resolveWebBaseUrl();
28
+ const token = overrides?.token ?? getToken(apiBaseUrl);
26
29
  if (!token) {
27
30
  return null;
28
31
  }
29
32
  return {
30
- baseUrl,
33
+ apiBaseUrl,
34
+ webBaseUrl,
31
35
  token,
32
36
  context,
33
37
  version: cliPackage.version,
package/dist/ui.js CHANGED
@@ -121,3 +121,6 @@ export function maskToken(token) {
121
121
  }
122
122
  return `${token.slice(0, 4)}${"*".repeat(token.length - 8)}${token.slice(-4)}`;
123
123
  }
124
+ export function webLink(path, runtime) {
125
+ return `${runtime.webBaseUrl}${path}`;
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {