@get-dx/cli 0.8.0 → 0.9.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/dist/cli.js +4 -0
- package/dist/commands/attributeGroups.js +100 -0
- package/dist/commands/auth.js +2 -1
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/snapshots.js +116 -0
- package/dist/commands/teams.js +1 -1
- package/dist/config.js +70 -9
- package/dist/doctor/auth.js +133 -0
- package/dist/doctor/cli.js +154 -0
- package/dist/doctor/config.js +119 -0
- package/dist/doctor/environment.js +60 -0
- package/dist/doctor/format.js +68 -0
- package/dist/doctor/index.js +81 -0
- package/dist/doctor/network.js +224 -0
- package/dist/doctor/probes.js +130 -0
- package/dist/doctor/skill.js +16 -0
- package/dist/doctor/types.js +11 -0
- package/dist/http.js +2 -0
- package/dist/secrets.js +87 -9
- package/dist/ui/blocks.js +46 -0
- package/dist/ui.js +8 -1
- package/dist/versionCheck.js +25 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Command, Option } from "commander";
|
|
2
|
+
import { attributeGroupsCommand } from "./commands/attributeGroups.js";
|
|
2
3
|
import { authCommand } from "./commands/auth.js";
|
|
3
4
|
import { catalogCommand } from "./commands/catalog.js";
|
|
5
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
4
6
|
import { initCommand } from "./commands/init.js";
|
|
5
7
|
import { scorecardsCommand } from "./commands/scorecards.js";
|
|
6
8
|
import { snapshotsCommand } from "./commands/snapshots.js";
|
|
@@ -48,8 +50,10 @@ function createProgram() {
|
|
|
48
50
|
.addOption(new Option("--agent <name>", "Agent name to send as an HTTP header"))
|
|
49
51
|
.addOption(new Option("--agent-model <model>", "Agent model name to send as an HTTP header"))
|
|
50
52
|
.addOption(new Option("--agent-session-id <id>", "Agent session ID to send as an HTTP header"));
|
|
53
|
+
program.addCommand(attributeGroupsCommand());
|
|
51
54
|
program.addCommand(authCommand());
|
|
52
55
|
program.addCommand(catalogCommand());
|
|
56
|
+
program.addCommand(doctorCommand());
|
|
53
57
|
program.addCommand(initCommand());
|
|
54
58
|
program.addCommand(scorecardsCommand());
|
|
55
59
|
program.addCommand(snapshotsCommand());
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
|
|
3
|
+
import { request } from "../http.js";
|
|
4
|
+
import { renderJson, renderRichText } from "../renderers.js";
|
|
5
|
+
import { buildRuntime } from "../runtime.js";
|
|
6
|
+
import * as ui from "../ui.js";
|
|
7
|
+
export function attributeGroupsCommand() {
|
|
8
|
+
const attributeGroups = new Command()
|
|
9
|
+
.name("attributeGroups")
|
|
10
|
+
.description("List attribute groups and values for filtering snapshot scores");
|
|
11
|
+
attributeGroups
|
|
12
|
+
.command("list")
|
|
13
|
+
.description("List attribute groups and their attribute values")
|
|
14
|
+
.addHelpText("afterAll", createExampleText([
|
|
15
|
+
{
|
|
16
|
+
label: "List attribute groups in human-readable output",
|
|
17
|
+
command: "dx attributeGroups list",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
label: "Print raw JSON for automation",
|
|
21
|
+
command: "dx --json attributeGroups list",
|
|
22
|
+
},
|
|
23
|
+
]))
|
|
24
|
+
.action(wrapAction(async (_options, command) => {
|
|
25
|
+
const runtime = await buildRuntime(getContext(command));
|
|
26
|
+
const response = await listAttributeGroups(runtime);
|
|
27
|
+
if (runtime.context.json) {
|
|
28
|
+
renderJson(response);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
renderAttributeGroups(response.attribute_groups);
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
return attributeGroups;
|
|
35
|
+
}
|
|
36
|
+
export async function listAttributeGroups(runtime) {
|
|
37
|
+
const response = await request(runtime, "/attributeGroups.list", {
|
|
38
|
+
method: "GET",
|
|
39
|
+
});
|
|
40
|
+
return response.body;
|
|
41
|
+
}
|
|
42
|
+
function renderAttributeGroups(unsortedGroups) {
|
|
43
|
+
const attributeGroups = [...unsortedGroups].sort(byPosition);
|
|
44
|
+
const blocks = [ui.h1("Attribute Groups")];
|
|
45
|
+
blocks.push(ui.p(`Displaying ${ui.bold(attributeGroups.length.toString())} attribute groups.`));
|
|
46
|
+
if (attributeGroups.length === 0) {
|
|
47
|
+
blocks.push(ui.p("No attribute groups are available for this account. Create or sync attribute groups in DX before filtering snapshot scores by attribute."));
|
|
48
|
+
renderRichText(blocks);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const attributeGroup of attributeGroups) {
|
|
52
|
+
blocks.push(ui.h2(`${attributeGroup.name} (${ui.code(attributeGroup.id)})`));
|
|
53
|
+
blocks.push(ui.dl([
|
|
54
|
+
ui.dli("Type", formatAttributeGroupType(attributeGroup.type)),
|
|
55
|
+
ui.dli("ID", ui.code(attributeGroup.id)),
|
|
56
|
+
...(attributeGroup.position === null
|
|
57
|
+
? []
|
|
58
|
+
: [ui.dli("Position", attributeGroup.position.toString())]),
|
|
59
|
+
...(attributeGroup.hidden ? [ui.dli("Hidden", "Yes")] : []),
|
|
60
|
+
// The API only returns privileged groups the user can see.
|
|
61
|
+
...(attributeGroup.privileged ? [ui.dli("Privileged", "Yes")] : []),
|
|
62
|
+
], { termWidth: 12 }));
|
|
63
|
+
if (attributeGroup.attributes.length === 0) {
|
|
64
|
+
blocks.push(ui.p(`Attributes: ${ui.dim("(None)")}`));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
blocks.push(ui.p("Attributes:"));
|
|
68
|
+
blocks.push(ui.ul([...attributeGroup.attributes]
|
|
69
|
+
.sort(byPositionThenName)
|
|
70
|
+
.map((attribute) => ui.li(`${attribute.name} (${ui.code(attribute.id)})`))));
|
|
71
|
+
}
|
|
72
|
+
renderRichText(blocks);
|
|
73
|
+
}
|
|
74
|
+
// Items without a position sort last. Ties keep the API's order (a stable
|
|
75
|
+
// sort), which is by id for groups.
|
|
76
|
+
function byPosition(a, b) {
|
|
77
|
+
if (a.position === b.position)
|
|
78
|
+
return 0;
|
|
79
|
+
if (a.position === null)
|
|
80
|
+
return 1;
|
|
81
|
+
if (b.position === null)
|
|
82
|
+
return -1;
|
|
83
|
+
return a.position - b.position;
|
|
84
|
+
}
|
|
85
|
+
// Attributes are ordered by position, then name.
|
|
86
|
+
function byPositionThenName(a, b) {
|
|
87
|
+
return byPosition(a, b) || a.name.localeCompare(b.name);
|
|
88
|
+
}
|
|
89
|
+
function formatAttributeGroupType(type) {
|
|
90
|
+
switch (type.toUpperCase()) {
|
|
91
|
+
case "ADMIN_MANAGED":
|
|
92
|
+
return "Admin managed";
|
|
93
|
+
case "SELF_REPORTED":
|
|
94
|
+
return "Self reported";
|
|
95
|
+
case "DX_MANAGED":
|
|
96
|
+
return "DX managed";
|
|
97
|
+
default:
|
|
98
|
+
return type;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -154,8 +154,9 @@ export function authCommand() {
|
|
|
154
154
|
return auth;
|
|
155
155
|
}
|
|
156
156
|
// TODO: should we move this somewhere more central, since `init` needs it?
|
|
157
|
-
export async function getAuthInfo(runtime) {
|
|
157
|
+
export async function getAuthInfo(runtime, options) {
|
|
158
158
|
const response = await request(runtime, "/auth.info", {
|
|
159
|
+
...options,
|
|
159
160
|
method: "GET",
|
|
160
161
|
});
|
|
161
162
|
return response.body;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
|
|
3
|
+
import { runDoctor } from "../doctor/index.js";
|
|
4
|
+
import { AsyncProgressReporter, renderJson, renderRichText, } from "../renderers.js";
|
|
5
|
+
import * as ui from "../ui.js";
|
|
6
|
+
const MIN_LABEL_WIDTH = 26;
|
|
7
|
+
const PHASE_MESSAGES = {
|
|
8
|
+
config: "Checking configuration…",
|
|
9
|
+
network: "Checking network…",
|
|
10
|
+
auth: "Checking authentication…",
|
|
11
|
+
version: "Checking for updates…",
|
|
12
|
+
};
|
|
13
|
+
export function doctorCommand() {
|
|
14
|
+
return new Command("doctor")
|
|
15
|
+
.description("Diagnose problems with your CLI instance's installation, configuration, networking, and authentication")
|
|
16
|
+
.addHelpText("afterAll", createExampleText([
|
|
17
|
+
{ label: "Check your setup", command: "dx doctor" },
|
|
18
|
+
{ label: "Print the report as JSON", command: "dx doctor --json" },
|
|
19
|
+
]))
|
|
20
|
+
.action(wrapAction(async (_options, command) => {
|
|
21
|
+
const context = getContext(command);
|
|
22
|
+
const progress = new AsyncProgressReporter();
|
|
23
|
+
progress.start(PHASE_MESSAGES.config);
|
|
24
|
+
let report;
|
|
25
|
+
try {
|
|
26
|
+
report = await runDoctor(context, {
|
|
27
|
+
onPhase: (phase) => progress.update(PHASE_MESSAGES[phase]),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
progress.stop();
|
|
32
|
+
}
|
|
33
|
+
if (context.json) {
|
|
34
|
+
renderJson(report);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
renderDoctorReport(report);
|
|
38
|
+
}
|
|
39
|
+
if (!report.ok) {
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
export function renderDoctorReport(report) {
|
|
45
|
+
const labelWidth = Math.max(MIN_LABEL_WIDTH, ...report.sections.flatMap((section) => section.checks.map((check) => check.label.length + 2)));
|
|
46
|
+
renderRichText([
|
|
47
|
+
...report.sections.map((section) => ui.statusList(section.title, section.checks.map((check) => ({
|
|
48
|
+
status: check.status,
|
|
49
|
+
label: check.label,
|
|
50
|
+
detail: formatDetail(check),
|
|
51
|
+
hint: check.hint,
|
|
52
|
+
})), { labelWidth })),
|
|
53
|
+
ui.p(summaryLine(report)),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
function formatDetail(check) {
|
|
57
|
+
if (!check.source) {
|
|
58
|
+
return check.message;
|
|
59
|
+
}
|
|
60
|
+
return `${check.message} ${ui.dim(`[from ${sourceName(check.source)}]`)}`;
|
|
61
|
+
}
|
|
62
|
+
function sourceName(source) {
|
|
63
|
+
switch (source) {
|
|
64
|
+
case "env":
|
|
65
|
+
return "env";
|
|
66
|
+
case "config":
|
|
67
|
+
return "config file";
|
|
68
|
+
case "default":
|
|
69
|
+
return "default";
|
|
70
|
+
case "keychain":
|
|
71
|
+
return "keychain";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function summaryLine(report) {
|
|
75
|
+
const { fail, warn } = report.summary;
|
|
76
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
77
|
+
if (fail > 0) {
|
|
78
|
+
const failed = ui.error(`${plural(fail, "check")} failed`);
|
|
79
|
+
return warn > 0 ? `${failed}, ${plural(warn, "warning")}.` : `${failed}.`;
|
|
80
|
+
}
|
|
81
|
+
if (warn > 0) {
|
|
82
|
+
return ui.warning(`All required checks passed, ${plural(warn, "warning")}.`);
|
|
83
|
+
}
|
|
84
|
+
return ui.success("All checks passed.");
|
|
85
|
+
}
|
|
@@ -4,6 +4,7 @@ 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,35 @@ 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);
|
|
138
168
|
return snapshots;
|
|
139
169
|
}
|
|
140
170
|
async function listSnapshotDriverComments(runtime, options) {
|
|
@@ -159,6 +189,13 @@ async function listSnapshotCsatComments(runtime, options) {
|
|
|
159
189
|
});
|
|
160
190
|
return response.body;
|
|
161
191
|
}
|
|
192
|
+
async function listSnapshotTeams(runtime, snapshotId) {
|
|
193
|
+
const response = await request(runtime, "/snapshots.snapshotTeams.list", {
|
|
194
|
+
method: "GET",
|
|
195
|
+
query: { snapshot_id: snapshotId },
|
|
196
|
+
});
|
|
197
|
+
return response.body;
|
|
198
|
+
}
|
|
162
199
|
async function getSnapshotInfo(runtime, snapshotId) {
|
|
163
200
|
const response = await request(runtime, "/snapshots.info", {
|
|
164
201
|
method: "GET",
|
|
@@ -237,6 +274,22 @@ function renderSnapshotCsatComments(comments, nextCursor, snapshotId, runtime) {
|
|
|
237
274
|
}
|
|
238
275
|
renderRichText(blocks);
|
|
239
276
|
}
|
|
277
|
+
function renderSnapshotTeams(teams, snapshotId, runtime) {
|
|
278
|
+
const blocks = [ui.h1("Snapshot Teams")];
|
|
279
|
+
blocks.push(ui.p(`Displaying ${ui.bold(teams.length.toString())} teams.`));
|
|
280
|
+
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")}.`));
|
|
281
|
+
blocks.push(ui.p(ui.link(snapshotWebLink(snapshotId, "drivers", runtime))));
|
|
282
|
+
if (teams.length === 0) {
|
|
283
|
+
blocks.push(ui.p(ui.dim("(None)")));
|
|
284
|
+
renderRichText(blocks);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const rootTeams = buildSnapshotTeamTree(teams);
|
|
288
|
+
rootTeams.forEach((rootTeam) => {
|
|
289
|
+
appendSnapshotTeamBlock(blocks, rootTeam, 0);
|
|
290
|
+
});
|
|
291
|
+
renderRichText(blocks);
|
|
292
|
+
}
|
|
240
293
|
function renderSnapshotInfo(snapshot, snapshotId, runtime) {
|
|
241
294
|
const teamScores = snapshot.team_scores ?? [];
|
|
242
295
|
const blocks = [ui.h1("Snapshot Information")];
|
|
@@ -298,6 +351,69 @@ function extractCsatComments(response) {
|
|
|
298
351
|
response.comments ??
|
|
299
352
|
[]);
|
|
300
353
|
}
|
|
354
|
+
class SnapshotTeamTreeNode {
|
|
355
|
+
team;
|
|
356
|
+
children = [];
|
|
357
|
+
constructor(team) {
|
|
358
|
+
this.team = team;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function buildSnapshotTeamTree(teams) {
|
|
362
|
+
const nodes = new Map();
|
|
363
|
+
teams.forEach((team) => {
|
|
364
|
+
nodes.set(team.id, new SnapshotTeamTreeNode(team));
|
|
365
|
+
});
|
|
366
|
+
const rootTeams = [];
|
|
367
|
+
teams.forEach((team) => {
|
|
368
|
+
const currentNode = nodes.get(team.id);
|
|
369
|
+
if (currentNode.team.parent_id === null) {
|
|
370
|
+
// It's a top-level root node
|
|
371
|
+
rootTeams.push(currentNode);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// Append it to its parent's children array
|
|
375
|
+
nodes.get(currentNode.team.parent_id).children.push(currentNode);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
const sortNodes = (items) => {
|
|
379
|
+
items.sort((left, right) => compareSnapshotTeams(left.team, right.team));
|
|
380
|
+
for (const item of items) {
|
|
381
|
+
sortNodes(item.children);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
sortNodes(rootTeams);
|
|
385
|
+
return rootTeams;
|
|
386
|
+
}
|
|
387
|
+
function compareSnapshotTeams(left, right) {
|
|
388
|
+
const leftName = left.name.toLocaleLowerCase();
|
|
389
|
+
const rightName = right.name.toLocaleLowerCase();
|
|
390
|
+
if (leftName !== rightName) {
|
|
391
|
+
return leftName.localeCompare(rightName);
|
|
392
|
+
}
|
|
393
|
+
// If the names are the same, compare the IDs
|
|
394
|
+
const leftId = left.id.toLocaleLowerCase();
|
|
395
|
+
const rightId = right.id.toLocaleLowerCase();
|
|
396
|
+
return leftId.localeCompare(rightId);
|
|
397
|
+
}
|
|
398
|
+
function appendSnapshotTeamBlock(blocks, node, depth) {
|
|
399
|
+
blocks.push(depth === 0
|
|
400
|
+
? ui.h2(node.team.name)
|
|
401
|
+
: ui.h3(`${"↳".repeat(depth)} ${node.team.name}`));
|
|
402
|
+
const details = [
|
|
403
|
+
optionalDetail("Snapshot Team ID", formatText(node.team.id), node.team.id),
|
|
404
|
+
optionalDetail("Team ID", formatText(node.team.team_id), node.team.team_id),
|
|
405
|
+
// Match `dx teams info`: show "(None)" when the API returns a null manager,
|
|
406
|
+
// omit the row only when the field is absent entirely.
|
|
407
|
+
ui.dli("Manager", formatTeamMember(node.team.manager)),
|
|
408
|
+
optionalDetail("Parent ID", formatText(node.team.parent_id), node.team.parent_id),
|
|
409
|
+
].filter((detail) => detail !== null);
|
|
410
|
+
if (details.length > 0) {
|
|
411
|
+
blocks.push(ui.dl(details, { termWidth: 18 }));
|
|
412
|
+
}
|
|
413
|
+
node.children.forEach((child) => {
|
|
414
|
+
appendSnapshotTeamBlock(blocks, child, depth + 1);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
301
417
|
function formatCommentHeading(comment) {
|
|
302
418
|
const heading = comment.item_name ??
|
|
303
419
|
comment.driver_name ??
|
package/dist/commands/teams.js
CHANGED
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|