@get-dx/cli 0.6.1 → 0.7.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 +2 -0
- package/dist/commandHelpers.js +34 -1
- package/dist/commands/studio/query.js +2 -2
- package/dist/commands/users/linkableAccounts.js +99 -0
- package/dist/commands/users/links.js +205 -0
- package/dist/commands/users/sources.js +1 -0
- package/dist/commands/users.js +9 -0
- package/dist/http.js +51 -3
- package/package.json +1 -1
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);
|
package/dist/commandHelpers.js
CHANGED
|
@@ -58,7 +58,7 @@ export function handleError(error, command, argv) {
|
|
|
58
58
|
}
|
|
59
59
|
else {
|
|
60
60
|
if (error instanceof HttpError) {
|
|
61
|
-
process.stderr.write(pc.red(
|
|
61
|
+
process.stderr.write(pc.red(formatHttpError(error)));
|
|
62
62
|
}
|
|
63
63
|
else if (error instanceof CommanderError &&
|
|
64
64
|
(error.code === "commander.help" || error.message === "(outputHelp)")) {
|
|
@@ -82,6 +82,39 @@ export function handleError(error, command, argv) {
|
|
|
82
82
|
})();
|
|
83
83
|
process.exit(exitCode);
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* `status` and `body` are only populated when the API actually answered.
|
|
87
|
+
* Network, TLS, and non-JSON failures carry their detail in `message` alone, so
|
|
88
|
+
* these helpers coalesce so we display full details.
|
|
89
|
+
*/
|
|
90
|
+
function formatHttpError(error) {
|
|
91
|
+
const heading = formatHttpErrorHeading(error);
|
|
92
|
+
const body = formatHttpErrorBody(error.body);
|
|
93
|
+
return body === null ? `${heading}\n` : `${heading}\n\n${body}\n`;
|
|
94
|
+
}
|
|
95
|
+
function formatHttpErrorHeading(error) {
|
|
96
|
+
if (error.status === undefined) {
|
|
97
|
+
return error.message;
|
|
98
|
+
}
|
|
99
|
+
// A successful or redirect status that still failed the request — an
|
|
100
|
+
// unparseable body, or a redirect missing its Location — isn't an error the
|
|
101
|
+
// API reported, so don't describe it as one.
|
|
102
|
+
if (error.status < 400) {
|
|
103
|
+
return `${error.message} (HTTP ${error.status})`;
|
|
104
|
+
}
|
|
105
|
+
return `The API returned an error with status code ${error.status}: ${error.message}`;
|
|
106
|
+
}
|
|
107
|
+
function formatHttpErrorBody(body) {
|
|
108
|
+
if (body === undefined || body === "") {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
// Non-JSON bodies (proxy block pages, HTML error pages) are far more legible
|
|
112
|
+
// raw than as a JSON string literal full of escaped newlines.
|
|
113
|
+
if (typeof body === "string") {
|
|
114
|
+
return body;
|
|
115
|
+
}
|
|
116
|
+
return JSON.stringify(body, null, 2) ?? String(body);
|
|
117
|
+
}
|
|
85
118
|
function printJson(value) {
|
|
86
119
|
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
87
120
|
}
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
|
|
6
6
|
import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
|
|
7
|
-
import { request } from "../../http.js";
|
|
7
|
+
import { describeFetchFailure, request } from "../../http.js";
|
|
8
8
|
import { AsyncProgressReporter, renderJson, renderRichText, } from "../../renderers.js";
|
|
9
9
|
import { buildRuntime } from "../../runtime.js";
|
|
10
10
|
import * as ui from "../../ui.js";
|
|
@@ -197,7 +197,7 @@ async function fetchResponse(url, init) {
|
|
|
197
197
|
response = await fetch(url, init);
|
|
198
198
|
}
|
|
199
199
|
catch (error) {
|
|
200
|
-
throw new HttpError(`
|
|
200
|
+
throw new HttpError(`Could not reach ${url}: ${describeFetchFailure(error)}`);
|
|
201
201
|
}
|
|
202
202
|
return response;
|
|
203
203
|
}
|
|
@@ -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
|
+
}
|
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({
|
|
@@ -43,7 +43,7 @@ export async function request(runtime, route, options = {}) {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
catch (error) {
|
|
46
|
-
throw new HttpError(`
|
|
46
|
+
throw new HttpError(`Could not reach ${url}: ${describeFetchFailure(error)}`);
|
|
47
47
|
}
|
|
48
48
|
const responseBodyText = await response.text();
|
|
49
49
|
const parsedResponseBody = parseResponseBody(responseBodyText);
|
|
@@ -61,12 +61,60 @@ export async function request(runtime, route, options = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
if (typeof parsedResponseBody === "string" &&
|
|
63
63
|
parsedResponseBody === responseBodyText) {
|
|
64
|
-
|
|
64
|
+
// A 2xx that isn't JSON usually means something other than the API answered
|
|
65
|
+
// e.g. a proxy block page, a captive portal, etc. Pass the status
|
|
66
|
+
// and the raw text along so the user can see what actually came back.
|
|
67
|
+
throw new HttpError(responseBodyText
|
|
68
|
+
? `Invalid JSON response from ${url}`
|
|
69
|
+
: `Empty response body from ${url}`, response.status, responseBodyText);
|
|
65
70
|
}
|
|
66
71
|
const retryAfterMs = parseRetryAfterMs(response.headers);
|
|
67
72
|
const body = parsedResponseBody;
|
|
68
73
|
return retryAfterMs === undefined ? { body } : { body, retryAfterMs };
|
|
69
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
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Flatten an error thrown by `fetch` into a single readable message.
|
|
95
|
+
*
|
|
96
|
+
* Node reports every DNS, proxy, and TLS failure as `TypeError: fetch failed`
|
|
97
|
+
* and hangs the real reason off `cause`, so the top-level message alone can't
|
|
98
|
+
* distinguish an untrusted corporate root certificate from an unreachable host.
|
|
99
|
+
*/
|
|
100
|
+
export function describeFetchFailure(error) {
|
|
101
|
+
if (!(error instanceof Error)) {
|
|
102
|
+
return String(error);
|
|
103
|
+
}
|
|
104
|
+
const parts = [];
|
|
105
|
+
let current = error;
|
|
106
|
+
while (current instanceof Error) {
|
|
107
|
+
const { code } = current;
|
|
108
|
+
const part = code ? `${current.message} (${code})` : current.message;
|
|
109
|
+
if (!parts.includes(part)) {
|
|
110
|
+
parts.push(part);
|
|
111
|
+
}
|
|
112
|
+
current = current.cause;
|
|
113
|
+
}
|
|
114
|
+
// "fetch failed" carries no information once a cause is available.
|
|
115
|
+
const informative = parts.filter((part) => parts.length === 1 || part !== "fetch failed");
|
|
116
|
+
return informative.join(": ");
|
|
117
|
+
}
|
|
70
118
|
export function parseRetryAfterMs(headers) {
|
|
71
119
|
const retryAfter = headers.get("retry-after");
|
|
72
120
|
if (!retryAfter) {
|