@get-dx/cli 0.9.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.
@@ -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
@@ -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
+ }
@@ -0,0 +1,231 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
3
+ import { CliError, EXIT_CODES } from "../../errors.js";
4
+ import { request } from "../../http.js";
5
+ import { renderJson } from "../../renderers.js";
6
+ import { buildRuntime } from "../../runtime.js";
7
+ import * as ui from "../../ui.js";
8
+ import { renderRelation, renderRelationDeleted, renderRelationList, } from "./relationsRendering.js";
9
+ const RELATION_TYPES = [
10
+ "consumes",
11
+ "depends on",
12
+ "parent of",
13
+ "part of",
14
+ "provides",
15
+ "manages",
16
+ ];
17
+ const RELATION_CARDINALITIES = [
18
+ "one_to_one",
19
+ "one_to_many",
20
+ "many_to_one",
21
+ "many_to_many",
22
+ ];
23
+ const RELATION_TYPE_HELP = `Relation type. One of: ${RELATION_TYPES.map((t) => `"${t}"`).join(", ")}`;
24
+ export function relationsCommand() {
25
+ const relations = new Command()
26
+ .name("relations")
27
+ .description("Manage relation definitions between entity types");
28
+ relations
29
+ .command("create")
30
+ .description("Create a new relation definition between two entity types in your software catalog")
31
+ .requiredOption("--identifier <identifier>", "Unique identifier for the new relation (required)")
32
+ .requiredOption("--source-type <type>", "Entity type identifier for the source side of the relation (required)")
33
+ .requiredOption("--target-type <type>", "Entity type identifier for the target side of the relation (required)")
34
+ .requiredOption("--type <type>", `${RELATION_TYPE_HELP} (required)`)
35
+ .requiredOption("--cardinality <cardinality>", `Relation cardinality. One of: ${RELATION_CARDINALITIES.join(", ")} (required)`)
36
+ .option("--description <desc>", "Description of the relation")
37
+ .addHelpText("afterAll", createExampleText([
38
+ {
39
+ label: "Create a relation between services",
40
+ command: 'dx catalog relations create --identifier service-depends-on-service --source-type service --target-type service --type "depends on" --cardinality many_to_many',
41
+ },
42
+ {
43
+ label: "Create with a description and return JSON",
44
+ command: 'dx catalog relations create --identifier service-provides-package --source-type service --target-type package --type provides --cardinality one_to_many --description "Service provides a client SDK" --json',
45
+ },
46
+ ]))
47
+ .action(wrapAction(async (options, command) => {
48
+ const runtime = await buildRuntime(getContext(command));
49
+ const response = await createRelation(runtime, {
50
+ identifier: options.identifier,
51
+ source_entity_type_identifier: options.sourceType,
52
+ target_entity_type_identifier: options.targetType,
53
+ type: options.type,
54
+ cardinality: options.cardinality,
55
+ description: options.description,
56
+ });
57
+ if (runtime.context.json) {
58
+ renderJson(response);
59
+ }
60
+ else {
61
+ renderRelation(response.relation, `${ui.success(ui.GLYPHS.CHECK)} Relation created`);
62
+ }
63
+ }));
64
+ relations
65
+ .command("delete")
66
+ .description("Delete a relation definition from your software catalog")
67
+ .argument("<identifier>", "Relation identifier")
68
+ .addHelpText("afterAll", createExampleText([
69
+ {
70
+ label: "Delete the `service-depends-on-service` relation",
71
+ command: "dx catalog relations delete service-depends-on-service",
72
+ },
73
+ {
74
+ label: "Delete a relation and return JSON",
75
+ command: "dx catalog relations delete service-depends-on-service --json",
76
+ },
77
+ ]))
78
+ .action(wrapAction(async (identifier, _options, command) => {
79
+ const runtime = await buildRuntime(getContext(command));
80
+ const response = await deleteRelation(runtime, identifier);
81
+ if (runtime.context.json) {
82
+ renderJson(response);
83
+ }
84
+ else {
85
+ renderRelationDeleted(response.relation);
86
+ }
87
+ }));
88
+ relations
89
+ .command("info")
90
+ .description("Get information about a relation definition")
91
+ .argument("<identifier>", "Relation identifier")
92
+ .addHelpText("afterAll", createExampleText([
93
+ {
94
+ label: "Fetch info for the `service-depends-on-service` relation",
95
+ command: "dx catalog relations info service-depends-on-service",
96
+ },
97
+ {
98
+ label: "Fetch info and return as JSON",
99
+ command: "dx catalog relations info service-depends-on-service --json",
100
+ },
101
+ ]))
102
+ .action(wrapAction(async (identifier, _options, command) => {
103
+ const runtime = await buildRuntime(getContext(command));
104
+ const response = await getRelation(runtime, identifier);
105
+ if (runtime.context.json) {
106
+ renderJson(response);
107
+ }
108
+ else {
109
+ renderRelation(response.relation);
110
+ }
111
+ }));
112
+ relations
113
+ .command("list")
114
+ .description("List relation definitions in your software catalog")
115
+ .option("--cursor <cursor>", "Cursor for the next page of results")
116
+ .option("--limit <n>", "Max relations per page (default is 25)", (value) => parsePositiveIntOption(value, "--limit"))
117
+ .addHelpText("afterAll", createExampleText([
118
+ {
119
+ label: "List the first page of relations",
120
+ command: "dx catalog relations list",
121
+ },
122
+ {
123
+ label: "List with a limit and return JSON",
124
+ command: "dx catalog relations list --limit 10 --json",
125
+ },
126
+ {
127
+ label: "Fetch the next page using a cursor from the prior response",
128
+ command: "dx catalog relations list --cursor avsgf30ccan3",
129
+ },
130
+ ]))
131
+ .action(wrapAction(async (options, command) => {
132
+ const runtime = await buildRuntime(getContext(command));
133
+ const response = await listRelations(runtime, {
134
+ cursor: options.cursor,
135
+ limit: options.limit,
136
+ });
137
+ if (runtime.context.json) {
138
+ renderJson(response);
139
+ }
140
+ else {
141
+ renderRelationList(response.relations, response.response_metadata?.next_cursor ?? null);
142
+ }
143
+ }));
144
+ relations
145
+ .command("update")
146
+ .description("Update a relation definition. The identifier, source and target entity types, and cardinality cannot be changed.")
147
+ .argument("<identifier>", "Relation identifier")
148
+ .option("--type <type>", RELATION_TYPE_HELP)
149
+ .option("--description <desc>", "Description of the relation")
150
+ .addHelpText("afterAll", createExampleText([
151
+ {
152
+ label: "Update a relation's description",
153
+ command: 'dx catalog relations update service-depends-on-service --description "Runtime dependencies between services"',
154
+ },
155
+ {
156
+ label: "Change the relation type and return JSON",
157
+ command: "dx catalog relations update service-provides-package --type provides --json",
158
+ },
159
+ ]))
160
+ .action(wrapAction(async (identifier, options, command) => {
161
+ if (options.type === undefined && options.description === undefined) {
162
+ throw new CliError("Provide at least one of --type or --description to update", EXIT_CODES.ARGUMENT_ERROR);
163
+ }
164
+ const runtime = await buildRuntime(getContext(command));
165
+ const response = await updateRelation(runtime, identifier, {
166
+ type: options.type,
167
+ description: options.description,
168
+ });
169
+ if (runtime.context.json) {
170
+ renderJson(response);
171
+ }
172
+ else {
173
+ renderRelation(response.relation, `${ui.success(ui.GLYPHS.CHECK)} Relation updated`);
174
+ }
175
+ }));
176
+ return relations;
177
+ }
178
+ async function createRelation(runtime, params) {
179
+ const body = {
180
+ identifier: params.identifier,
181
+ source_entity_type_identifier: params.source_entity_type_identifier,
182
+ target_entity_type_identifier: params.target_entity_type_identifier,
183
+ type: params.type,
184
+ cardinality: params.cardinality,
185
+ };
186
+ if (params.description !== undefined)
187
+ body.description = params.description;
188
+ const response = await request(runtime, "/catalog.relations.create", {
189
+ method: "POST",
190
+ body,
191
+ });
192
+ return response.body;
193
+ }
194
+ async function deleteRelation(runtime, identifier) {
195
+ const response = await request(runtime, "/catalog.relations.delete", {
196
+ method: "POST",
197
+ query: { identifier },
198
+ });
199
+ return response.body;
200
+ }
201
+ async function getRelation(runtime, identifier) {
202
+ const response = await request(runtime, "/catalog.relations.info", {
203
+ method: "GET",
204
+ query: { identifier },
205
+ });
206
+ return response.body;
207
+ }
208
+ async function listRelations(runtime, params) {
209
+ const query = {};
210
+ if (params.cursor !== undefined)
211
+ query.cursor = params.cursor;
212
+ if (params.limit !== undefined)
213
+ query.limit = params.limit;
214
+ const response = await request(runtime, "/catalog.relations.list", {
215
+ method: "GET",
216
+ query,
217
+ });
218
+ return response.body;
219
+ }
220
+ async function updateRelation(runtime, identifier, params) {
221
+ const body = { identifier };
222
+ if (params.type !== undefined)
223
+ body.type = params.type;
224
+ if (params.description !== undefined)
225
+ body.description = params.description;
226
+ const response = await request(runtime, "/catalog.relations.update", {
227
+ method: "POST",
228
+ body,
229
+ });
230
+ return response.body;
231
+ }
@@ -0,0 +1,37 @@
1
+ import { renderRichText } from "../../renderers.js";
2
+ import * as ui from "../../ui.js";
3
+ export function renderRelationList(relations, nextCursor) {
4
+ const blocks = [ui.h1("Relations")];
5
+ blocks.push(ui.p(`Displaying ${ui.bold(relations.length.toString())} relations.`));
6
+ if (nextCursor) {
7
+ blocks.push(ui.p(`Next cursor: ${ui.code(nextCursor)}`));
8
+ }
9
+ for (const relation of relations) {
10
+ blocks.push(ui.h2(ui.code(relation.identifier)));
11
+ blocks.push(...relationContent(relation));
12
+ }
13
+ renderRichText(blocks);
14
+ }
15
+ export function renderRelation(relation, title = "Relation Information") {
16
+ renderRichText([ui.h1(title), ...relationContent(relation)]);
17
+ }
18
+ export function renderRelationDeleted(relation) {
19
+ renderRichText([
20
+ ui.h1(`${ui.success(ui.GLYPHS.CHECK)} Relation deleted`),
21
+ ui.p(`The relation ${ui.code(relation.identifier)} has been deleted.`),
22
+ ]);
23
+ }
24
+ function relationContent(relation) {
25
+ return [
26
+ ui.dl([
27
+ ui.dli("Identifier", ui.code(relation.identifier)),
28
+ ui.dli("Relation", `${ui.code(relation.source_entity_type_identifier)} ${relation.type} ${ui.code(relation.target_entity_type_identifier)}`),
29
+ ui.dli("Type", relation.type),
30
+ ui.dli("Inverse type", relation.inverse_type),
31
+ ui.dli("Cardinality", ui.code(relation.cardinality)),
32
+ ui.dli("Description", relation.description || ui.dim("(None)")),
33
+ ui.dli("Created", ui.timestampSummary(relation.created_at)),
34
+ ui.dli("Last updated", ui.timestampSummary(relation.updated_at)),
35
+ ], { termWidth: 15 }),
36
+ ];
37
+ }
@@ -1,11 +1,15 @@
1
1
  import { Command } from "commander";
