@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 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());
@@ -125,6 +125,16 @@ export function parsePositiveIntOption(value, flag) {
125
125
  }
126
126
  return n;
127
127
  }
128
+ export function parseCommaSeparatedValues(value) {
129
+ if (value === undefined) {
130
+ return undefined;
131
+ }
132
+ const parsed = value
133
+ .split(",")
134
+ .map((entry) => entry.trim())
135
+ .filter((entry) => entry.length > 0);
136
+ return parsed.length > 0 ? parsed : undefined;
137
+ }
128
138
  export function createExampleText(examples) {
129
139
  const lines = [];
130
140
  lines.push(""); // separate from the rest of the help text
@@ -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
+ }
@@ -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;
@@ -1,5 +1,5 @@
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, HttpError } from "../../errors.js";
4
4
  import { request } from "../../http.js";
5
5
  import { renderJson } from "../../renderers.js";
@@ -655,16 +655,6 @@ function getEntityMutationOptionValues(options) {
655
655
  owner_user_ids: parseCommaSeparatedValues(options.ownerUserIds),
656
656
  };
657
657
  }
658
- function parseCommaSeparatedValues(value) {
659
- if (value === undefined) {
660
- return undefined;
661
- }
662
- const parsed = value
663
- .split(",")
664
- .map((entry) => entry.trim())
665
- .filter((entry) => entry.length > 0);
666
- return parsed.length > 0 ? parsed : undefined;
667
- }
668
658
  function buildEntityMutationBody(identifier, params) {
669
659
  const body = { identifier };
670
660
  if ("type" in params && params.type !== undefined) {
@@ -0,0 +1,19 @@
1
+ # Relation edges for bulk upsert
2
+ #
3
+ # Edit this file, then run:
4
+ #
5
+ # ```
6
+ # dx catalog relationEdges bulkUpsert --relation <relation-identifier> --from-file <path>
7
+ # ```
8
+ #
9
+ # Each key is a source entity identifier, and each value is a list of target
10
+ # entity identifiers.
11
+ #
12
+ # Bulk upsert only adds edges. Existing edges that are not listed here are kept;
13
+ # use `dx catalog relationEdges remove` to delete an edge.
14
+
15
+ source-entity-identifier-1:
16
+ - target-entity-identifier-a
17
+ - target-entity-identifier-b
18
+ source-entity-identifier-2:
19
+ - target-entity-identifier-c
@@ -0,0 +1,285 @@
1
+ import fs from "fs";
2
+ import { Command } from "commander";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
5
+ import { CliError, EXIT_CODES } from "../../errors.js";
6
+ import { request } from "../../http.js";
7
+ import { renderJson } from "../../renderers.js";
8
+ import { buildRuntime } from "../../runtime.js";
9
+ import { renderRelationEdgeAdded, renderRelationEdgeList, renderRelationEdgeRemoved, renderRelationEdgesTemplateWritten, renderRelationEdgesUpserted, } from "./relationEdgesRendering.js";
10
+ const RELATION_EDGES_BLANK_TEMPLATE_YAML = fs.readFileSync(new URL("./relation-edges-blank-template.yaml", import.meta.url), "utf8");
11
+ export function relationEdgesCommand() {
12
+ const relationEdges = new Command()
13
+ .name("relationEdges")
14
+ .description("Manage relation edges between entities");
15
+ relationEdges
16
+ .command("add")
17
+ .description("Add a relation edge between two entities")
18
+ .requiredOption("--relation <identifier>", "Relation identifier (required)")
19
+ .requiredOption("--source <identifier>", "Source entity identifier (required)")
20
+ .requiredOption("--target <identifier>", "Target entity identifier (required)")
21
+ .addHelpText("afterAll", createExampleText([
22
+ {
23
+ label: "Record that `login-frontend` depends on `authn-backend`",
24
+ command: "dx catalog relationEdges add --relation service-depends-on-service --source login-frontend --target authn-backend",
25
+ },
26
+ {
27
+ label: "Add an edge and return JSON",
28
+ command: "dx catalog relationEdges add --relation service-depends-on-service --source login-frontend --target authn-backend --json",
29
+ },
30
+ ]))
31
+ .action(wrapAction(async (options, command) => {
32
+ const edge = {
33
+ relation: options.relation,
34
+ source: options.source,
35
+ target: options.target,
36
+ };
37
+ const runtime = await buildRuntime(getContext(command));
38
+ const response = await addRelationEdge(runtime, edge);
39
+ if (runtime.context.json) {
40
+ renderJson(response);
41
+ }
42
+ else {
43
+ renderRelationEdgeAdded(edge);
44
+ }
45
+ }));
46
+ relationEdges
47
+ .command("bulkUpsert")
48
+ .description("Create or update many relation edges for one relation from a YAML or JSON file or stdin. The file maps each source entity identifier to a list of target entity identifiers.")
49
+ .requiredOption("--relation <identifier>", "Relation identifier (required)")
50
+ .option("--from-file <path>", "Path to a YAML or JSON file of edges. Use `initBulkUpsert` to generate a template.")
51
+ .option("--from-stdin", "Read YAML or JSON edges from stdin")
52
+ .addHelpText("afterAll", createExampleText([
53
+ {
54
+ label: "Generate a template first",
55
+ command: "dx catalog relationEdges initBulkUpsert ./edges.yaml",
56
+ },
57
+ {
58
+ label: "Upsert edges from a YAML file",
59
+ command: "dx catalog relationEdges bulkUpsert --relation service-depends-on-service --from-file ./edges.yaml",
60
+ },
61
+ {
62
+ label: "Upsert edges from stdin",
63
+ command: 'echo \'{"login-frontend": ["authn-backend", "feature-flag-backend"]}\' | dx catalog relationEdges bulkUpsert --relation service-depends-on-service --from-stdin',
64
+ },
65
+ ]))
66
+ .action(wrapAction(async (options, command) => {
67
+ if (!options.fromFile && !options.fromStdin) {
68
+ throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
69
+ }
70
+ if (options.fromFile && options.fromStdin) {
71
+ throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
72
+ }
73
+ const raw = options.fromFile
74
+ ? readYamlFile(options.fromFile)
75
+ : await readYamlStdin();
76
+ const edges = parseEdgesMap(raw, options.fromFile ? options.fromFile : "stdin");
77
+ const runtime = await buildRuntime(getContext(command));
78
+ const response = await bulkUpsertRelationEdges(runtime, options.relation, edges);
79
+ if (runtime.context.json) {
80
+ renderJson(response);
81
+ }
82
+ else {
83
+ renderRelationEdgesUpserted(options.relation, edges);
84
+ }
85
+ }));
86
+ relationEdges
87
+ .command("initBulkUpsert")
88
+ .description("Write a blank YAML template of relation edges to use with `bulkUpsert`")
89
+ .argument("<path>", "File path to write the YAML to")
90
+ .addHelpText("afterAll", createExampleText([
91
+ {
92
+ label: "Write a blank relation edges template",
93
+ command: "dx catalog relationEdges initBulkUpsert ./edges.yaml",
94
+ },
95
+ {
96
+ label: "Upsert edges from the template",
97
+ command: "dx catalog relationEdges bulkUpsert --relation service-depends-on-service --from-file ./edges.yaml",
98
+ },
99
+ ]))
100
+ .action(wrapAction(async (path, _options, command) => {
101
+ const runtime = await buildRuntime(getContext(command));
102
+ fs.writeFileSync(path, RELATION_EDGES_BLANK_TEMPLATE_YAML, "utf8");
103
+ if (runtime.context.json) {
104
+ renderJson({ ok: true, path });
105
+ }
106
+ else {
107
+ renderRelationEdgesTemplateWritten(path);
108
+ }
109
+ }));
110
+ relationEdges
111
+ .command("list")
112
+ .description("List the entities connected to an entity through a relation")
113
+ .requiredOption("--entity <identifier>", "Entity identifier (required)")
114
+ .requiredOption("--relation <identifier>", "Relation identifier (required)")
115
+ .option("--direction <direction>", "Treat the entity as the source (`outgoing`) or target (`incoming`) of the relation (default is outgoing)")
116
+ .option("--cursor <cursor>", "Cursor for the next page of results")
117
+ .option("--limit <n>", "Max edges per page, up to 50 (default is 25)", (value) => parsePositiveIntOption(value, "--limit"))
118
+ .addHelpText("afterAll", createExampleText([
119
+ {
120
+ label: "List the APIs that `payments-service` consumes",
121
+ command: "dx catalog relationEdges list --entity payments-service --relation service-consumes-api",
122
+ },
123
+ {
124
+ label: "List incoming edges and return JSON",
125
+ command: "dx catalog relationEdges list --entity core-api --relation service-consumes-api --direction incoming --json",
126
+ },
127
+ {
128
+ label: "Fetch the next page using a cursor from the prior response",
129
+ command: "dx catalog relationEdges list --entity payments-service --relation service-consumes-api --cursor d0d93f40-e534-51ac-0a0a-c839dfa2ff35",
130
+ },
131
+ ]))
132
+ .action(wrapAction(async (options, command) => {
133
+ const runtime = await buildRuntime(getContext(command));
134
+ const response = await listRelationEdges(runtime, {
135
+ entity_identifier: options.entity,
136
+ relation_identifier: options.relation,
137
+ direction: options.direction,
138
+ cursor: options.cursor,
139
+ limit: options.limit,
140
+ });
141
+ if (runtime.context.json) {
142
+ renderJson(response);
143
+ }
144
+ else {
145
+ renderRelationEdgeList(response.entity_relations, {
146
+ entity: options.entity,
147
+ relation: options.relation,
148
+ direction: options.direction ?? "outgoing",
149
+ }, response.response_metadata?.next_cursor ?? null);
150
+ }
151
+ }));
152
+ relationEdges
153
+ .command("remove")
154
+ .description("Remove a relation edge between two entities")
155
+ .requiredOption("--relation <identifier>", "Relation identifier (required)")
156
+ .requiredOption("--source <identifier>", "Source entity identifier (required)")
157
+ .requiredOption("--target <identifier>", "Target entity identifier (required)")
158
+ .addHelpText("afterAll", createExampleText([
159
+ {
160
+ label: "Remove the edge from `login-frontend` to `authn-backend`",
161
+ command: "dx catalog relationEdges remove --relation service-depends-on-service --source login-frontend --target authn-backend",
162
+ },
163
+ {
164
+ label: "Remove an edge and return JSON",
165
+ command: "dx catalog relationEdges remove --relation service-depends-on-service --source login-frontend --target authn-backend --json",
166
+ },
167
+ ]))
168
+ .action(wrapAction(async (options, command) => {
169
+ const edge = {
170
+ relation: options.relation,
171
+ source: options.source,
172
+ target: options.target,
173
+ };
174
+ const runtime = await buildRuntime(getContext(command));
175
+ const response = await removeRelationEdge(runtime, edge);
176
+ if (runtime.context.json) {
177
+ renderJson(response);
178
+ }
179
+ else {
180
+ renderRelationEdgeRemoved(edge);
181
+ }
182
+ }));
183
+ return relationEdges;
184
+ }
185
+ async function addRelationEdge(runtime, edge) {
186
+ const response = await request(runtime, "/catalog.relationEdges.add", {
187
+ method: "POST",
188
+ body: edgeBody(edge),
189
+ });
190
+ return response.body;
191
+ }
192
+ async function bulkUpsertRelationEdges(runtime, relation, edges) {
193
+ const response = await request(runtime, "/catalog.relationEdges.bulkUpsert", {
194
+ method: "POST",
195
+ body: { relation_identifier: relation, edges },
196
+ });
197
+ return response.body;
198
+ }
199
+ async function listRelationEdges(runtime, params) {
200
+ const query = {
201
+ entity_identifier: params.entity_identifier,
202
+ relation_identifier: params.relation_identifier,
203
+ };
204
+ if (params.direction !== undefined)
205
+ query.direction = params.direction;
206
+ if (params.cursor !== undefined)
207
+ query.cursor = params.cursor;
208
+ if (params.limit !== undefined)
209
+ query.limit = params.limit;
210
+ const response = await request(runtime, "/catalog.relationEdges.list", {
211
+ method: "GET",
212
+ query,
213
+ });
214
+ return response.body;
215
+ }
216
+ async function removeRelationEdge(runtime, edge) {
217
+ const response = await request(runtime, "/catalog.relationEdges.remove", {
218
+ method: "POST",
219
+ body: edgeBody(edge),
220
+ });
221
+ return response.body;
222
+ }
223
+ function edgeBody(edge) {
224
+ return {
225
+ relation_identifier: edge.relation,
226
+ source_entity_identifier: edge.source,
227
+ target_entity_identifier: edge.target,
228
+ };
229
+ }
230
+ // --- Input helpers ---
231
+ function parseEdgesMap(raw, sourceLabel) {
232
+ if (raw === null || raw === undefined) {
233
+ throw noEdgesError(sourceLabel);
234
+ }
235
+ if (typeof raw !== "object" || Array.isArray(raw)) {
236
+ throw new CliError("Edges must be an object mapping each source entity identifier to a list of target entity identifiers", EXIT_CODES.ARGUMENT_ERROR);
237
+ }
238
+ for (const [source, targets] of Object.entries(raw)) {
239
+ if (!Array.isArray(targets) ||
240
+ !targets.every((target) => typeof target === "string")) {
241
+ throw new CliError(`Targets for source "${source}" must be a list of entity identifiers`, EXIT_CODES.ARGUMENT_ERROR);
242
+ }
243
+ }
244
+ const edges = raw;
245
+ if (Object.values(edges).every((targets) => targets.length === 0)) {
246
+ throw noEdgesError(sourceLabel);
247
+ }
248
+ return edges;
249
+ }
250
+ function noEdgesError(sourceLabel) {
251
+ return new CliError(`No edges found in ${sourceLabel}`, EXIT_CODES.ARGUMENT_ERROR);
252
+ }
253
+ function readYamlFile(filePath) {
254
+ let content;
255
+ try {
256
+ content = fs.readFileSync(filePath, "utf8");
257
+ }
258
+ catch (err) {
259
+ throw new CliError(`Could not read file "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
260
+ }
261
+ try {
262
+ return parseYaml(content);
263
+ }
264
+ catch (err) {
265
+ throw new CliError(`Failed to parse YAML from "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
266
+ }
267
+ }
268
+ async function readYamlStdin() {
269
+ return new Promise((resolve, reject) => {
270
+ const chunks = [];
271
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
272
+ process.stdin.on("end", () => {
273
+ const content = Buffer.concat(chunks).toString("utf8");
274
+ try {
275
+ resolve(parseYaml(content));
276
+ }
277
+ catch (err) {
278
+ reject(new CliError(`Failed to parse YAML from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR));
279
+ }
280
+ });
281
+ process.stdin.on("error", (err) => {
282
+ reject(new CliError(`Failed to read from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR));
283
+ });
284
+ });
285
+ }
@@ -0,0 +1,45 @@
1
+ import { renderRichText } from "../../renderers.js";
2
+ import * as ui from "../../ui.js";
3
+ export function renderRelationEdgeAdded(edge) {
4
+ renderRichText([
5
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Added edge ${edgeSummary(edge)}`),
6
+ ]);
7
+ }
8
+ export function renderRelationEdgeRemoved(edge) {
9
+ renderRichText([
10
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Removed edge ${edgeSummary(edge)}`),
11
+ ]);
12
+ }
13
+ export function renderRelationEdgesUpserted(relation, edges) {
14
+ const count = Object.values(edges).reduce((sum, targets) => sum + targets.length, 0);
15
+ renderRichText([
16
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Upserted ${ui.bold(count.toString())} ${count === 1 ? "edge" : "edges"} for ${ui.code(relation)}`),
17
+ ]);
18
+ }
19
+ export function renderRelationEdgesTemplateWritten(path) {
20
+ renderRichText([
21
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Blank template written to ${ui.code(path)}.`),
22
+ ui.p(`Edit the file, then run: ${ui.code(`dx catalog relationEdges bulkUpsert --relation <relation-identifier> --from-file ${path}`)}`),
23
+ ]);
24
+ }
25
+ export function renderRelationEdgeList(entities, query, nextCursor) {
26
+ const blocks = [ui.h1("Relation Edges")];
27
+ blocks.push(ui.dl([
28
+ ui.dli("Entity", ui.code(query.entity)),
29
+ ui.dli("Relation", ui.code(query.relation)),
30
+ ui.dli("Direction", query.direction),
31
+ ], { termWidth: 11 }));
32
+ blocks.push(ui.p(`Displaying ${ui.bold(entities.length.toString())} related entities.`));
33
+ if (nextCursor) {
34
+ blocks.push(ui.p(`Next cursor: ${ui.code(nextCursor)}`));
35
+ }
36
+ if (entities.length > 0) {
37
+ blocks.push(ui.ul(entities.map((entity) => ui.li(entity.name
38
+ ? `${entity.name} (${ui.code(entity.identifier)})`
39
+ : ui.code(entity.identifier)))));
40
+ }
41
+ renderRichText(blocks);
42
+ }
43
+ function edgeSummary(edge) {
44
+ return `${ui.code(edge.source)} -> ${ui.code(edge.target)} (${ui.code(edge.relation)})`;
45
+ }