@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.
- package/dist/cli.js +4 -0
- package/dist/commandHelpers.js +10 -0
- package/dist/commands/attributeGroups.js +100 -0
- package/dist/commands/auth.js +2 -1
- package/dist/commands/catalog/entities.js +1 -11
- package/dist/commands/catalog/relation-edges-blank-template.yaml +19 -0
- package/dist/commands/catalog/relationEdges.js +285 -0
- package/dist/commands/catalog/relationEdgesRendering.js +45 -0
- package/dist/commands/catalog/relations.js +231 -0
- package/dist/commands/catalog/relationsRendering.js +37 -0
- package/dist/commands/catalog.js +4 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/snapshots.js +318 -8
- package/dist/commands/studio/reports.js +8 -0
- package/dist/commands/teams.js +1 -1
- package/dist/commands/workflow-blank-template.yaml +6 -0
- package/dist/commands/workflowsRendering.js +3 -0
- 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
|
@@ -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
|
-
|
|
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
|
|
365
|
-
|
|
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" });
|
package/dist/commands/teams.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|