@get-dx/cli 0.6.2 → 0.8.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
@@ -6,6 +6,7 @@ import { scorecardsCommand } from "./commands/scorecards.js";
6
6
  import { snapshotsCommand } from "./commands/snapshots.js";
7
7
  import { studioCommand } from "./commands/studio.js";
8
8
  import { teamsCommand } from "./commands/teams.js";
9
+ import { usersCommand } from "./commands/users.js";
9
10
  import { workflowRunsCommand } from "./commands/workflowRuns.js";
10
11
  import { workflowsCommand } from "./commands/workflows.js";
11
12
  import { handleError } from "./commandHelpers.js";
@@ -54,6 +55,7 @@ function createProgram() {
54
55
  program.addCommand(snapshotsCommand());
55
56
  program.addCommand(studioCommand());
56
57
  program.addCommand(teamsCommand());
58
+ program.addCommand(usersCommand());
57
59
  program.addCommand(workflowRunsCommand());
58
60
  program.addCommand(workflowsCommand());
59
61
  applyExitOverride(program);
@@ -14,9 +14,9 @@ export function entityTypesCommand() {
14
14
  .description("Manage catalog entity types");
15
15
  entityTypes
16
16
  .command("create")
17
- .description("Create a new entity type from a YAML file or stdin. The `init` command can be used to generate a starting template.")
18
- .option("--from-file <path>", "Read a YAML file and create an entity type from its contents")
19
- .option("--from-stdin", "Read YAML from stdin and create an entity type from its contents")
17
+ .description("Create a new entity type from a YAML or JSON file or stdin. The `init` command can be used to generate a starting template.")
18
+ .option("--from-file <path>", "Read a YAML or JSON file and create an entity type from its contents")
19
+ .option("--from-stdin", "Read YAML or JSON from stdin and create an entity type from its contents")
20
20
  .addHelpText("afterAll", createExampleText([
21
21
  {
22
22
  label: "Generate a blank template first",
@@ -205,10 +205,10 @@ export function entityTypesCommand() {
205
205
  }));
206
206
  entityTypes
207
207
  .command("update")
208
- .description("Update an existing entity type from a YAML file or stdin. The `init` command can be used to initialize the entity type file.")
208
+ .description("Update an existing entity type from a YAML or JSON file or stdin. The `init` command can be used to initialize the entity type file.")
209
209
  .argument("<identifier>", "The unique identifier of the entity type")
210
- .option("--from-file <path>", "Read a YAML file and update the entity type with its contents")
211
- .option("--from-stdin", "Read YAML from stdin and update the entity type with its contents")
210
+ .option("--from-file <path>", "Read a YAML or JSON file and update the entity type with its contents")
211
+ .option("--from-stdin", "Read YAML or JSON from stdin and update the entity type with its contents")
212
212
  .addHelpText("afterAll", createExampleText([
213
213
  {
214
214
  label: "Initialize a file first",
@@ -14,9 +14,9 @@ export function scorecardsCommand() {
14
14
  .description("Manage scorecards");
15
15
  scorecards
16
16
  .command("create")
17
- .description("Create a new scorecard from a YAML file or stdin. The `init` command can be used to generate a starting template.")
18
- .option("--from-file <path>", "Read a YAML file and create a scorecard from its contents")
19
- .option("--from-stdin", "Read YAML from stdin and create a scorecard from its contents")
17
+ .description("Create a new scorecard from a YAML or JSON file or stdin. The `init` command can be used to generate a starting template.")
18
+ .option("--from-file <path>", "Read a YAML or JSON file and create a scorecard from its contents")
19
+ .option("--from-stdin", "Read YAML or JSON from stdin and create a scorecard from its contents")
20
20
  .addHelpText("afterAll", createExampleText([
21
21
  {
22
22
  label: "Generate a blank template first",
@@ -216,10 +216,10 @@ export function scorecardsCommand() {
216
216
  }));
217
217
  scorecards
218
218
  .command("update")
219
- .description("Update an existing scorecard from a YAML file or stdin. The `init` command can be used to initialize the scorecard file.")
219
+ .description("Update an existing scorecard from a YAML or JSON file or stdin. The `init` command can be used to initialize the scorecard file.")
220
220
  .argument("<id>", "The unique ID of the scorecard")
221
- .option("--from-file <path>", "Read a YAML file and update the scorecard with its contents")
222
- .option("--from-stdin", "Read YAML from stdin and update the scorecard with its contents")
221
+ .option("--from-file <path>", "Read a YAML or JSON file and update the scorecard with its contents")
222
+ .option("--from-stdin", "Read YAML or JSON from stdin and update the scorecard with its contents")
223
223
  .addHelpText("afterAll", createExampleText([
224
224
  {
225
225
  label: "Initialize a file first",
@@ -17,9 +17,9 @@ export function reportsCommand() {
17
17
  .description("Manage Data Studio reports");
18
18
  reports
19
19
  .command("create")
20
- .description("Create a Data Studio report from a YAML file or stdin")
21
- .option("--from-file <path>", "Read a YAML file and create a report from its contents")
22
- .option("--from-stdin", "Read YAML from stdin and create a report from its contents")
20
+ .description("Create a Data Studio report from a YAML or JSON file or stdin")
21
+ .option("--from-file <path>", "Read a YAML or JSON file and create a report from its contents")
22
+ .option("--from-stdin", "Read YAML or JSON from stdin and create a report from its contents")
23
23
  .addHelpText("afterAll", createNoteText(VARIABLES_NOTE_TEXT))
24
24
  .addHelpText("afterAll", createExampleText([
25
25
  {
@@ -175,10 +175,10 @@ export function reportsCommand() {
175
175
  }));
176
176
  reports
177
177
  .command("update")
178
- .description("Update a Data Studio report from a YAML file or stdin. The `init` command can be used to initialize the report file.")
178
+ .description("Update a Data Studio report from a YAML or JSON file or stdin. The `init` command can be used to initialize the report file.")
179
179
  .argument("<id>", "Studio report ID")
180
- .option("--from-file <path>", "Read a YAML file and update the report with its contents")
181
- .option("--from-stdin", "Read YAML from stdin and update the report with its contents")
180
+ .option("--from-file <path>", "Read a YAML or JSON file and update the report with its contents")
181
+ .option("--from-stdin", "Read YAML or JSON from stdin and update the report with its contents")
182
182
  .addHelpText("afterAll", createNoteText(VARIABLES_NOTE_TEXT))
183
183
  .addHelpText("afterAll", createExampleText([
184
184
  {
@@ -0,0 +1,99 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
3
+ import { CliError, EXIT_CODES } from "../../errors.js";
4
+ import { requestWithMessages } from "../../http.js";
5
+ import { renderJson, renderRichText } from "../../renderers.js";
6
+ import { buildRuntime } from "../../runtime.js";
7
+ import * as ui from "../../ui.js";
8
+ import { SOURCES } from "./sources.js";
9
+ export function linkableAccountsCommand() {
10
+ const linkableAccounts = new Command()
11
+ .name("linkableAccounts")
12
+ .description("Search accounts that can be linked to DX users");
13
+ linkableAccounts
14
+ .command("list")
15
+ .description("Search the accounts DX has imported from a source, and get the account IDs to pass to `dx users links` commands")
16
+ .requiredOption("--source <source>", `Source to search (${SOURCES})`)
17
+ .option("--query <query>", "Case-insensitive text to find in the account's name, email, or username")
18
+ .option("--external-id <id>", "Only return the account with this ID from the tool itself")
19
+ .option("--instance-id <id>", "Only return accounts from this instance, organization, workspace, or connection")
20
+ .option("--linked", "Only return accounts linked to a DX user")
21
+ .option("--unlinked", "Only return accounts not linked to a DX user")
22
+ .option("--page <n>", "Page number to return (default is 1)", (value) => parsePositiveIntOption(value, "--page"))
23
+ .option("--page-size <n>", "Max accounts per page (default and maximum is 100)", (value) => parsePositiveIntOption(value, "--page-size"))
24
+ .addHelpText("afterAll", createExampleText([
25
+ {
26
+ label: "Search unlinked GitHub accounts matching a name",
27
+ command: "dx users linkableAccounts list --source github --query jane --unlinked",
28
+ },
29
+ {
30
+ label: "Find a Jira account by its Jira ID and return JSON",
31
+ command: "dx users linkableAccounts list --source jira --external-id 5b10ac8d82e05b22cc7d4ef5 --json",
32
+ },
33
+ {
34
+ label: "Fetch the next page of results",
35
+ command: "dx users linkableAccounts list --source github --page 2",
36
+ },
37
+ ]))
38
+ .action(wrapAction(async (options, command) => {
39
+ if (options.linked && options.unlinked) {
40
+ throw new CliError("--linked and --unlinked are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
41
+ }
42
+ const runtime = await buildRuntime(getContext(command));
43
+ const response = await listLinkableAccounts(runtime, {
44
+ source: options.source,
45
+ query: options.query,
46
+ external_id: options.externalId,
47
+ instance_id: options.instanceId,
48
+ linked: options.unlinked ? false : options.linked,
49
+ page: options.page,
50
+ page_size: options.pageSize,
51
+ });
52
+ if (runtime.context.json) {
53
+ renderJson(response);
54
+ }
55
+ else {
56
+ renderLinkableAccounts(response);
57
+ }
58
+ }));
59
+ return linkableAccounts;
60
+ }
61
+ const ERROR_MESSAGES = {
62
+ query_timeout: "The search timed out. Narrow it with --instance-id, --external-id, or a longer --query, then try again.",
63
+ };
64
+ async function listLinkableAccounts(runtime, params) {
65
+ return requestWithMessages(runtime, "/users.linkableAccounts.list", {
66
+ method: "GET",
67
+ query: params,
68
+ }, ERROR_MESSAGES);
69
+ }
70
+ function renderLinkableAccounts(response) {
71
+ const blocks = [ui.h1("Linkable Accounts")];
72
+ blocks.push(ui.p(`Displaying ${ui.bold(response.accounts.length.toString())} of ${ui.bold(response.total.toString())} accounts.`));
73
+ if (response.next_page) {
74
+ blocks.push(ui.p(`Next page: ${ui.code(response.next_page.toString())}`));
75
+ }
76
+ for (const account of response.accounts) {
77
+ blocks.push(ui.h2(`${account.label} (${ui.code(account.id)})`));
78
+ blocks.push(ui.dl([
79
+ ...accountDetailItems(account),
80
+ ui.dli("Linked user", account.linked_user_id
81
+ ? ui.code(account.linked_user_id)
82
+ : ui.dim("(Not linked)")),
83
+ ], { termWidth: 13 }));
84
+ }
85
+ renderRichText(blocks);
86
+ }
87
+ export function accountDetailItems(account) {
88
+ return [
89
+ ui.dli("Source", account.source),
90
+ ui.dli("External ID", formatCode(account.external_id)),
91
+ ui.dli("Instance ID", formatCode(account.instance_id)),
92
+ ui.dli("Name", account.name ?? ui.dim("(None)")),
93
+ ui.dli("Email", account.email ?? ui.dim("(None)")),
94
+ ui.dli("Username", account.username ?? ui.dim("(None)")),
95
+ ];
96
+ }
97
+ function formatCode(value) {
98
+ return value ? ui.code(value) : ui.dim("(None)");
99
+ }
@@ -0,0 +1,205 @@
1
+ import { Command } from "commander";
2
+ import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
3
+ import { requestWithMessages } from "../../http.js";
4
+ import { renderJson, renderRichText } from "../../renderers.js";
5
+ import { buildRuntime } from "../../runtime.js";
6
+ import * as ui from "../../ui.js";
7
+ import { accountDetailItems } from "./linkableAccounts.js";
8
+ import { SOURCES } from "./sources.js";
9
+ export function linksCommand() {
10
+ const links = new Command()
11
+ .name("links")
12
+ .description("Manage the accounts linked to DX users");
13
+ links
14
+ .command("create")
15
+ .description("Link one account to a user, keeping the user's other linked accounts for that source")
16
+ .argument("<user-id>", "DX user ID")
17
+ .requiredOption("--source <source>", `Source the account comes from (${SOURCES})`)
18
+ .requiredOption("--account-id <id>", "Account ID to link, as returned by `dx users linkableAccounts list`")
19
+ .addHelpText("afterAll", createExampleText([
20
+ {
21
+ label: "Link a GitHub account to a user",
22
+ command: "dx users links create NTEyMDUw --source github --account-id 4812",
23
+ },
24
+ {
25
+ label: "Link an account and return JSON",
26
+ command: "dx users links create NTEyMDUw --source github --account-id 4812 --json",
27
+ },
28
+ ]))
29
+ .action(wrapAction(async (userId, options, command) => {
30
+ const runtime = await buildRuntime(getContext(command));
31
+ const response = await createLink(runtime, {
32
+ user_id: userId,
33
+ source: options.source,
34
+ account_id: options.accountId,
35
+ });
36
+ if (runtime.context.json) {
37
+ renderJson(response);
38
+ }
39
+ else {
40
+ renderRichText([
41
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Linked ${options.source} account ${ui.code(options.accountId)} to user ${ui.code(userId)}.`),
42
+ ]);
43
+ }
44
+ }));
45
+ links
46
+ .command("delete")
47
+ .description("Unlink one account from a user, keeping the user's other linked accounts for that source")
48
+ .argument("<user-id>", "DX user ID")
49
+ .requiredOption("--source <source>", `Source the account comes from (${SOURCES})`)
50
+ .requiredOption("--account-id <id>", "Account ID to unlink")
51
+ .addHelpText("afterAll", createExampleText([
52
+ {
53
+ label: "Unlink a GitHub account from a user",
54
+ command: "dx users links delete NTEyMDUw --source github --account-id 4812",
55
+ },
56
+ {
57
+ label: "Unlink an account and return JSON",
58
+ command: "dx users links delete NTEyMDUw --source github --account-id 4812 --json",
59
+ },
60
+ ]))
61
+ .action(wrapAction(async (userId, options, command) => {
62
+ const runtime = await buildRuntime(getContext(command));
63
+ const response = await deleteLink(runtime, {
64
+ user_id: userId,
65
+ source: options.source,
66
+ account_id: options.accountId,
67
+ });
68
+ if (runtime.context.json) {
69
+ renderJson(response);
70
+ }
71
+ else {
72
+ renderRichText([
73
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Unlinked ${options.source} account ${ui.code(options.accountId)} from user ${ui.code(userId)}.`),
74
+ ]);
75
+ }
76
+ }));
77
+ links
78
+ .command("list")
79
+ .description("List the accounts linked to a user")
80
+ .argument("<user-id>", "DX user ID")
81
+ .option("--source <source>", `Only return accounts from this source (${SOURCES})`)
82
+ .option("--page <n>", "Page number to return (default is 1)", (value) => parsePositiveIntOption(value, "--page"))
83
+ .option("--page-size <n>", "Max links per page (default and maximum is 100)", (value) => parsePositiveIntOption(value, "--page-size"))
84
+ .addHelpText("afterAll", createExampleText([
85
+ {
86
+ label: "List every account linked to a user",
87
+ command: "dx users links list NTEyMDUw",
88
+ },
89
+ {
90
+ label: "List a user's GitHub accounts and return JSON",
91
+ command: "dx users links list NTEyMDUw --source github --json",
92
+ },
93
+ {
94
+ label: "Fetch the next page of links",
95
+ command: "dx users links list NTEyMDUw --page 2",
96
+ },
97
+ ]))
98
+ .action(wrapAction(async (userId, options, command) => {
99
+ const runtime = await buildRuntime(getContext(command));
100
+ const response = await listLinks(runtime, {
101
+ user_id: userId,
102
+ source: options.source,
103
+ page: options.page,
104
+ page_size: options.pageSize,
105
+ });
106
+ if (runtime.context.json) {
107
+ renderJson(response);
108
+ }
109
+ else {
110
+ renderLinks(userId, response);
111
+ }
112
+ }));
113
+ links
114
+ .command("set")
115
+ .description("Replace every account linked to a user for one source. Accounts not in the list are unlinked.")
116
+ .argument("<user-id>", "DX user ID")
117
+ .requiredOption("--source <source>", `Source the accounts come from (${SOURCES})`)
118
+ .requiredOption("--account-ids <ids>", 'Comma-separated account IDs to link, up to 100. Pass "" to unlink every account for the source.')
119
+ .addHelpText("afterAll", createExampleText([
120
+ {
121
+ label: "Link exactly these two GitHub accounts to a user",
122
+ command: "dx users links set NTEyMDUw --source github --account-ids 4812,4813",
123
+ },
124
+ {
125
+ label: "Unlink every GitHub account from a user",
126
+ command: 'dx users links set NTEyMDUw --source github --account-ids ""',
127
+ },
128
+ {
129
+ label: "Replace a user's Jira accounts and return JSON",
130
+ command: "dx users links set NTEyMDUw --source jira --account-ids 991 --json",
131
+ },
132
+ ]))
133
+ .action(wrapAction(async (userId, options, command) => {
134
+ const accountIds = options.accountIds
135
+ .split(",")
136
+ .map((id) => id.trim())
137
+ .filter((id) => id.length > 0);
138
+ const runtime = await buildRuntime(getContext(command));
139
+ const response = await setLinks(runtime, {
140
+ user_id: userId,
141
+ source: options.source,
142
+ account_ids: accountIds,
143
+ });
144
+ if (runtime.context.json) {
145
+ renderJson(response);
146
+ }
147
+ else {
148
+ renderRichText([
149
+ ui.p(accountIds.length > 0
150
+ ? `${ui.success(ui.GLYPHS.CHECK)} Set the ${options.source} accounts linked to user ${ui.code(userId)} to ${accountIds.map((id) => ui.code(id)).join(", ")}.`
151
+ : `${ui.success(ui.GLYPHS.CHECK)} Unlinked every ${options.source} account from user ${ui.code(userId)}.`),
152
+ ]);
153
+ }
154
+ }));
155
+ return links;
156
+ }
157
+ const NOT_SYNCED = "This user hasn't synced to Data Cloud yet. Try again once their next sync finishes.";
158
+ const ERROR_MESSAGES = {
159
+ link_update_in_progress: "Another link update for this user and source is still running. Try again in a few seconds.",
160
+ manual_link_conflict: "An account is manually linked to another user. Remove that link before linking it to this user.",
161
+ not_found: "The user or account wasn't found. Check the user ID, and look up account IDs with `dx users linkableAccounts list`.",
162
+ user_not_in_datacloud: NOT_SYNCED,
163
+ // users.links.set passes Data Cloud's own error string through instead of a code.
164
+ "User not found in datacloud": NOT_SYNCED,
165
+ };
166
+ async function createLink(runtime, params) {
167
+ return requestWithMessages(runtime, "/users.links.create", {
168
+ method: "POST",
169
+ body: params,
170
+ }, ERROR_MESSAGES);
171
+ }
172
+ async function deleteLink(runtime, params) {
173
+ return requestWithMessages(runtime, "/users.links.delete", {
174
+ method: "POST",
175
+ body: params,
176
+ }, ERROR_MESSAGES);
177
+ }
178
+ async function listLinks(runtime, params) {
179
+ return requestWithMessages(runtime, "/users.links.list", {
180
+ method: "GET",
181
+ query: params,
182
+ }, ERROR_MESSAGES);
183
+ }
184
+ async function setLinks(runtime, params) {
185
+ return requestWithMessages(runtime, "/users.links.set", {
186
+ method: "POST",
187
+ body: params,
188
+ }, ERROR_MESSAGES);
189
+ }
190
+ function renderLinks(userId, response) {
191
+ const blocks = [ui.h1("Linked Accounts")];
192
+ blocks.push(ui.p(`Displaying ${ui.bold(response.links.length.toString())} of ${ui.bold(response.total.toString())} links for user ${ui.code(userId)}.`));
193
+ if (response.next_page) {
194
+ blocks.push(ui.p(`Next page: ${ui.code(response.next_page.toString())}`));
195
+ }
196
+ for (const link of response.links) {
197
+ blocks.push(ui.h2(link.account
198
+ ? `${link.account.label} (${ui.code(link.account_id)})`
199
+ : `${link.source} account (${ui.code(link.account_id)})`));
200
+ blocks.push(ui.dl(link.account
201
+ ? accountDetailItems(link.account)
202
+ : [ui.dli("Source", link.source)], { termWidth: 13 }));
203
+ }
204
+ renderRichText(blocks);
205
+ }
@@ -0,0 +1 @@
1
+ export const SOURCES = "ado, amazon_kiro, bitbucket, bitbucket_server, claude_code, codex, cursor, github, gitlab, jira, linear, windsurf";
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+ import { linkableAccountsCommand } from "./users/linkableAccounts.js";
3
+ import { linksCommand } from "./users/links.js";
4
+ export function usersCommand() {
5
+ const users = new Command().name("users").description("Manage DX users");
6
+ users.addCommand(linkableAccountsCommand());
7
+ users.addCommand(linksCommand());
8
+ return users;
9
+ }
@@ -0,0 +1,78 @@
1
+ # Self-service workflow configuration
2
+ #
3
+ # Edit this file, then run:
4
+ #
5
+ # ```
6
+ # dx workflows create --from-file <path>
7
+ # ```
8
+
9
+ # Unique per account. Letters, numbers, ".", "_" and "-" only; "new" is reserved.
10
+ identifier: ""
11
+ name: ""
12
+ description: ""
13
+ # icon: globe
14
+ color: "#38bdf8"
15
+
16
+ # scope: "GLOBAL" (not tied to a catalog entity) or "ENTITY" (run against an entity)
17
+ scope: GLOBAL
18
+
19
+ # execution_type: "SIMPLE" or "EVENT_DRIVEN"
20
+ execution_type: SIMPLE
21
+
22
+ # Who can trigger a run:
23
+ # ANY - all users, no approval
24
+ # APPROVAL - all users, but an approver must accept (set approver_user_ids)
25
+ # RUNNERS - only selected users and teams (set runners)
26
+ # ENTITY - only owners of the entity (requires scope: ENTITY)
27
+ trigger_type: ANY
28
+ # approver_user_ids: ["<active user id>"] # at least one when APPROVAL
29
+ # runners: # at least one when RUNNERS
30
+ # - user_id: "<user id>" # each runner sets exactly one of
31
+ # - squad_id: "<team id>" # user_id or squad_id
32
+ # squad_linkage_id: null # optional
33
+
34
+ # Which entities the workflow can run against (only used when scope is ENTITY).
35
+ # entity_filter_type: "ENTITY_TYPES" or "SQL"
36
+ entity_filter_type: ENTITY_TYPES
37
+ # Entity type identifiers (when entity_filter_type is "ENTITY_TYPES"; at least
38
+ # one is required when scope is ENTITY)
39
+ entity_filter_type_identifiers: []
40
+ # SQL filter expression (required when scope is ENTITY and entity_filter_type is "SQL")
41
+ # entity_filter_sql: ""
42
+
43
+ # The HTTP request sent when the workflow runs.
44
+ # The url, body, header values and form_data values can use variables:
45
+ # {{run.id}}, {{workflow.identifier}}, {{workflow.name}},
46
+ # {{triggered_by_user.id}}, {{triggered_by_user.name}}, {{triggered_by_user.email}},
47
+ # {{triggered_by_user.team_id}}, {{triggered_by_user.team_name}},
48
+ # {{entity.identifier}}, {{entity.name}} (scope: ENTITY only),
49
+ # {{data.<parameter identifier>}}, {{secret.<name>}}
50
+ http_request:
51
+ # method: GET, POST, PUT, PATCH or DELETE
52
+ method: POST
53
+ url: "https://example.com/hooks/my-workflow"
54
+ headers:
55
+ Content-Type: application/json
56
+ # A string or null. Must be null when form_data is used.
57
+ body: |-
58
+ {"run_id": "{{run.id}}"}
59
+ # form_data:
60
+ # - key: example
61
+ # value: "{{data.example}}"
62
+
63
+ # Inputs collected from the user when triggering a run. Each parameter needs an
64
+ # identifier (letters, numbers, underscores), a name, a type and is_required.
65
+ parameters: []
66
+ # parameters:
67
+ # - identifier: environment
68
+ # name: Environment
69
+ # description: ""
70
+ # # type: STRING, INTEGER, FLOAT, BOOLEAN, SELECT, USER, TEAM, EMAIL or COMPUTED
71
+ # type: SELECT
72
+ # is_required: true
73
+ # default_value: staging
74
+ # # SELECT: {options: [...]} COMPUTED: {sql: "..."} other types: omit
75
+ # definition:
76
+ # options:
77
+ # - staging
78
+ # - production
@@ -1,15 +1,82 @@
1
+ import fs from "fs";
1
2
  import { Command } from "commander";
3
+ import { parse as parseYaml } from "yaml";
2
4
  import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
3
5
  import { CliError, EXIT_CODES } from "../errors.js";
4
6
  import { request } from "../http.js";
5
- import { renderJson } from "../renderers.js";
7
+ import { renderJson, renderRichText } from "../renderers.js";
6
8
  import { buildRuntime } from "../runtime.js";
7
- import { renderWorkflowList } from "./workflowsRendering.js";
9
+ import * as ui from "../ui.js";
10
+ import { renderWorkflowCreated, renderWorkflowList, } from "./workflowsRendering.js";
11
+ const WORKFLOW_BLANK_TEMPLATE_YAML = fs.readFileSync(new URL("./workflow-blank-template.yaml", import.meta.url), "utf8");
8
12
  const WORKFLOW_SCOPES = ["GLOBAL", "ENTITY"];
9
13
  export function workflowsCommand() {
10
14
  const workflows = new Command()
11
15
  .name("workflows")
12
16
  .description("Manage Self-service workflow definitions");
17
+ workflows
18
+ .command("create")
19
+ .description("Create a Self-service workflow from a YAML or JSON file or stdin. The `init` command can be used to generate a starting template.")
20
+ .option("--from-file <path>", "Read a YAML or JSON file and create a workflow from its contents")
21
+ .option("--from-stdin", "Read YAML or JSON from stdin and create a workflow from its contents")
22
+ .addHelpText("afterAll", createExampleText([
23
+ {
24
+ label: "Create a workflow from a file",
25
+ command: "dx workflows create --from-file workflow.yaml",
26
+ },
27
+ {
28
+ label: "Create a workflow from stdin",
29
+ command: "cat workflow.json | dx workflows create --from-stdin",
30
+ },
31
+ {
32
+ label: "Same request with JSON output",
33
+ command: "dx --json workflows create --from-file workflow.yaml",
34
+ },
35
+ ]))
36
+ .action(wrapAction(async (options, command) => {
37
+ const modeCount = [options.fromFile, options.fromStdin].filter(Boolean).length;
38
+ if (modeCount === 0) {
39
+ throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
40
+ }
41
+ if (modeCount > 1) {
42
+ throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
43
+ }
44
+ const runtime = await buildRuntime(getContext(command));
45
+ const raw = options.fromFile
46
+ ? readYamlFile(options.fromFile)
47
+ : await readYamlStdin();
48
+ const payload = parsePayloadObject(raw);
49
+ const response = await createWorkflow(runtime, payload);
50
+ if (runtime.context.json) {
51
+ renderJson(response);
52
+ }
53
+ else {
54
+ renderWorkflowCreated(response.workflow);
55
+ }
56
+ }));
57
+ workflows
58
+ .command("init")
59
+ .description("Write a blank Self-service workflow YAML template to a file")
60
+ .argument("<path>", "File path to write the YAML to")
61
+ .addHelpText("afterAll", createExampleText([
62
+ {
63
+ label: "Write a blank workflow template",
64
+ command: "dx workflows init ./my-workflow.yaml",
65
+ },
66
+ ]))
67
+ .action(wrapAction(async (path, _options, command) => {
68
+ const runtime = await buildRuntime(getContext(command));
69
+ fs.writeFileSync(path, WORKFLOW_BLANK_TEMPLATE_YAML, "utf8");
70
+ if (runtime.context.json) {
71
+ renderJson({ ok: true, path });
72
+ }
73
+ else {
74
+ renderRichText([
75
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Blank template written to ${ui.code(path)}.`),
76
+ ui.p(`Edit the file, then run: ${ui.code(`dx workflows create --from-file ${path}`)}`),
77
+ ]);
78
+ }
79
+ }));
13
80
  workflows
14
81
  .command("list")
15
82
  .description("List Self-service workflows for the account")
@@ -49,6 +116,45 @@ export function workflowsCommand() {
49
116
  }));
50
117
  return workflows;
51
118
  }
119
+ function readYamlFile(filePath) {
120
+ let content;
121
+ try {
122
+ content = fs.readFileSync(filePath, "utf8");
123
+ }
124
+ catch (err) {
125
+ throw new CliError(`Could not read file "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
126
+ }
127
+ return parseYamlContent(content, `file "${filePath}"`);
128
+ }
129
+ async function readYamlStdin() {
130
+ return new Promise((resolve, reject) => {
131
+ const chunks = [];
132
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
133
+ process.stdin.on("end", () => {
134
+ try {
135
+ resolve(parseYamlContent(Buffer.concat(chunks).toString("utf8"), "stdin"));
136
+ }
137
+ catch (err) {
138
+ reject(err);
139
+ }
140
+ });
141
+ process.stdin.on("error", (err) => reject(new CliError(`Failed to read from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR)));
142
+ });
143
+ }
144
+ function parseYamlContent(content, source) {
145
+ try {
146
+ return parseYaml(content);
147
+ }
148
+ catch (err) {
149
+ throw new CliError(`Failed to parse YAML or JSON from ${source}: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
150
+ }
151
+ }
152
+ function parsePayloadObject(raw) {
153
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
154
+ throw new CliError("Content must be a YAML or JSON object", EXIT_CODES.ARGUMENT_ERROR);
155
+ }
156
+ return raw;
157
+ }
52
158
  function parseOptionalTrimmed(value) {
53
159
  if (typeof value !== "string") {
54
160
  return undefined;
@@ -67,6 +173,10 @@ function parseOptionalScope(value) {
67
173
  return raw;
68
174
  }
69
175
  // --- API ---
176
+ export async function createWorkflow(runtime, payload) {
177
+ const response = await request(runtime, "/workflows.create", { method: "POST", body: payload });
178
+ return response.body;
179
+ }
70
180
  export async function listWorkflows(runtime, params) {
71
181
  const query = {};
72
182
  if (params.scope !== undefined)
@@ -38,18 +38,30 @@ export function renderWorkflowList(workflows) {
38
38
  ui.p(`Displaying ${ui.bold(workflows.length.toString())} workflows.`),
39
39
  ];
40
40
  for (const workflow of workflows) {
41
- blocks.push(ui.h2(`${workflow.name} (${ui.code(workflow.identifier)})`));
42
- blocks.push(ui.h3("Core attributes"));
43
- blocks.push(...coreContent(workflow));
44
- if (workflow.scope === "ENTITY") {
45
- blocks.push(ui.h3("Entity filter"));
46
- blocks.push(...entityFilterContent(workflow));
47
- }
48
- blocks.push(ui.h3("Parameters"));
49
- blocks.push(...parametersContent(workflow));
41
+ blocks.push(...workflowContent(workflow));
50
42
  }
51
43
  renderRichText(blocks);
52
44
  }
45
+ export function renderWorkflowCreated(workflow) {
46
+ renderRichText([
47
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Workflow created: ${ui.code(workflow.identifier)}`),
48
+ ...workflowContent(workflow),
49
+ ]);
50
+ }
51
+ function workflowContent(workflow) {
52
+ const blocks = [
53
+ ui.h2(`${workflow.name} (${ui.code(workflow.identifier)})`),
54
+ ui.h3("Core attributes"),
55
+ ...coreContent(workflow),
56
+ ];
57
+ if (workflow.scope === "ENTITY") {
58
+ blocks.push(ui.h3("Entity filter"));
59
+ blocks.push(...entityFilterContent(workflow));
60
+ }
61
+ blocks.push(ui.h3("Parameters"));
62
+ blocks.push(...parametersContent(workflow));
63
+ return blocks;
64
+ }
53
65
  function coreContent(workflow) {
54
66
  const blocks = [];
55
67
  const description = workflow.description?.trim()
package/dist/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { HttpError } from "./errors.js";
1
+ import { CliError, HttpError } from "./errors.js";
2
2
  export async function request(runtime, route, options = {}) {
3
3
  const method = options.method || "GET";
4
4
  const headers = new Headers({
@@ -72,6 +72,24 @@ export async function request(runtime, route, options = {}) {
72
72
  const body = parsedResponseBody;
73
73
  return retryAfterMs === undefined ? { body } : { body, retryAfterMs };
74
74
  }
75
+ export async function requestWithMessages(runtime, route, options, messages) {
76
+ try {
77
+ const response = await request(runtime, route, options);
78
+ return response.body;
79
+ }
80
+ catch (error) {
81
+ throw runtime.context.json ? error : readableError(error, messages);
82
+ }
83
+ }
84
+ function readableError(error, messages) {
85
+ if (!(error instanceof HttpError)) {
86
+ return error;
87
+ }
88
+ const code = error.body?.error;
89
+ return typeof code === "string" && Object.hasOwn(messages, code)
90
+ ? new CliError(messages[code], error.exitCode)
91
+ : error;
92
+ }
75
93
  /**
76
94
  * Flatten an error thrown by `fetch` into a single readable message.
77
95
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {