@get-dx/cli 0.1.6 → 0.2.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/README.md +20 -0
- package/dist/authCodeCallbackServer.js +79 -0
- package/dist/commandHelpers.js +3 -2
- package/dist/commands/auth.js +34 -7
- package/dist/commands/catalog/entitiesRendering.js +11 -0
- package/dist/commands/init.js +27 -19
- package/dist/commands/studio/query.js +6 -7
- package/dist/commands/studio/queryRendering.js +0 -60
- package/dist/commands/teams.js +106 -0
- package/dist/config.js +17 -0
- package/dist/loginViaBrowser.js +86 -0
- package/dist/renderers.js +77 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -18,6 +18,26 @@ Interactively login and install the AI agent skill:
|
|
|
18
18
|
dx init
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
The CLI uses `app.getdx.com` as the hostname and `api.getdx.com` as the api base url by default.
|
|
22
|
+
|
|
23
|
+
### Dedicated or managed deployments
|
|
24
|
+
|
|
25
|
+
If your organization is on a dedicated or managed DX deployment, pass your custom hostname at init time:
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
# Flag (dedicated instance at mycompany.getdx.io)
|
|
29
|
+
dx init --host mycompany.getdx.io
|
|
30
|
+
|
|
31
|
+
# Env var — useful for CI or scripted setups
|
|
32
|
+
DX_HOST=mycompany.getdx.io dx init
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The `DX_HOST` and `--host` values accept:
|
|
36
|
+
|
|
37
|
+
- `app.getdx.com` — DX Cloud (default)
|
|
38
|
+
- `<account>.getdx.io` — DX dedicated (derives `https://api.<account>.getdx.io` automatically)
|
|
39
|
+
- Any custom domain — you will be prompted for the separate API base URL
|
|
40
|
+
|
|
21
41
|
Show help text:
|
|
22
42
|
|
|
23
43
|
```shell
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
export class AuthCodeCallbackServer {
|
|
4
|
+
server = null;
|
|
5
|
+
address = null;
|
|
6
|
+
async start() {
|
|
7
|
+
await new Promise((resolve, reject) => {
|
|
8
|
+
const srv = createServer();
|
|
9
|
+
srv.on("error", reject);
|
|
10
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
11
|
+
this.server = srv;
|
|
12
|
+
const { address, port } = srv.address();
|
|
13
|
+
this.address = `http://${address}:${port}`;
|
|
14
|
+
resolve();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
getAddress() {
|
|
19
|
+
if (!this.address) {
|
|
20
|
+
throw new Error("Unreachable: server not started");
|
|
21
|
+
}
|
|
22
|
+
return this.address;
|
|
23
|
+
}
|
|
24
|
+
async listenForCode(onCodeReceipt) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (!this.server) {
|
|
27
|
+
reject(new Error("Unreachable: server not started"));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.server.on("request", async (req, res) => {
|
|
31
|
+
this.stop();
|
|
32
|
+
const url = new URL(req.url ?? "/", this.address);
|
|
33
|
+
const code = url.searchParams.get("code");
|
|
34
|
+
const state = url.searchParams.get("state");
|
|
35
|
+
if (!code) {
|
|
36
|
+
const message = "Authentication failed: no code received";
|
|
37
|
+
res.writeHead(500, { "Content-Type": "text/plain" }).end(message);
|
|
38
|
+
resolve({ type: "ERROR", error: new CliError(message) });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
result = await onCodeReceipt(state ?? "", code);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const message = err instanceof Error
|
|
47
|
+
? err.message
|
|
48
|
+
: "Authentication failed: unknown error";
|
|
49
|
+
res.writeHead(500, { "Content-Type": "text/plain" }).end(message);
|
|
50
|
+
resolve({
|
|
51
|
+
type: "ERROR",
|
|
52
|
+
error: err instanceof Error ? err : new CliError(message),
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (result.type === "SUCCESS") {
|
|
57
|
+
if (result.redirectUri) {
|
|
58
|
+
res.writeHead(302, { Location: result.redirectUri }).end();
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
res
|
|
62
|
+
.writeHead(200, { "Content-Type": "text/plain" })
|
|
63
|
+
.end("Authentication successful");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
res
|
|
68
|
+
.writeHead(500, { "Content-Type": "text/plain" })
|
|
69
|
+
.end(result.error.message);
|
|
70
|
+
}
|
|
71
|
+
resolve(result);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
stop() {
|
|
76
|
+
this.server?.close();
|
|
77
|
+
this.server = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/commandHelpers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
1
2
|
import { CommanderError } from "commander";
|
|
2
3
|
import { CliError, EXIT_CODES, HttpError } from "./errors.js";
|
|
3
4
|
export function getContext(command) {
|
|
@@ -56,14 +57,14 @@ export function handleError(error, command, argv) {
|
|
|
56
57
|
}
|
|
57
58
|
else {
|
|
58
59
|
if (error instanceof HttpError) {
|
|
59
|
-
process.stderr.write(`The API returned an error with status code ${error.status}:\n\n${JSON.stringify(error.body, null, 2)}\n`);
|
|
60
|
+
process.stderr.write(pc.red(`The API returned an error with status code ${error.status}:\n\n${JSON.stringify(error.body, null, 2)}\n`));
|
|
60
61
|
}
|
|
61
62
|
else if (error instanceof CommanderError &&
|
|
62
63
|
(error.code === "commander.help" || error.message === "(outputHelp)")) {
|
|
63
64
|
// Commander already printed the relevant help text.
|
|
64
65
|
}
|
|
65
66
|
else {
|
|
66
|
-
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
67
|
+
process.stderr.write(pc.red(`${error instanceof Error ? error.message : String(error)}\n`));
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
const exitCode = (() => {
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { password, select } from "@inquirer/prompts";
|
|
1
2
|
import { Command } from "commander";
|
|
2
3
|
import { deleteToken, setToken } from "../secrets.js";
|
|
3
4
|
import { renderJson } from "../renderers.js";
|
|
4
5
|
import { renderAuthInfo, renderLoggedOut } from "./authRendering.js";
|
|
5
|
-
import { wrapAction } from "../commandHelpers.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { getContext, wrapAction } from "../commandHelpers.js";
|
|
7
|
+
import { persistBaseUrl, resolveBaseUrl, resolveUiUrl } from "../config.js";
|
|
8
|
+
import { CliError } from "../errors.js";
|
|
8
9
|
import { request } from "../http.js";
|
|
10
|
+
import { loginViaBrowser } from "../loginViaBrowser.js";
|
|
9
11
|
import { buildLogger, buildRuntime } from "../runtime.js";
|
|
10
12
|
import cliPackage from "../../package.json" with { type: "json" };
|
|
11
13
|
import { maskToken } from "../ui.js";
|
|
@@ -15,25 +17,50 @@ export function authCommand() {
|
|
|
15
17
|
.description("Manage DX authentication");
|
|
16
18
|
auth
|
|
17
19
|
.command("login")
|
|
18
|
-
.
|
|
20
|
+
.option("--token <token>", "Account web API token or personal access token. Omit this option to login interactively by web browser or pasting.")
|
|
19
21
|
.action(wrapAction(async (commandOptions, command) => {
|
|
20
22
|
const context = getContext(command);
|
|
21
23
|
const baseUrl = resolveBaseUrl();
|
|
24
|
+
let token = commandOptions.token;
|
|
25
|
+
if (!token) {
|
|
26
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
27
|
+
throw new CliError("`dx auth login` without `--token` requires an interactive terminal; pass `--token` for non-interactive use");
|
|
28
|
+
}
|
|
29
|
+
const method = await select({
|
|
30
|
+
message: "How would you like to log in?",
|
|
31
|
+
choices: [
|
|
32
|
+
{ name: "Open browser", value: "browser" },
|
|
33
|
+
{ name: "Paste API token", value: "token" },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
if (method === "browser") {
|
|
37
|
+
token = await loginViaBrowser(resolveUiUrl(baseUrl));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
token = await password({
|
|
41
|
+
message: "Paste your account web API token here:",
|
|
42
|
+
mask: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (!token) {
|
|
46
|
+
throw new CliError("Account web API token or personal access token is required");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
22
49
|
const runtime = {
|
|
23
50
|
baseUrl,
|
|
24
|
-
token
|
|
51
|
+
token,
|
|
25
52
|
context,
|
|
26
53
|
version: cliPackage.version,
|
|
27
54
|
logger: buildLogger(context),
|
|
28
55
|
};
|
|
29
56
|
const response = await getAuthInfo(runtime);
|
|
30
57
|
persistBaseUrl(baseUrl);
|
|
31
|
-
setToken(baseUrl,
|
|
58
|
+
setToken(baseUrl, token);
|
|
32
59
|
if (context.json) {
|
|
33
60
|
renderJson({ ...response, base_url: baseUrl });
|
|
34
61
|
return;
|
|
35
62
|
}
|
|
36
|
-
renderAuthInfo(response,
|
|
63
|
+
renderAuthInfo(response, token, baseUrl);
|
|
37
64
|
}));
|
|
38
65
|
auth.command("logout").action(wrapAction(async (_options, command) => {
|
|
39
66
|
const context = getContext(command);
|
|
@@ -238,6 +238,9 @@ function propertyValueContent(identifier, value) {
|
|
|
238
238
|
const matchText = value.match_count === 1 ? "match" : "matches";
|
|
239
239
|
return `File matching rule: ${ui.code(value.status)} (${value.match_count} ${matchText})`;
|
|
240
240
|
}
|
|
241
|
+
else if (isOpenApiSpec(value)) {
|
|
242
|
+
return `OpenAPI spec`;
|
|
243
|
+
}
|
|
241
244
|
else if (isUrl(value)) {
|
|
242
245
|
return ui.link(value);
|
|
243
246
|
}
|
|
@@ -284,3 +287,11 @@ function isFileMatchingRule(value) {
|
|
|
284
287
|
typeof value.status === "string" &&
|
|
285
288
|
["FOUND_MATCHES", "NO_MATCHES", "FILE_NOT_FOUND"].includes(value.status));
|
|
286
289
|
}
|
|
290
|
+
function isOpenApiSpec(value) {
|
|
291
|
+
// TODO: consider switching to zod
|
|
292
|
+
return (typeof value === "object" &&
|
|
293
|
+
value !== null &&
|
|
294
|
+
typeof value === "object" &&
|
|
295
|
+
"info" in value &&
|
|
296
|
+
"paths" in value);
|
|
297
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { access } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { input, password, confirm } from "@inquirer/prompts";
|
|
4
|
+
import { input, password, confirm, select } from "@inquirer/prompts";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import { execa } from "execa";
|
|
7
7
|
import { buildRuntime, buildRuntimeSafe } from "../runtime.js";
|
|
8
8
|
import { getAuthInfo } from "./auth.js";
|
|
9
9
|
import { renderAuthInfo } from "./authRendering.js";
|
|
10
|
+
import { loginViaBrowser } from "../loginViaBrowser.js";
|
|
10
11
|
import { getContext } from "../commandHelpers.js";
|
|
11
12
|
import { wrapAction } from "../commandHelpers.js";
|
|
12
13
|
import { renderRichText } from "../renderers.js";
|
|
@@ -50,12 +51,14 @@ export function initCommand() {
|
|
|
50
51
|
const init = new Command()
|
|
51
52
|
.name("init")
|
|
52
53
|
.description("Initialize the DX CLI")
|
|
53
|
-
.
|
|
54
|
+
.option("--host <hostname>", "DX hostname for dedicated or managed deployments (e.g. mycompany.getdx.io)")
|
|
55
|
+
.action(wrapAction(async (commandOptions, command) => {
|
|
54
56
|
const context = getContext(command);
|
|
55
57
|
let runtime = buildRuntimeSafe(context);
|
|
56
58
|
ensureInteractive();
|
|
57
59
|
showWelcomeBanner();
|
|
58
|
-
|
|
60
|
+
const host = commandOptions.host || process.env.DX_HOST;
|
|
61
|
+
runtime = await ensureLoggedIn(runtime, host);
|
|
59
62
|
await optionallySetupSkill(runtime);
|
|
60
63
|
renderRichText([
|
|
61
64
|
ui.blankLine(),
|
|
@@ -72,7 +75,7 @@ function ensureInteractive() {
|
|
|
72
75
|
function showWelcomeBanner() {
|
|
73
76
|
renderRichText([ui.p(ui.success(WELCOME_BANNER))]);
|
|
74
77
|
}
|
|
75
|
-
async function ensureLoggedIn(runtime) {
|
|
78
|
+
async function ensureLoggedIn(runtime, host) {
|
|
76
79
|
renderRichText([ui.h1(`Checking if you are logged in...`), ui.blankLine()]);
|
|
77
80
|
if (runtime) {
|
|
78
81
|
let authInfo = null;
|
|
@@ -88,17 +91,9 @@ async function ensureLoggedIn(runtime) {
|
|
|
88
91
|
}
|
|
89
92
|
}
|
|
90
93
|
renderRichText([ui.p(`You are not logged in yet.`), ui.blankLine()]);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
message: "What is your DX hostname? (leave blank for app.getdx.com)",
|
|
95
|
-
});
|
|
96
|
-
parsed = parseHostname(raw);
|
|
97
|
-
if (parsed.type === "invalid") {
|
|
98
|
-
renderRichText([
|
|
99
|
-
ui.p(ui.error(`Could not recognise that hostname. Please try again.`)),
|
|
100
|
-
]);
|
|
101
|
-
}
|
|
94
|
+
const parsed = host ? parseHostname(host) : { type: "cloud" };
|
|
95
|
+
if (parsed.type === "invalid") {
|
|
96
|
+
throw new CliError(`Could not recognize hostname "${host}". Expected app.getdx.com, <account>.getdx.io, or a custom domain.`);
|
|
102
97
|
}
|
|
103
98
|
switch (parsed.type) {
|
|
104
99
|
case "cloud":
|
|
@@ -118,11 +113,24 @@ async function ensureLoggedIn(runtime) {
|
|
|
118
113
|
}
|
|
119
114
|
}
|
|
120
115
|
}
|
|
121
|
-
async function attemptLogin(apiBaseUrl,
|
|
122
|
-
const
|
|
123
|
-
message: "
|
|
124
|
-
|
|
116
|
+
async function attemptLogin(apiBaseUrl, uiBaseUrl) {
|
|
117
|
+
const method = await select({
|
|
118
|
+
message: "How would you like to log in?",
|
|
119
|
+
choices: [
|
|
120
|
+
{ name: "Open browser", value: "browser" },
|
|
121
|
+
{ name: "Paste API token", value: "token" },
|
|
122
|
+
],
|
|
125
123
|
});
|
|
124
|
+
let token;
|
|
125
|
+
if (method === "browser") {
|
|
126
|
+
token = await loginViaBrowser(uiBaseUrl);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
token = await password({
|
|
130
|
+
message: "Paste your account web API token here:",
|
|
131
|
+
mask: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
126
134
|
if (!token) {
|
|
127
135
|
throw new CliError("Account web API token is required");
|
|
128
136
|
}
|
|
@@ -5,10 +5,10 @@ import { Command } from "commander";
|
|
|
5
5
|
import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
|
|
6
6
|
import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
|
|
7
7
|
import { request } from "../../http.js";
|
|
8
|
-
import { renderJson } from "../../renderers.js";
|
|
8
|
+
import { AsyncProgressReporter, renderJson, renderRichText, } from "../../renderers.js";
|
|
9
9
|
import { buildRuntime } from "../../runtime.js";
|
|
10
10
|
import * as ui from "../../ui.js";
|
|
11
|
-
import {
|
|
11
|
+
import { renderCsvSaved, renderQueryResultsTable } from "./queryRendering.js";
|
|
12
12
|
const DEFAULT_RETRY_AFTER_MS = 1000;
|
|
13
13
|
const PENDING_QUERY_RUN_STATUSES = new Set([
|
|
14
14
|
"queued",
|
|
@@ -51,7 +51,7 @@ export function queryCommand() {
|
|
|
51
51
|
}
|
|
52
52
|
const variables = parseVariables(options.variable);
|
|
53
53
|
const runtime = buildRuntime(context);
|
|
54
|
-
const progress = new
|
|
54
|
+
const progress = new AsyncProgressReporter();
|
|
55
55
|
const queryRun = await executeQuery(runtime, progress, sql, variables);
|
|
56
56
|
await waitForQuery(runtime, progress, queryRun.id);
|
|
57
57
|
if (options.output) {
|
|
@@ -70,7 +70,9 @@ export function queryCommand() {
|
|
|
70
70
|
}
|
|
71
71
|
async function executeQuery(runtime, progress, sql, variables) {
|
|
72
72
|
try {
|
|
73
|
-
|
|
73
|
+
renderRichText([ui.p(`${ui.bold("Running:")} ${ui.code(sql)}`)], {
|
|
74
|
+
useStderr: true,
|
|
75
|
+
});
|
|
74
76
|
progress.start(ui.bold("Submitting query"));
|
|
75
77
|
const executeResponse = await executeStudioQuery(runtime, sql, variables);
|
|
76
78
|
const queryRun = executeResponse.body.query_run;
|
|
@@ -119,9 +121,6 @@ async function waitForQuery(runtime, progress, queryRunId) {
|
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
|
-
function renderRunningQuery(sql) {
|
|
123
|
-
process.stderr.write(`${ui.bold("Running:")} ${ui.code(sql)}\n`);
|
|
124
|
-
}
|
|
125
124
|
function renderPendingQueryStatus(queryRunId) {
|
|
126
125
|
return `${ui.bold("Query running")} ${ui.dim(`(${queryRunId})`)}`;
|
|
127
126
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { clearLine, cursorTo } from "node:readline";
|
|
2
1
|
import { renderRichText } from "../../renderers.js";
|
|
3
2
|
import * as ui from "../../ui.js";
|
|
4
3
|
export function renderQueryResultsTable(results) {
|
|
@@ -7,65 +6,6 @@ export function renderQueryResultsTable(results) {
|
|
|
7
6
|
export function renderCsvSaved(filename) {
|
|
8
7
|
renderRichText([ui.p(`Saved CSV results to ${ui.code(filename)}.`)]);
|
|
9
8
|
}
|
|
10
|
-
export class QueryProgressReporter {
|
|
11
|
-
enabled = Boolean(process.stderr.isTTY);
|
|
12
|
-
frames = [
|
|
13
|
-
"⠋",
|
|
14
|
-
"⠙",
|
|
15
|
-
"⠹",
|
|
16
|
-
"⠸",
|
|
17
|
-
"⠼",
|
|
18
|
-
"⠴",
|
|
19
|
-
"⠦",
|
|
20
|
-
"⠧",
|
|
21
|
-
"⠇",
|
|
22
|
-
"⠏",
|
|
23
|
-
].map((frame) => ui.dim(frame));
|
|
24
|
-
timer;
|
|
25
|
-
frameIndex = 0;
|
|
26
|
-
currentMessage = "";
|
|
27
|
-
start(message) {
|
|
28
|
-
if (!this.enabled) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
this.currentMessage = message;
|
|
32
|
-
this.render();
|
|
33
|
-
this.timer = setInterval(() => this.render(), 80);
|
|
34
|
-
this.timer.unref?.();
|
|
35
|
-
}
|
|
36
|
-
update(message) {
|
|
37
|
-
if (!this.enabled) {
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
this.currentMessage = message;
|
|
41
|
-
this.render();
|
|
42
|
-
}
|
|
43
|
-
stop(finalMessage) {
|
|
44
|
-
if (!this.enabled) {
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (this.timer) {
|
|
48
|
-
clearInterval(this.timer);
|
|
49
|
-
this.timer = undefined;
|
|
50
|
-
}
|
|
51
|
-
if (finalMessage) {
|
|
52
|
-
this.clearCurrentLine();
|
|
53
|
-
process.stderr.write(`${finalMessage}\n`);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
this.clearCurrentLine();
|
|
57
|
-
}
|
|
58
|
-
render() {
|
|
59
|
-
const frame = this.frames[this.frameIndex % this.frames.length];
|
|
60
|
-
this.frameIndex += 1;
|
|
61
|
-
this.clearCurrentLine();
|
|
62
|
-
process.stderr.write(`${frame} ${this.currentMessage}`);
|
|
63
|
-
}
|
|
64
|
-
clearCurrentLine() {
|
|
65
|
-
clearLine(process.stderr, 0);
|
|
66
|
-
cursorTo(process.stderr, 0);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
9
|
function formatAsciiTable(columns, rows) {
|
|
70
10
|
if (columns.length === 0) {
|
|
71
11
|
return "(Query returned no columns)";
|
package/dist/commands/teams.js
CHANGED
|
@@ -32,6 +32,36 @@ export function teamsCommand() {
|
|
|
32
32
|
renderTeamByMembers(response.team);
|
|
33
33
|
}
|
|
34
34
|
}));
|
|
35
|
+
teams
|
|
36
|
+
.command("info")
|
|
37
|
+
.description("Retrieve details for an individual team")
|
|
38
|
+
.option("--team-id <id>", "DX team ID")
|
|
39
|
+
.option("--reference-id <id>", "Team ID that is internal to your organization")
|
|
40
|
+
.addHelpText("afterAll", createExampleText([
|
|
41
|
+
{
|
|
42
|
+
label: "Fetch info for a team by DX team ID",
|
|
43
|
+
command: "dx teams info --team-id NTA4Nzc",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
label: "Fetch info for a team by your organization's reference ID",
|
|
47
|
+
command: "dx teams info --reference-id 06BEC4E0-5A61-354E-08A6-C39D756058AB",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: "Fetch team info and return JSON",
|
|
51
|
+
command: "dx --json teams info --team-id NTA4Nzc",
|
|
52
|
+
},
|
|
53
|
+
]))
|
|
54
|
+
.action(wrapAction(async (options, command) => {
|
|
55
|
+
const lookup = parseTeamInfoLookup(options);
|
|
56
|
+
const runtime = buildRuntime(getContext(command));
|
|
57
|
+
const response = await getTeamInfo(runtime, lookup);
|
|
58
|
+
if (runtime.context.json) {
|
|
59
|
+
renderJson(response);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
renderTeamInfo(response.team);
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
35
65
|
teams
|
|
36
66
|
.command("list")
|
|
37
67
|
.description("List all teams in DX")
|
|
@@ -72,6 +102,13 @@ async function findTeamByMembers(runtime, teamEmails) {
|
|
|
72
102
|
throw buildFindByMembersError(error, teamEmails);
|
|
73
103
|
}
|
|
74
104
|
}
|
|
105
|
+
async function getTeamInfo(runtime, lookup) {
|
|
106
|
+
const response = await request(runtime, "/teams.info", {
|
|
107
|
+
method: "GET",
|
|
108
|
+
query: lookup,
|
|
109
|
+
});
|
|
110
|
+
return response.body;
|
|
111
|
+
}
|
|
75
112
|
async function listTeams(runtime) {
|
|
76
113
|
const response = await request(runtime, "/teams.list", {
|
|
77
114
|
method: "GET",
|
|
@@ -93,6 +130,31 @@ function renderTeamByMembers(team) {
|
|
|
93
130
|
];
|
|
94
131
|
renderRichText(blocks);
|
|
95
132
|
}
|
|
133
|
+
function renderTeamInfo(team) {
|
|
134
|
+
const blocks = [ui.h1("Team Information")];
|
|
135
|
+
const heading = formatTeamHeading(team);
|
|
136
|
+
if (heading) {
|
|
137
|
+
blocks.push(ui.h2(heading));
|
|
138
|
+
}
|
|
139
|
+
const details = [];
|
|
140
|
+
pushDetail(details, "ID", formatText(team.id), team.id !== undefined);
|
|
141
|
+
pushDetail(details, "Name", team.name ?? ui.dim("(None)"), team.name !== undefined);
|
|
142
|
+
pushDetail(details, "Reference ID", formatText(team.reference_id), team.reference_id !== undefined);
|
|
143
|
+
pushDetail(details, "Parent ID", formatText(team.parent_id), team.parent_id !== undefined);
|
|
144
|
+
pushDetail(details, "Manager ID", formatText(team.manager_id), team.manager_id !== undefined);
|
|
145
|
+
pushDetail(details, "Parent team", typeof team.parent === "boolean" ? (team.parent ? "Yes" : "No") : "", team.parent !== undefined);
|
|
146
|
+
pushDetail(details, "Last changed", team.last_changed_at
|
|
147
|
+
? ui.timestampSummary(team.last_changed_at)
|
|
148
|
+
: ui.dim("(None)"), team.last_changed_at !== undefined);
|
|
149
|
+
pushDetail(details, "Lead", formatTeamMember(team.lead), team.lead !== undefined);
|
|
150
|
+
pushDetail(details, "Manager", formatTeamMember(team.manager), team.manager !== undefined);
|
|
151
|
+
if (details.length > 0) {
|
|
152
|
+
blocks.push(ui.dl(details, { termWidth: 14 }));
|
|
153
|
+
}
|
|
154
|
+
blocks.push(ui.h2("Contributors"));
|
|
155
|
+
blocks.push(formatTeamMemberList(team.contributors));
|
|
156
|
+
renderRichText(blocks);
|
|
157
|
+
}
|
|
96
158
|
function renderTeams(teams) {
|
|
97
159
|
const blocks = [ui.h1("Teams")];
|
|
98
160
|
blocks.push(ui.p(`Displaying ${ui.bold(teams.length.toString())} teams.`));
|
|
@@ -123,12 +185,56 @@ function renderTeams(teams) {
|
|
|
123
185
|
function formatText(value) {
|
|
124
186
|
return value ? ui.code(value) : ui.dim("(None)");
|
|
125
187
|
}
|
|
188
|
+
function formatTeamHeading(team) {
|
|
189
|
+
if (team.name && team.id) {
|
|
190
|
+
return `${team.name} (${ui.code(team.id)})`;
|
|
191
|
+
}
|
|
192
|
+
if (team.name) {
|
|
193
|
+
return team.name;
|
|
194
|
+
}
|
|
195
|
+
if (team.id) {
|
|
196
|
+
return ui.code(team.id);
|
|
197
|
+
}
|
|
198
|
+
if (team.reference_id) {
|
|
199
|
+
return ui.code(team.reference_id);
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
126
203
|
function formatTeamMember(member) {
|
|
127
204
|
if (!member) {
|
|
128
205
|
return ui.dim("(None)");
|
|
129
206
|
}
|
|
130
207
|
return `${member.name} <${member.email}> (${ui.code(member.id)})`;
|
|
131
208
|
}
|
|
209
|
+
function formatTeamMemberList(members) {
|
|
210
|
+
if (!members || members.length === 0) {
|
|
211
|
+
return ui.p(ui.dim("(None)"));
|
|
212
|
+
}
|
|
213
|
+
return ui.ul(members.map((member) => ui.li(formatTeamMember(member))));
|
|
214
|
+
}
|
|
215
|
+
function pushDetail(details, term, value, shouldInclude) {
|
|
216
|
+
if (shouldInclude) {
|
|
217
|
+
details.push(ui.dli(term, value));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function parseTeamInfoLookup(options) {
|
|
221
|
+
const teamId = parseOptionalTextOption(options.teamId);
|
|
222
|
+
const referenceId = parseOptionalTextOption(options.referenceId);
|
|
223
|
+
if (!teamId && !referenceId) {
|
|
224
|
+
throw new CliError("One of --team-id or --reference-id is required", EXIT_CODES.ARGUMENT_ERROR);
|
|
225
|
+
}
|
|
226
|
+
if (teamId && referenceId) {
|
|
227
|
+
throw new CliError("--team-id and --reference-id are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
|
|
228
|
+
}
|
|
229
|
+
return teamId ? { team_id: teamId } : { reference_id: referenceId };
|
|
230
|
+
}
|
|
231
|
+
function parseOptionalTextOption(value) {
|
|
232
|
+
if (typeof value !== "string") {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
const trimmed = value.trim();
|
|
236
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
237
|
+
}
|
|
132
238
|
function parseTeamEmails(value) {
|
|
133
239
|
if (typeof value !== "string") {
|
|
134
240
|
throw new CliError("--team-emails is required", EXIT_CODES.ARGUMENT_ERROR);
|
package/dist/config.js
CHANGED
|
@@ -30,6 +30,23 @@ export function resolveBaseUrl() {
|
|
|
30
30
|
}
|
|
31
31
|
return normalizeBaseUrl(readConfig().baseUrl || DEFAULT_BASE_URL);
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Web UI origin for browser OAuth (`/cli/auth`), inferred from the API base URL.
|
|
35
|
+
* Dedicated and cloud hostnames map to their app origins; otherwise the API URL origin is used.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveUiUrl(apiBaseUrl) {
|
|
38
|
+
const normalized = normalizeBaseUrl(apiBaseUrl);
|
|
39
|
+
const url = new URL(normalized);
|
|
40
|
+
const host = url.hostname;
|
|
41
|
+
if (host === "api.getdx.com") {
|
|
42
|
+
return "https://app.getdx.com";
|
|
43
|
+
}
|
|
44
|
+
const dedicated = host.match(/^api\.(.+)\.getdx\.io$/);
|
|
45
|
+
if (dedicated) {
|
|
46
|
+
return `https://${dedicated[1]}.getdx.io`;
|
|
47
|
+
}
|
|
48
|
+
return url.origin;
|
|
49
|
+
}
|
|
33
50
|
export function persistBaseUrl(baseUrl) {
|
|
34
51
|
writeConfig({ ...readConfig(), baseUrl: normalizeBaseUrl(baseUrl) });
|
|
35
52
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import { AuthCodeCallbackServer, } from "./authCodeCallbackServer.js";
|
|
4
|
+
import { CliError } from "./errors.js";
|
|
5
|
+
import { renderRichText } from "./renderers.js";
|
|
6
|
+
import * as ui from "./ui.js";
|
|
7
|
+
export async function loginViaBrowser(uiBaseUrl) {
|
|
8
|
+
const server = new AuthCodeCallbackServer();
|
|
9
|
+
await server.start();
|
|
10
|
+
const callbackUri = server.getAddress();
|
|
11
|
+
const codeVerifier = generateCodeVerifier();
|
|
12
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
13
|
+
const state = generateState();
|
|
14
|
+
const params = new URLSearchParams({
|
|
15
|
+
callback_uri: callbackUri,
|
|
16
|
+
state,
|
|
17
|
+
code_challenge: codeChallenge,
|
|
18
|
+
code_challenge_method: "S256",
|
|
19
|
+
});
|
|
20
|
+
const authUrl = `${uiBaseUrl}/cli/auth?${params.toString()}`;
|
|
21
|
+
renderRichText([
|
|
22
|
+
ui.p(`Opening your browser to complete authentication...`),
|
|
23
|
+
ui.blankLine(),
|
|
24
|
+
]);
|
|
25
|
+
try {
|
|
26
|
+
await open(authUrl);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
renderRichText([
|
|
30
|
+
ui.p(`Failed to open browser automatically. Visit this URL to continue:`),
|
|
31
|
+
ui.blankLine(),
|
|
32
|
+
ui.codeBlock(authUrl),
|
|
33
|
+
ui.blankLine(),
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
renderRichText([ui.p(`Waiting for authentication in your browser...`)]);
|
|
37
|
+
const handleCodeReceipt = async (stateFromResponse, code) => {
|
|
38
|
+
const valid = validateState(stateFromResponse, state);
|
|
39
|
+
if (!valid) {
|
|
40
|
+
return {
|
|
41
|
+
type: "ERROR",
|
|
42
|
+
error: new CliError("Authentication failed: state mismatch"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const tokenExchangeResponse = await exchangeCodeForToken(uiBaseUrl, code, codeVerifier);
|
|
46
|
+
if (!tokenExchangeResponse.ok) {
|
|
47
|
+
return {
|
|
48
|
+
type: "ERROR",
|
|
49
|
+
error: new CliError(`Authentication failed: token exchange failed with status ${tokenExchangeResponse.status}`),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const body = (await tokenExchangeResponse.json());
|
|
53
|
+
return {
|
|
54
|
+
type: "SUCCESS",
|
|
55
|
+
token: body.access_token,
|
|
56
|
+
redirectUri: body.redirect_uri,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
const serverResult = await server.listenForCode(handleCodeReceipt);
|
|
60
|
+
if (serverResult.type === "ERROR")
|
|
61
|
+
throw serverResult.error;
|
|
62
|
+
return serverResult.token;
|
|
63
|
+
}
|
|
64
|
+
function validateState(stateFromResponse, originalState) {
|
|
65
|
+
return stateFromResponse === originalState;
|
|
66
|
+
}
|
|
67
|
+
async function exchangeCodeForToken(baseUrl, code, codeVerifier) {
|
|
68
|
+
return fetch(`${baseUrl}/cli/token`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
71
|
+
body: new URLSearchParams({ code, code_verifier: codeVerifier }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// RFC 8252 §8.9: random string used to prevent CSRF-style attacks
|
|
75
|
+
function generateState() {
|
|
76
|
+
return randomBytes(16).toString("base64url");
|
|
77
|
+
}
|
|
78
|
+
// RFC 7636 §4.1 — 32 random bytes base64url-encoded = 43 unreserved chars
|
|
79
|
+
function generateCodeVerifier() {
|
|
80
|
+
return randomBytes(32).toString("base64url");
|
|
81
|
+
}
|
|
82
|
+
// RFC 7636 §4.2 — SHA256 hex digest, base64-encoded without padding
|
|
83
|
+
function generateCodeChallenge(codeVerifier) {
|
|
84
|
+
const hexDigest = createHash("sha256").update(codeVerifier).digest("hex");
|
|
85
|
+
return Buffer.from(hexDigest).toString("base64").replace(/=/g, "");
|
|
86
|
+
}
|
package/dist/renderers.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { clearLine, cursorTo } from "node:readline";
|
|
2
|
+
import { dim } from "./ui.js";
|
|
1
3
|
/**
|
|
2
4
|
* Render an object in plain JSON formatting.
|
|
3
5
|
*
|
|
@@ -11,7 +13,7 @@ export function renderJson(value) {
|
|
|
11
13
|
*
|
|
12
14
|
* Should be used when the `--flag` is NOT present.
|
|
13
15
|
*/
|
|
14
|
-
export function renderRichText(blocks) {
|
|
16
|
+
export function renderRichText(blocks, options) {
|
|
15
17
|
const normalizedBlocks = blocks
|
|
16
18
|
.filter((block) => block !== null)
|
|
17
19
|
.flatMap((block) => {
|
|
@@ -25,5 +27,78 @@ export function renderRichText(blocks) {
|
|
|
25
27
|
const blockContent = block.render();
|
|
26
28
|
renderedBlocks.push(blockContent);
|
|
27
29
|
}
|
|
28
|
-
|
|
30
|
+
if (options?.useStderr) {
|
|
31
|
+
process.stderr.write(renderedBlocks.join("\n"));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
process.stdout.write(renderedBlocks.join("\n"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Renders a TTY spinner and status on stderr for long-running async work. Does not
|
|
39
|
+
* use `renderRichText` because stdout is reserved for command results; stderr keeps
|
|
40
|
+
* auxiliary output separate from the primary stream.
|
|
41
|
+
*/
|
|
42
|
+
export class AsyncProgressReporter {
|
|
43
|
+
enabled = Boolean(process.stderr.isTTY);
|
|
44
|
+
intervalMs;
|
|
45
|
+
frames = [
|
|
46
|
+
"⠋",
|
|
47
|
+
"⠙",
|
|
48
|
+
"⠹",
|
|
49
|
+
"⠸",
|
|
50
|
+
"⠼",
|
|
51
|
+
"⠴",
|
|
52
|
+
"⠦",
|
|
53
|
+
"⠧",
|
|
54
|
+
"⠇",
|
|
55
|
+
"⠏",
|
|
56
|
+
].map((frame) => dim(frame));
|
|
57
|
+
timer;
|
|
58
|
+
frameIndex = 0;
|
|
59
|
+
currentMessage = "";
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.intervalMs = options?.intervalMs ?? 80;
|
|
62
|
+
}
|
|
63
|
+
start(message) {
|
|
64
|
+
if (!this.enabled) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.currentMessage = message;
|
|
68
|
+
this.render();
|
|
69
|
+
this.timer = setInterval(() => this.render(), this.intervalMs);
|
|
70
|
+
this.timer.unref?.();
|
|
71
|
+
}
|
|
72
|
+
update(message) {
|
|
73
|
+
if (!this.enabled) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.currentMessage = message;
|
|
77
|
+
this.render();
|
|
78
|
+
}
|
|
79
|
+
stop(finalMessage) {
|
|
80
|
+
if (!this.enabled) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (this.timer) {
|
|
84
|
+
clearInterval(this.timer);
|
|
85
|
+
this.timer = undefined;
|
|
86
|
+
}
|
|
87
|
+
if (finalMessage) {
|
|
88
|
+
this.clearCurrentLine();
|
|
89
|
+
process.stderr.write(`${finalMessage}\n`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.clearCurrentLine();
|
|
93
|
+
}
|
|
94
|
+
render() {
|
|
95
|
+
const frame = this.frames[this.frameIndex % this.frames.length];
|
|
96
|
+
this.frameIndex += 1;
|
|
97
|
+
this.clearCurrentLine();
|
|
98
|
+
process.stderr.write(`${frame} ${this.currentMessage}`);
|
|
99
|
+
}
|
|
100
|
+
clearCurrentLine() {
|
|
101
|
+
clearLine(process.stderr, 0);
|
|
102
|
+
cursorTo(process.stderr, 0);
|
|
103
|
+
}
|
|
29
104
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@get-dx/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DX CLI",
|
|
5
5
|
"author": "DX <developers@getdx.com>",
|
|
6
6
|
"bugs": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"commander": "^14.0.1",
|
|
43
43
|
"dayjs": "^1.11.20",
|
|
44
44
|
"execa": "^9.6.1",
|
|
45
|
+
"open": "^11.0.0",
|
|
45
46
|
"picocolors": "^1.1.1",
|
|
46
47
|
"yaml": "^2.8.3"
|
|
47
48
|
},
|