2
2
  import { entitiesCommand } from "./catalog/entities.js";
3
3
  import { entityTypesCommand } from "./catalog/entityTypes.js";
4
+ import { relationEdgesCommand } from "./catalog/relationEdges.js";
5
+ import { relationsCommand } from "./catalog/relations.js";
4
6
  export function catalogCommand() {
5
7
  const catalog = new Command()
6
8
  .name("catalog")
7
9
  .description("Manage the Software Catalog");
8
10
  catalog.addCommand(entitiesCommand());
9
11
  catalog.addCommand(entityTypesCommand());
12
+ catalog.addCommand(relationEdgesCommand());
13
+ catalog.addCommand(relationsCommand());
10
14
  return catalog;
11
15
  }
@@ -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 } from "../errors.js";
4
4
  import { request } from "../http.js";
5
5
  import { renderJson, renderRichText } from "../renderers.js";
@@ -165,6 +165,60 @@ export function snapshotsCommand() {
165
165
  }
166
166
  }));
167
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);
168
222
  return snapshots;
169
223
  }
170
224
  async function listSnapshotDriverComments(runtime, options) {
@@ -196,6 +250,57 @@ async function listSnapshotTeams(runtime, snapshotId) {
196
250
  });
197
251
  return response.body;
198
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
+ }
199
304
  async function getSnapshotInfo(runtime, snapshotId) {
200
305
  const response = await request(runtime, "/snapshots.info", {
201
306
  method: "GET",
@@ -314,6 +419,70 @@ function renderSnapshotInfo(snapshot, snapshotId, runtime) {
314
419
  }
315
420
  renderRichText(blocks);
316
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
+ }
317
486
  function renderSnapshots(snapshots, runtime) {
318
487
  const blocks = [ui.h1("Snapshots")];
319
488
  blocks.push(ui.p(`Displaying ${ui.bold(snapshots.length.toString())} snapshots.`));
@@ -466,19 +635,44 @@ function formatBenchmarkTable(teamScore) {
466
635
  ["vs_90th", formatBenchmarkNumber(teamScore.vs_90th)],
467
636
  ];
468
637
  const columns = ["Benchmark", "Value"];
469
- const widths = columns.map((column, index) => Math.max(column.length, ...rows.map((row) => row[index].length)));
470
- const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
471
- const header = `| ${columns.map((column, index) => column.padEnd(widths[index])).join(" | ")} |`;
472
- const body = rows.map((row) => `| ${row.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`);
473
- return [border, header, border, ...body, border].join("\n");
638
+ return buildTable(columns, rows);
474
639
  }
475
640
  function formatBenchmarkNumber(value) {
476
641
  return typeof value === "number" && Number.isFinite(value)
477
642
  ? value.toString()
478
643
  : "(None)";
479
644
  }
480
- function snapshotWebLink(snapshotId, section, runtime) {
481
- 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);
482
676
  }
483
677
  function parseRequiredTextOption(value, flag) {
484
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" });
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {