@get-dx/cli 0.8.0 → 0.10.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.
@@ -1,9 +1,10 @@
1
1
  import { Command } from "commander";
2
- import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../commandHelpers.js";
2
+ import { createExampleText, getContext, parseCommaSeparatedValues, parsePositiveIntOption, wrapAction, } from "../commandHelpers.js";
3
3
  import { CliError, EXIT_CODES } from "../errors.js";
4
4
  import { request } from "../http.js";
5
5
  import { renderJson, renderRichText } from "../renderers.js";
6
6
  import { buildRuntime } from "../runtime.js";
7
+ import { formatTeamMember } from "./teams.js";
7
8
  import * as ui from "../ui.js";
8
9
  export function snapshotsCommand() {
9
10
  const snapshots = new Command()
@@ -135,6 +136,89 @@ export function snapshotsCommand() {
135
136
  renderSnapshots(response.snapshots, runtime);
136
137
  }
137
138
  }));
139
+ const snapshotTeams = new Command()
140
+ .name("snapshotTeams")
141
+ .description("Work with snapshot teams");
142
+ snapshotTeams
143
+ .command("list")
144
+ .description("List teams for a snapshot")
145
+ .option("--id <id>", "The unique ID of the snapshot")
146
+ .addHelpText("afterAll", createExampleText([
147
+ {
148
+ label: "List teams for a snapshot",
149
+ command: "dx snapshots snapshotTeams list --id MjUyNbaY",
150
+ },
151
+ {
152
+ label: "List snapshot teams as JSON",
153
+ command: "dx --json snapshots snapshotTeams list --id MjUyNbaY",
154
+ },
155
+ ]))
156
+ .action(wrapAction(async (options, command) => {
157
+ const snapshotId = parseRequiredTextOption(options.id, "--id");
158
+ const runtime = await buildRuntime(getContext(command));
159
+ const response = await listSnapshotTeams(runtime, snapshotId);
160
+ if (runtime.context.json) {
161
+ renderJson(response);
162
+ }
163
+ else {
164
+ renderSnapshotTeams(response.snapshot_teams, snapshotId, runtime);
165
+ }
166
+ }));
167
+ snapshots.addCommand(snapshotTeams);
168
+ const workflowScores = new Command()
169
+ .name("workflowScores")
170
+ .description("Work with snapshot workflow scores");
171
+ workflowScores
172
+ .command("list")
173
+ .description("List workflow scores and details for a snapshot")
174
+ .option("--id <id>", "The unique ID of the snapshot")
175
+ .option("--team-ids <ids>", "Comma-separated team IDs")
176
+ .option("--snapshot-team-ids <ids>", "Comma-separated snapshot team IDs")
177
+ .option("--group-ids <ids>", "Comma-separated group IDs")
178
+ .option("--branch-ids <ids>", "Comma-separated branch IDs")
179
+ .option("--attribute-ids <ids>", "Comma-separated attribute IDs")
180
+ .addHelpText("afterAll", createExampleText([
181
+ {
182
+ label: "List workflow scores for a snapshot",
183
+ command: "dx snapshots workflowScores list --id MjUyNbaY",
184
+ },
185
+ {
186
+ label: "List snapshot workflow scores as JSON",
187
+ command: "dx --json snapshots workflowScores list --id MjUyNbaY",
188
+ },
189
+ {
190
+ label: "Filter workflow scores by team IDs",
191
+ command: "dx snapshots workflowScores list --id MjUyNbaY --team-ids MTExNDg5,MTExNDg4",
192
+ },
193
+ {
194
+ label: "Filter workflow scores by snapshot team IDs",
195
+ command: "dx snapshots workflowScores list --id MjUyNbaY --snapshot-team-ids NTcxMDM",
196
+ },
197
+ {
198
+ label: "Filter workflow scores by group IDs",
199
+ command: "dx snapshots workflowScores list --id MjUyNbaY --group-ids MzYyMjM",
200
+ },
201
+ {
202
+ label: "Filter workflow scores by attribute IDs",
203
+ command: "dx snapshots workflowScores list --id MjUyNbaY --attribute-ids NzI4OA",
204
+ },
205
+ {
206
+ label: "Filter workflow scores by branch IDs",
207
+ command: "dx snapshots workflowScores list --id MjUyNbaY --branch-ids MQ",
208
+ },
209
+ ]))
210
+ .action(wrapAction(async (options, command) => {
211
+ const snapshotId = parseRequiredTextOption(options.id, "--id");
212
+ const runtime = await buildRuntime(getContext(command));
213
+ const response = await listSnapshotWorkflowScores(runtime, snapshotId, options);
214
+ if (runtime.context.json) {
215
+ renderJson(response);
216
+ }
217
+ else {
218
+ renderSnapshotWorkflowScores(response, snapshotId, options, runtime);
219
+ }
220
+ }));
221
+ snapshots.addCommand(workflowScores);
138
222
  return snapshots;
139
223
  }
140
224
  async function listSnapshotDriverComments(runtime, options) {
@@ -159,6 +243,64 @@ async function listSnapshotCsatComments(runtime, options) {
159
243
  });
160
244
  return response.body;
161
245
  }
246
+ async function listSnapshotTeams(runtime, snapshotId) {
247
+ const response = await request(runtime, "/snapshots.snapshotTeams.list", {
248
+ method: "GET",
249
+ query: { snapshot_id: snapshotId },
250
+ });
251
+ return response.body;
252
+ }
253
+ async function listSnapshotWorkflowScores(runtime, snapshotId, options) {
254
+ const response = await request(runtime, "/snapshots.workflowScores.list", {
255
+ method: "GET",
256
+ query: {
257
+ snapshot_id: snapshotId,
258
+ ...snapshotFilterQuery(options, "apiKey"),
259
+ },
260
+ });
261
+ return response.body;
262
+ }
263
+ // The web app names some filter params differently from the API. Attributes
264
+ // have no webKey because the web app only accepts decoded tag IDs.
265
+ const SNAPSHOT_FILTER_KEYS = [
266
+ { option: "teamIds", apiKey: "team_ids", webKey: "team_ids" },
267
+ {
268
+ option: "snapshotTeamIds",
269
+ apiKey: "snapshot_team_ids",
270
+ webKey: "snapshot_team_ids",
271
+ },
272
+ { option: "groupIds", apiKey: "group_ids", webKey: "group_ids" },
273
+ { option: "branchIds", apiKey: "branch_ids", webKey: "branch" },
274
+ { option: "attributeIds", apiKey: "attribute_ids" },
275
+ ];
276
+ // The web app can't combine groups with snapshot teams (selecting a group
277
+ // clears snapshot teams) and only selects one branch at a time, so drop those
278
+ // filters from the link and report which ones were left out
279
+ function snapshotWebFilters(options) {
280
+ const webOptions = { ...options };
281
+ const omittedFlags = [];
282
+ if (parseCommaSeparatedValues(options.groupIds) &&
283
+ parseCommaSeparatedValues(options.snapshotTeamIds)) {
284
+ webOptions.snapshotTeamIds = undefined;
285
+ omittedFlags.push("--snapshot-team-ids");
286
+ }
287
+ if ((parseCommaSeparatedValues(options.branchIds)?.length ?? 0) > 1) {
288
+ webOptions.branchIds = undefined;
289
+ omittedFlags.push("--branch-ids");
290
+ }
291
+ if (parseCommaSeparatedValues(options.attributeIds)) {
292
+ omittedFlags.push("--attribute-ids");
293
+ }
294
+ return { query: snapshotFilterQuery(webOptions, "webKey"), omittedFlags };
295
+ }
296
+ function snapshotFilterQuery(options, target) {
297
+ return Object.fromEntries(SNAPSHOT_FILTER_KEYS.flatMap((keys) => {
298
+ const key = keys[target];
299
+ return key
300
+ ? [[key, parseCommaSeparatedValues(options[keys.option])?.join(",")]]
301
+ : [];
302
+ }));
303
+ }
162
304
  async function getSnapshotInfo(runtime, snapshotId) {
163
305
  const response = await request(runtime, "/snapshots.info", {
164
306
  method: "GET",
@@ -237,6 +379,22 @@ function renderSnapshotCsatComments(comments, nextCursor, snapshotId, runtime) {
237
379
  }
238
380
  renderRichText(blocks);
239
381
  }
382
+ function renderSnapshotTeams(teams, snapshotId, runtime) {
383
+ const blocks = [ui.h1("Snapshot Teams")];
384
+ blocks.push(ui.p(`Displaying ${ui.bold(teams.length.toString())} teams.`));
385
+ blocks.push(ui.p(`Snapshot Team IDs are only valid within this snapshot and feed ${ui.code("snapshot_team_ids")} score filters. Use ${ui.code("team_id")} with ${ui.code("dx teams info --team-id")}.`));
386
+ blocks.push(ui.p(ui.link(snapshotWebLink(snapshotId, "drivers", runtime))));
387
+ if (teams.length === 0) {
388
+ blocks.push(ui.p(ui.dim("(None)")));
389
+ renderRichText(blocks);
390
+ return;
391
+ }
392
+ const rootTeams = buildSnapshotTeamTree(teams);
393
+ rootTeams.forEach((rootTeam) => {
394
+ appendSnapshotTeamBlock(blocks, rootTeam, 0);
395
+ });
396
+ renderRichText(blocks);
397
+ }
240
398
  function renderSnapshotInfo(snapshot, snapshotId, runtime) {
241
399
  const teamScores = snapshot.team_scores ?? [];
242
400
  const blocks = [ui.h1("Snapshot Information")];
@@ -261,6 +419,70 @@ function renderSnapshotInfo(snapshot, snapshotId, runtime) {
261
419
  }
262
420
  renderRichText(blocks);
263
421
  }
422
+ function renderSnapshotWorkflowScores(response, snapshotId, filterOptions, runtime) {
423
+ const { results } = response;
424
+ const filterDetails = workflowScoresFilterDetails(response.filters);
425
+ const webFilters = snapshotWebFilters(filterOptions);
426
+ const blocks = [ui.h1("Snapshot Workflow Scores")];
427
+ if (filterDetails.length > 0) {
428
+ blocks.push(ui.dl(filterDetails, { termWidth: 17 }));
429
+ }
430
+ blocks.push(ui.p(`Displaying ${ui.bold(results.length.toString())} ${results.length === 1 ? "workflow" : "workflows"}.`), ui.p(ui.link(
431
+ // Sort the web view by workflow name, ascending (sb = sort by, sr = sort reversed)
432
+ snapshotWebLink(snapshotId, "workflow", runtime, {
433
+ sb: "name",
434
+ sr: "false",
435
+ ...webFilters.query,
436
+ }))));
437
+ if (webFilters.omittedFlags.length > 0) {
438
+ blocks.push(ui.p(ui.dim(`Web link omits ${webFilters.omittedFlags.join(" and ")}; the web app can't apply those filters.`)));
439
+ }
440
+ if (results.length === 0) {
441
+ blocks.push(ui.p(ui.dim("(None)")));
442
+ renderRichText(blocks);
443
+ return;
444
+ }
445
+ for (const result of results) {
446
+ blocks.push(ui.h2(result.workflow.name));
447
+ blocks.push(ui.p(result.workflow.prompt));
448
+ if (result.hidden) {
449
+ blocks.push(ui.p(ui.dim("(Hidden)")));
450
+ continue;
451
+ }
452
+ const benchmark = result.workflow.benchmark;
453
+ const benchmarkValue = benchmark
454
+ ? `${benchmark.name} (${benchmark.year}) p50=${benchmark.p_50}, p75=${benchmark.p_75}, p90=${benchmark.p_90}`
455
+ : ui.dim("(None)");
456
+ blocks.push(ui.dl([
457
+ ui.dli("Workflow ID", result.workflow.id),
458
+ ui.dli("Score", formatNumber(result.score)),
459
+ optionalDetail("Respondents", formatNumber(result.total_respondents), result.total_respondents),
460
+ optionalDetail("Cohort size", formatNumber(result.total_cohort), result.total_cohort),
461
+ ui.dli("Unit", formatText(result.workflow.unit)),
462
+ ui.dli("Lower is better", result.workflow.lower_is_better ? "Yes" : "No"),
463
+ ui.dli("Benchmark", benchmarkValue),
464
+ ].filter((detail) => detail !== null), { termWidth: 17 }));
465
+ blocks.push(ui.h3("Distribution"));
466
+ blocks.push(ui.p(formatWorkflowDistributionTable(result.distribution)));
467
+ }
468
+ renderRichText(blocks);
469
+ }
470
+ function workflowScoresFilterDetails(filters) {
471
+ const names = (items) => items.map((item) => item.name).join(", ");
472
+ const attributes = filters.attribute_groups.flatMap((group) => group.attributes.map((attribute) => ({
473
+ id: attribute.id,
474
+ name: `${group.name}: ${attribute.name}`,
475
+ })));
476
+ return [
477
+ ["Teams", filters.teams],
478
+ ["Snapshot teams", filters.snapshot_teams],
479
+ ["Groups", filters.groups],
480
+ ["Branches", filters.branches],
481
+ ["Attributes", attributes],
482
+ ]
483
+ .filter(([, items]) => items.length > 0)
484
+ .map(([term, items]) => ui.dli(term, names(items)));
485
+ }
264
486
  function renderSnapshots(snapshots, runtime) {
265
487
  const blocks = [ui.h1("Snapshots")];
266
488
  blocks.push(ui.p(`Displaying ${ui.bold(snapshots.length.toString())} snapshots.`));
@@ -298,6 +520,69 @@ function extractCsatComments(response) {
298
520
  response.comments ??
299
521
  []);
300
522
  }
523
+ class SnapshotTeamTreeNode {
524
+ team;
525
+ children = [];
526
+ constructor(team) {
527
+ this.team = team;
528
+ }
529
+ }
530
+ function buildSnapshotTeamTree(teams) {
531
+ const nodes = new Map();
532
+ teams.forEach((team) => {
533
+ nodes.set(team.id, new SnapshotTeamTreeNode(team));
534
+ });
535
+ const rootTeams = [];
536
+ teams.forEach((team) => {
537
+ const currentNode = nodes.get(team.id);
538
+ if (currentNode.team.parent_id === null) {
539
+ // It's a top-level root node
540
+ rootTeams.push(currentNode);
541
+ }
542
+ else {
543
+ // Append it to its parent's children array
544
+ nodes.get(currentNode.team.parent_id).children.push(currentNode);
545
+ }
546
+ });
547
+ const sortNodes = (items) => {
548
+ items.sort((left, right) => compareSnapshotTeams(left.team, right.team));
549
+ for (const item of items) {
550
+ sortNodes(item.children);
551
+ }
552
+ };
553
+ sortNodes(rootTeams);
554
+ return rootTeams;
555
+ }
556
+ function compareSnapshotTeams(left, right) {
557
+ const leftName = left.name.toLocaleLowerCase();
558
+ const rightName = right.name.toLocaleLowerCase();
559
+ if (leftName !== rightName) {
560
+ return leftName.localeCompare(rightName);
561
+ }
562
+ // If the names are the same, compare the IDs
563
+ const leftId = left.id.toLocaleLowerCase();
564
+ const rightId = right.id.toLocaleLowerCase();
565
+ return leftId.localeCompare(rightId);
566
+ }
567
+ function appendSnapshotTeamBlock(blocks, node, depth) {
568
+ blocks.push(depth === 0
569
+ ? ui.h2(node.team.name)
570
+ : ui.h3(`${"↳".repeat(depth)} ${node.team.name}`));
571
+ const details = [
572
+ optionalDetail("Snapshot Team ID", formatText(node.team.id), node.team.id),
573
+ optionalDetail("Team ID", formatText(node.team.team_id), node.team.team_id),
574
+ // Match `dx teams info`: show "(None)" when the API returns a null manager,
575
+ // omit the row only when the field is absent entirely.
576
+ ui.dli("Manager", formatTeamMember(node.team.manager)),
577
+ optionalDetail("Parent ID", formatText(node.team.parent_id), node.team.parent_id),
578
+ ].filter((detail) => detail !== null);
579
+ if (details.length > 0) {
580
+ blocks.push(ui.dl(details, { termWidth: 18 }));
581
+ }
582
+ node.children.forEach((child) => {
583
+ appendSnapshotTeamBlock(blocks, child, depth + 1);
584
+ });
585
+ }
301
586
  function formatCommentHeading(comment) {
302
587
  const heading = comment.item_name ??
303
588
  comment.driver_name ??
@@ -350,19 +635,44 @@ function formatBenchmarkTable(teamScore) {
350
635
  ["vs_90th", formatBenchmarkNumber(teamScore.vs_90th)],
351
636
  ];
352
637
  const columns = ["Benchmark", "Value"];
353
- const widths = columns.map((column, index) => Math.max(column.length, ...rows.map((row) => row[index].length)));
354
- const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
355
- const header = `| ${columns.map((column, index) => column.padEnd(widths[index])).join(" | ")} |`;
356
- const body = rows.map((row) => `| ${row.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`);
357
- return [border, header, border, ...body, border].join("\n");
638
+ return buildTable(columns, rows);
358
639
  }
359
640
  function formatBenchmarkNumber(value) {
360
641
  return typeof value === "number" && Number.isFinite(value)
361
642
  ? value.toString()
362
643
  : "(None)";
363
644
  }
364
- function snapshotWebLink(snapshotId, section, runtime) {
365
- return ui.webLink(`/snapshots/${snapshotId}/${section}`, runtime);
645
+ function formatWorkflowDistributionTable(distribution) {
646
+ if (distribution.length === 0) {
647
+ return ui.dim("(None)");
648
+ }
649
+ const rows = distribution.map((item) => [
650
+ item.label.trim(),
651
+ item.value.toString(),
652
+ item.count.toString(),
653
+ ]);
654
+ const columns = ["Label", "Value", "Count"];
655
+ return buildTable(columns, rows);
656
+ }
657
+ function buildTable(columns, rows) {
658
+ const widths = columns.map((column, index) => Math.max(column.length, ...rows.map((row) => row[index].length)));
659
+ const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
660
+ const header = `| ${columns.map((column, index) => column.padEnd(widths[index])).join(" | ")} |`;
661
+ const body = rows.map((row) => `| ${row.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`);
662
+ return [border, header, border, ...body, border].join("\n");
663
+ }
664
+ function snapshotWebLink(snapshotId, section, runtime, queryParams = {}) {
665
+ let urlPath = `/snapshots/${snapshotId}/${section}`;
666
+ const params = new URLSearchParams();
667
+ for (const [key, value] of Object.entries(queryParams)) {
668
+ if (value !== undefined) {
669
+ params.set(key, value);
670
+ }
671
+ }
672
+ if (params.size > 0) {
673
+ urlPath += `?${params.toString()}`;
674
+ }
675
+ return ui.webLink(urlPath, runtime);
366
676
  }
367
677
  function parseRequiredTextOption(value, flag) {
368
678
  if (typeof value !== "string" || value.trim().length === 0) {
@@ -370,12 +370,20 @@ function studioReportToYaml(report) {
370
370
  viewer_emails: report.viewer_emails ?? [],
371
371
  edit_access_type: report.edit_access_type,
372
372
  editor_emails: report.editor_emails ?? [],
373
+ sections: report.sections?.map((section) => ({
374
+ id: section.id,
375
+ name: section.name,
376
+ description: section.description,
377
+ })),
373
378
  tiles: report.tiles.map((tile) => ({
374
379
  id: tile.id,
375
380
  title: tile.title,
376
381
  sql: tile.sql,
377
382
  chart_type: tile.chart_type,
378
383
  chart_config: tile.chart_config,
384
+ section_id: tile.section_id,
385
+ width: tile.width,
386
+ height: tile.height,
379
387
  })),
380
388
  };
381
389
  return stringifyYaml(payload, { blockQuote: "literal" });
@@ -200,7 +200,7 @@ function formatTeamHeading(team) {
200
200
  }
201
201
  return null;
202
202
  }
203
- function formatTeamMember(member) {
203
+ export function formatTeamMember(member) {
204
204
  if (!member) {
205
205
  return ui.dim("(None)");
206
206
  }
@@ -31,6 +31,12 @@ trigger_type: ANY
31
31
  # - squad_id: "<team id>" # user_id or squad_id
32
32
  # squad_linkage_id: null # optional
33
33
 
34
+ # timeout: how long a run can stay in progress before it times out. Only
35
+ # applies when execution_type is EVENT_DRIVEN (ignored for SIMPLE). One of
36
+ # ONE_HOUR, SIX_HOURS, TWELVE_HOURS, ONE_DAY, THREE_DAYS, SEVEN_DAYS,
37
+ # FOURTEEN_DAYS or THIRTY_DAYS. Optional; if not set, a default of 1 hour is used.
38
+ # timeout: ONE_DAY
39
+
34
40
  # Which entities the workflow can run against (only used when scope is ENTITY).
35
41
  # entity_filter_type: "ENTITY_TYPES" or "SQL"
36
42
  entity_filter_type: ENTITY_TYPES
@@ -72,6 +72,9 @@ function coreContent(workflow) {
72
72
  ui.dli("Scope", workflowScopeLabel(workflow.scope)),
73
73
  ui.dli("Execution type", workflowExecutionTypeLabel(workflow.execution_type)),
74
74
  ui.dli("Who can run", `${workflowTriggerTypeLabel(workflow.trigger_type)} ${ui.dim(workflowTriggerTypeDescription(workflow.trigger_type))}`),
75
+ ...(workflow.timeout
76
+ ? [ui.dli("Timeout", ui.code(workflow.timeout))]
77
+ : []),
75
78
  ], { termWidth: 16 }));
76
79
  return blocks;
77
80
  }
package/dist/config.js CHANGED
@@ -41,6 +41,53 @@ export function readConfig() {
41
41
  }
42
42
  return stored;
43
43
  }
44
+ /**
45
+ * Report on the state of the config file without hiding problems, unlike
46
+ * `readConfig`, which treats an unparseable file as empty.
47
+ */
48
+ export function inspectConfigFile() {
49
+ const configPath = getConfigPath();
50
+ let stats;
51
+ let content;
52
+ try {
53
+ stats = fs.statSync(configPath);
54
+ content = fs.readFileSync(configPath, "utf8");
55
+ }
56
+ catch (error) {
57
+ if (error.code === "ENOENT") {
58
+ return { status: "missing", path: configPath };
59
+ }
60
+ return {
61
+ status: "invalid",
62
+ path: configPath,
63
+ error: error instanceof Error ? error.message : String(error),
64
+ };
65
+ }
66
+ try {
67
+ const parsed = JSON.parse(content);
68
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
69
+ return {
70
+ status: "invalid",
71
+ path: configPath,
72
+ error: "expected a JSON object",
73
+ };
74
+ }
75
+ }
76
+ catch (error) {
77
+ return {
78
+ status: "invalid",
79
+ path: configPath,
80
+ error: error instanceof Error ? error.message : String(error),
81
+ };
82
+ }
83
+ return {
84
+ status: "ok",
85
+ path: configPath,
86
+ config: readConfig(),
87
+ sizeBytes: stats.size,
88
+ modifiedAt: stats.mtime,
89
+ };
90
+ }
44
91
  export function persistCurrentVersionCache(cache) {
45
92
  writeConfig({ ...readConfig(), currentVersionCache: cache });
46
93
  }
@@ -89,20 +136,31 @@ function getConfigDir() {
89
136
  * Precedence: `DX_API_BASE_URL`, then persisted `apiBaseUrl`, then DX Cloud default.
90
137
  */
91
138
  export function resolveApiBaseUrl() {
92
- if (process.env.DX_API_BASE_URL) {
93
- return normalizeUrl(process.env.DX_API_BASE_URL);
94
- }
95
- return normalizeUrl(readConfig().apiBaseUrl || DEFAULT_API_BASE_URL);
139
+ return resolveApiBaseUrlWithSource().url;
140
+ }
141
+ /** Like `resolveApiBaseUrl`, but also reports where the value came from. */
142
+ export function resolveApiBaseUrlWithSource() {
143
+ return resolveBaseUrl(process.env.DX_API_BASE_URL, readConfig().apiBaseUrl, DEFAULT_API_BASE_URL);
96
144
  }
97
145
  /**
98
146
  * Resolved web-app base URL for CLI deep links and browser OAuth.
99
147
  * Precedence: `DX_WEB_BASE_URL`, persisted `webBaseUrl`, then DX Cloud default.
100
148
  */
101
149
  export function resolveWebBaseUrl() {
102
- if (process.env.DX_WEB_BASE_URL) {
103
- return normalizeUrl(process.env.DX_WEB_BASE_URL);
150
+ return resolveWebBaseUrlWithSource().url;
151
+ }
152
+ /** Like `resolveWebBaseUrl`, but also reports where the value came from. */
153
+ export function resolveWebBaseUrlWithSource() {
154
+ return resolveBaseUrl(process.env.DX_WEB_BASE_URL, readConfig().webBaseUrl, DEFAULT_WEB_BASE_URL);
155
+ }
156
+ function resolveBaseUrl(fromEnv, fromConfig, fallback) {
157
+ if (fromEnv) {
158
+ return { url: normalizeUrl(fromEnv), source: "env" };
104
159
  }
105
- return normalizeUrl(readConfig().webBaseUrl || DEFAULT_WEB_BASE_URL);
160
+ if (fromConfig) {
161
+ return { url: normalizeUrl(fromConfig), source: "config" };
162
+ }
163
+ return { url: normalizeUrl(fallback), source: "default" };
106
164
  }
107
165
  // --- Deriving base URLs at login time -------------------------------------
108
166
  /**
@@ -151,14 +209,17 @@ export function deriveBaseUrlsFromEnv() {
151
209
  throw new CliError(`Could not recognize web base URL "${parsed.input}". Expected https://app.getdx.com, https://<account>.getdx.io, or a custom domain.`);
152
210
  }
153
211
  }
154
- function parseWebBaseUrl(raw) {
212
+ export function parseWebBaseUrl(raw) {
155
213
  const normalized = raw.trim().replace(/\/$/, "");
156
- if (!normalized || normalized === "app.getdx.com") {
214
+ if (!normalized) {
157
215
  return { type: "cloud" };
158
216
  }
159
217
  try {
160
218
  const url = new URL(normalized.startsWith("http") ? normalized : `https://${normalized}`);
161
219
  const host = url.hostname;
220
+ if (host === "app.getdx.com") {
221
+ return { type: "cloud" };
222
+ }
162
223
  const dedicatedMatch = host.match(/^(.+)\.getdx\.io$/);
163
224
  if (dedicatedMatch) {
164
225
  return { type: "dedicated", accountName: dedicatedMatch[1] };
@@ -0,0 +1,133 @@
1
+ import dayjs from "dayjs";
2
+ import { getAuthInfo } from "../commands/auth.js";
3
+ import { HttpError } from "../errors.js";
4
+ import { describeError, timed } from "./format.js";
5
+ import { CHECK_TIMEOUT_MS, DOCTOR_HEADER } from "./probes.js";
6
+ import { check } from "./types.js";
7
+ const EXPIRY_WARNING_DAYS = 7;
8
+ const SECTION = { id: "auth", title: "Auth" };
9
+ export async function checkAuth(options) {
10
+ const { runtime, apiReachable, now } = options;
11
+ if (!runtime) {
12
+ return skipped("no token is configured");
13
+ }
14
+ if (!apiReachable) {
15
+ return skipped("the API is unreachable");
16
+ }
17
+ const host = new URL(runtime.apiBaseUrl).host;
18
+ let info;
19
+ let ms;
20
+ try {
21
+ ({ value: info, ms } = await timed(() => getAuthInfo(runtime, {
22
+ headers: DOCTOR_HEADER,
23
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
24
+ })));
25
+ }
26
+ catch (error) {
27
+ return { ...SECTION, checks: [tokenErrorCheck(error, host)] };
28
+ }
29
+ const { auth, account } = info;
30
+ const checks = [
31
+ check("token_valid", "Token valid", "ok", `${tokenTypeName(auth.token_type)} "${auth.token_name}" (responded in ${ms}ms)`, {
32
+ data: {
33
+ token_type: auth.token_type,
34
+ token_name: auth.token_name,
35
+ scopes: auth.scopes,
36
+ created_at: auth.created_at,
37
+ ms,
38
+ },
39
+ }),
40
+ check("account", "Account", "ok", account.name, {
41
+ data: { name: account.name },
42
+ }),
43
+ ];
44
+ if (auth.token_type === "personal_access_token") {
45
+ checks.push(expiryCheck(auth.expires_at, now));
46
+ }
47
+ return { ...SECTION, checks };
48
+ }
49
+ function skipped(reason) {
50
+ return {
51
+ ...SECTION,
52
+ checks: [
53
+ check("token_valid", "Token valid", "skip", `skipped because ${reason}`),
54
+ ],
55
+ };
56
+ }
57
+ function tokenErrorCheck(error, host) {
58
+ if (error instanceof HttpError && error.status === 401) {
59
+ return check("token_valid", "Token valid", "fail", `token rejected by ${host} (HTTP 401: ${error.message})`, {
60
+ hint: "The token may be revoked, expired, or issued by a different DX instance. Run `dx auth login` to store a new one.",
61
+ data: { status: 401 },
62
+ });
63
+ }
64
+ if (error instanceof HttpError && error.status !== undefined) {
65
+ const { message, hint } = describeHttpFailure(error.status, error.message, host);
66
+ return check("token_valid", "Token valid", "fail", message, {
67
+ hint,
68
+ data: { status: error.status },
69
+ });
70
+ }
71
+ return check("token_valid", "Token valid", "fail", describeError(error, "auth.info request"), {
72
+ hint: `The API was reachable a moment ago, but this request to ${host} failed. Try again.`,
73
+ data: { status: null },
74
+ });
75
+ }
76
+ function describeHttpFailure(status, errorMessage, host) {
77
+ if (status === 403) {
78
+ return {
79
+ message: `access denied by ${host} (HTTP 403: ${errorMessage})`,
80
+ hint: "The token was recognized but this request was refused. An IP allowlist, proxy, or firewall may be blocking it. Check with your DX admin or network team.",
81
+ };
82
+ }
83
+ if (status === 429) {
84
+ return {
85
+ message: `rate limited by ${host} (HTTP 429)`,
86
+ hint: "Too many requests. Wait a minute and run `dx doctor` again.",
87
+ };
88
+ }
89
+ if (status >= 500) {
90
+ return {
91
+ message: `HTTP ${status}: ${errorMessage}`,
92
+ hint: "The DX API is having problems. Try again in a few minutes, and contact DX support if this persists.",
93
+ };
94
+ }
95
+ return {
96
+ message: `HTTP ${status}: ${errorMessage}`,
97
+ hint: "The DX API could not validate the token. Try again, and contact DX support if this persists.",
98
+ };
99
+ }
100
+ function expiryCheck(expiresAt, now) {
101
+ if (!expiresAt) {
102
+ return check("expires", "Expires", "ok", "never", {
103
+ data: { expires_at: null },
104
+ });
105
+ }
106
+ const expires = dayjs(expiresAt);
107
+ const days = expires.diff(dayjs(now), "day");
108
+ const date = expires.format("YYYY-MM-DD");
109
+ const data = { expires_at: expiresAt, days_remaining: days };
110
+ if (expires.isBefore(now)) {
111
+ return check("expires", "Expires", "fail", `expired on ${date}`, {
112
+ hint: "Create a new personal access token and run `dx auth login`.",
113
+ data,
114
+ });
115
+ }
116
+ if (days < EXPIRY_WARNING_DAYS) {
117
+ return check("expires", "Expires", "warn", `${date} (${days}d)`, {
118
+ hint: "This token expires soon. Create a new one and run `dx auth login`.",
119
+ data,
120
+ });
121
+ }
122
+ return check("expires", "Expires", "ok", `${date} (${days}d)`, { data });
123
+ }
124
+ function tokenTypeName(tokenType) {
125
+ switch (tokenType) {
126
+ case "account_web_api_token":
127
+ return "organization token";
128
+ case "personal_access_token":
129
+ return "personal access token";
130
+ default:
131
+ return String(tokenType);
132
+ }
133
+ }