@get-dx/cli 0.1.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 +44 -0
- package/dist/cli.js +50 -0
- package/dist/commandHelpers.js +110 -0
- package/dist/commands/auth.js +70 -0
- package/dist/commands/authRendering.js +40 -0
- package/dist/commands/catalog/entities.js +596 -0
- package/dist/commands/catalog/entitiesRendering.js +286 -0
- package/dist/commands/catalog/entity-type-blank-template.yaml +51 -0
- package/dist/commands/catalog/entityTypes.js +402 -0
- package/dist/commands/catalog/entityTypesRendering.js +159 -0
- package/dist/commands/catalog.js +11 -0
- package/dist/commands/scorecard-blank-template.yaml +82 -0
- package/dist/commands/scorecards.js +480 -0
- package/dist/commands/scorecardsRendering.js +219 -0
- package/dist/commands/studio/query.js +270 -0
- package/dist/commands/studio/queryRendering.js +99 -0
- package/dist/commands/studio.js +9 -0
- package/dist/config.js +38 -0
- package/dist/errors.js +24 -0
- package/dist/http.js +128 -0
- package/dist/index.js +3 -0
- package/dist/logger.js +70 -0
- package/dist/renderers.js +29 -0
- package/dist/runtime.js +22 -0
- package/dist/secrets.js +80 -0
- package/dist/types.js +1 -0
- package/dist/ui/blocks.js +94 -0
- package/dist/ui/glyphs.js +5 -0
- package/dist/ui.js +109 -0
- package/package.json +56 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { createExampleText, getContext, wrapAction, } from "../../commandHelpers.js";
|
|
6
|
+
import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
|
|
7
|
+
import { request } from "../../http.js";
|
|
8
|
+
import { renderJson } from "../../renderers.js";
|
|
9
|
+
import { buildRuntime } from "../../runtime.js";
|
|
10
|
+
import * as ui from "../../ui.js";
|
|
11
|
+
import { QueryProgressReporter, renderCsvSaved, renderQueryResultsTable, } from "./queryRendering.js";
|
|
12
|
+
const DEFAULT_RETRY_AFTER_MS = 1000;
|
|
13
|
+
const PENDING_QUERY_RUN_STATUSES = new Set([
|
|
14
|
+
"queued",
|
|
15
|
+
"started",
|
|
16
|
+
"running",
|
|
17
|
+
]);
|
|
18
|
+
export function queryCommand() {
|
|
19
|
+
return new Command()
|
|
20
|
+
.name("query")
|
|
21
|
+
.description("Execute a Data Studio SQL query and wait for the results")
|
|
22
|
+
.argument("<sql>", "SQL query to execute")
|
|
23
|
+
.option("--output <filename>", "Save the full result set as CSV to a file")
|
|
24
|
+
.addHelpText("afterAll", createExampleText([
|
|
25
|
+
{
|
|
26
|
+
label: "Run a query and print the results as a table",
|
|
27
|
+
command: "dx studio query 'SELECT * FROM github_pulls LIMIT 10'",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: "Run a query and print the JSON results payload",
|
|
31
|
+
command: "dx studio query 'SELECT id, name FROM github_repos LIMIT 5' --json",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: "Run a query and save the full result set as CSV",
|
|
35
|
+
command: "dx studio query 'SELECT * FROM github_pulls' --output pulls.csv",
|
|
36
|
+
},
|
|
37
|
+
]))
|
|
38
|
+
.action(wrapAction(async (sql, options, command) => {
|
|
39
|
+
const context = getContext(command);
|
|
40
|
+
if (context.json && options.output) {
|
|
41
|
+
throw new CliError("--output cannot be used with --json", EXIT_CODES.ARGUMENT_ERROR);
|
|
42
|
+
}
|
|
43
|
+
const runtime = buildRuntime(context);
|
|
44
|
+
const progress = new QueryProgressReporter();
|
|
45
|
+
const queryRun = await executeQuery(runtime, progress, sql);
|
|
46
|
+
await waitForQuery(runtime, progress, queryRun.id);
|
|
47
|
+
if (options.output) {
|
|
48
|
+
await downloadStudioQueryResultsCsv(runtime, queryRun.id, options.output);
|
|
49
|
+
renderCsvSaved(path.resolve(options.output));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const response = await getStudioQueryResults(runtime, queryRun.id);
|
|
53
|
+
if (runtime.context.json) {
|
|
54
|
+
renderJson(response);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
renderQueryResultsTable(response.results);
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
async function executeQuery(runtime, progress, sql) {
|
|
62
|
+
try {
|
|
63
|
+
renderRunningQuery(sql);
|
|
64
|
+
progress.start(ui.bold("Submitting query"));
|
|
65
|
+
const executeResponse = await executeStudioQuery(runtime, sql);
|
|
66
|
+
const queryRun = executeResponse.body.query_run;
|
|
67
|
+
await waitForRetryAfter(executeResponse.retryAfterMs);
|
|
68
|
+
return queryRun;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
progress.stop();
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function waitForQuery(runtime, progress, queryRunId) {
|
|
76
|
+
while (true) {
|
|
77
|
+
let retryAfterMs;
|
|
78
|
+
try {
|
|
79
|
+
const infoResponse = await getStudioQueryRun(runtime, queryRunId);
|
|
80
|
+
const queryRun = infoResponse.body.query_run;
|
|
81
|
+
retryAfterMs = infoResponse.retryAfterMs;
|
|
82
|
+
if (queryRun.status === "succeeded") {
|
|
83
|
+
progress.stop(`${ui.success(ui.GLYPHS.CHECK)} Query completed.`);
|
|
84
|
+
return queryRun;
|
|
85
|
+
}
|
|
86
|
+
if (queryRun.status === "failed") {
|
|
87
|
+
progress.stop(`${ui.error(ui.GLYPHS.ERROR)} Query failed.`);
|
|
88
|
+
throw buildFailedQueryError(queryRun.error);
|
|
89
|
+
}
|
|
90
|
+
if (queryRun.status === "expired") {
|
|
91
|
+
progress.stop(`${ui.warning(ui.GLYPHS.WARNING)} Query expired.`);
|
|
92
|
+
throw new CliError(`Query results expired on ${queryRun.expires_at}.`, EXIT_CODES.RETRY_RECOMMENDED);
|
|
93
|
+
}
|
|
94
|
+
if (!PENDING_QUERY_RUN_STATUSES.has(queryRun.status)) {
|
|
95
|
+
progress.stop(`${ui.error(ui.GLYPHS.ERROR)} Query failed.`);
|
|
96
|
+
throw new CliError(`Unexpected query status: ${queryRun.status}`, 1);
|
|
97
|
+
}
|
|
98
|
+
progress.update(renderPendingQueryStatus(queryRun.id));
|
|
99
|
+
await waitForRetryAfter(retryAfterMs);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof HttpError && error.status === 429) {
|
|
103
|
+
progress.update(`${ui.warning(ui.GLYPHS.WARNING)} Polling too quickly; retrying ${ui.dim(`(${queryRunId})`)}`);
|
|
104
|
+
await waitForRetryAfter(retryAfterMs);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
progress.stop(`${ui.error(ui.GLYPHS.ERROR)} Query failed.`);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function renderRunningQuery(sql) {
|
|
113
|
+
process.stderr.write(`${ui.bold("Running:")} ${ui.code(sql)}\n`);
|
|
114
|
+
}
|
|
115
|
+
function renderPendingQueryStatus(queryRunId) {
|
|
116
|
+
return `${ui.bold("Query running")} ${ui.dim(`(${queryRunId})`)}`;
|
|
117
|
+
}
|
|
118
|
+
async function executeStudioQuery(runtime, sql) {
|
|
119
|
+
return request(runtime, "/studio.queryRuns.execute", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: { sql },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function getStudioQueryRun(runtime, id) {
|
|
125
|
+
return request(runtime, "/studio.queryRuns.info", {
|
|
126
|
+
method: "GET",
|
|
127
|
+
query: { id },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function getStudioQueryResults(runtime, id) {
|
|
131
|
+
const response = await request(runtime, "/studio.queryRuns.results", {
|
|
132
|
+
method: "GET",
|
|
133
|
+
query: { id },
|
|
134
|
+
});
|
|
135
|
+
return response.body;
|
|
136
|
+
}
|
|
137
|
+
async function downloadStudioQueryResultsCsv(runtime, id, filename) {
|
|
138
|
+
const response = await fetchWithApiHeaders(runtime, `${runtime.baseUrl}/studio.queryRuns.results?id=${encodeURIComponent(id)}&format=csv`, { redirect: "manual" });
|
|
139
|
+
if (response.status >= 300 && response.status < 400) {
|
|
140
|
+
const location = response.headers.get("location");
|
|
141
|
+
if (!location) {
|
|
142
|
+
throw new HttpError("CSV download redirect did not include a Location header.", response.status);
|
|
143
|
+
}
|
|
144
|
+
const downloadResponse = await fetchResponse(new URL(location, runtime.baseUrl).toString(), {
|
|
145
|
+
method: "GET",
|
|
146
|
+
headers: buildDownloadHeaders(runtime, "text/csv"),
|
|
147
|
+
});
|
|
148
|
+
await ensureOkResponse(downloadResponse);
|
|
149
|
+
await writeResponseBodyToFile(downloadResponse, filename);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
await ensureOkResponse(response);
|
|
153
|
+
await writeResponseBodyToFile(response, filename);
|
|
154
|
+
}
|
|
155
|
+
function buildHeaders(runtime, accept) {
|
|
156
|
+
const headers = new Headers({
|
|
157
|
+
Accept: accept,
|
|
158
|
+
"User-Agent": `dx-cli/${runtime.version}`,
|
|
159
|
+
});
|
|
160
|
+
headers.set("Authorization", `Bearer ${runtime.token}`);
|
|
161
|
+
if (runtime.context.agent) {
|
|
162
|
+
headers.set("X-DX-Agent-Name", runtime.context.agent);
|
|
163
|
+
}
|
|
164
|
+
if (runtime.context.agentSessionId) {
|
|
165
|
+
headers.set("X-DX-Agent-Session-Id", runtime.context.agentSessionId);
|
|
166
|
+
}
|
|
167
|
+
return headers;
|
|
168
|
+
}
|
|
169
|
+
function buildDownloadHeaders(runtime, accept) {
|
|
170
|
+
return new Headers({
|
|
171
|
+
Accept: accept,
|
|
172
|
+
"User-Agent": `dx-cli/${runtime.version}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async function fetchWithApiHeaders(runtime, url, options = {}) {
|
|
176
|
+
return fetchResponse(url, {
|
|
177
|
+
method: "GET",
|
|
178
|
+
headers: buildHeaders(runtime, "text/csv"),
|
|
179
|
+
...options,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async function fetchResponse(url, init) {
|
|
183
|
+
let response;
|
|
184
|
+
try {
|
|
185
|
+
response = await fetch(url, init);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
throw new HttpError(`Request failed: ${error.message}`);
|
|
189
|
+
}
|
|
190
|
+
return response;
|
|
191
|
+
}
|
|
192
|
+
async function ensureOkResponse(response) {
|
|
193
|
+
if (response.ok) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const body = await parseResponseBody(response);
|
|
197
|
+
const message = extractErrorMessage(body) ||
|
|
198
|
+
`Request failed with status ${response.status}`;
|
|
199
|
+
throw new HttpError(message, response.status, body);
|
|
200
|
+
}
|
|
201
|
+
async function parseResponseBody(response) {
|
|
202
|
+
return parseResponseText(await response.text());
|
|
203
|
+
}
|
|
204
|
+
function extractErrorMessage(body) {
|
|
205
|
+
if (!body || typeof body !== "object") {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const record = body;
|
|
209
|
+
if (record.error_details &&
|
|
210
|
+
typeof record.error_details === "object" &&
|
|
211
|
+
typeof record.error_details.message ===
|
|
212
|
+
"string") {
|
|
213
|
+
return record.error_details.message;
|
|
214
|
+
}
|
|
215
|
+
if (typeof record.error === "string") {
|
|
216
|
+
return record.error;
|
|
217
|
+
}
|
|
218
|
+
if (typeof record.message === "string") {
|
|
219
|
+
return record.message;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
function parseResponseText(text) {
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(text);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return text;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function waitForRetryAfter(retryAfterMs) {
|
|
232
|
+
const delayMs = retryAfterMs ?? DEFAULT_RETRY_AFTER_MS;
|
|
233
|
+
await new Promise((resolve) => {
|
|
234
|
+
setTimeout(resolve, delayMs);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function writeResponseBodyToFile(response, filename) {
|
|
238
|
+
if (!response.body) {
|
|
239
|
+
fs.writeFileSync(filename, "");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const output = fs.createWriteStream(filename);
|
|
243
|
+
const reader = response.body.getReader();
|
|
244
|
+
try {
|
|
245
|
+
while (true) {
|
|
246
|
+
const { done, value } = await reader.read();
|
|
247
|
+
if (done) {
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
if (value && !output.write(Buffer.from(value))) {
|
|
251
|
+
await once(output, "drain");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
output.end();
|
|
255
|
+
await once(output, "close");
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
output.destroy();
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
reader.releaseLock();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function buildFailedQueryError(error) {
|
|
266
|
+
if (!error) {
|
|
267
|
+
return new CliError("Query failed.", 1);
|
|
268
|
+
}
|
|
269
|
+
return new CliError(`Query failed (${error.code}): ${error.message}`, 1);
|
|
270
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { clearLine, cursorTo } from "node:readline";
|
|
2
|
+
import { renderRichText } from "../../renderers.js";
|
|
3
|
+
import * as ui from "../../ui.js";
|
|
4
|
+
export function renderQueryResultsTable(results) {
|
|
5
|
+
renderRichText([ui.p(formatAsciiTable(results.columns, results.rows))]);
|
|
6
|
+
}
|
|
7
|
+
export function renderCsvSaved(filename) {
|
|
8
|
+
renderRichText([ui.p(`Saved CSV results to ${ui.code(filename)}.`)]);
|
|
9
|
+
}
|
|
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
|
+
function formatAsciiTable(columns, rows) {
|
|
70
|
+
if (columns.length === 0) {
|
|
71
|
+
return "(Query returned no columns)";
|
|
72
|
+
}
|
|
73
|
+
const normalizedRows = rows.map((row) => columns.map((_, index) => formatCell(row[index])));
|
|
74
|
+
const widths = columns.map((column, index) => Math.max(column.length, ...normalizedRows.map((row) => row[index]?.length ?? 0)));
|
|
75
|
+
const border = `+${widths.map((width) => "-".repeat(width + 2)).join("+")}+`;
|
|
76
|
+
const header = `| ${columns.map((column, index) => column.padEnd(widths[index])).join(" | ")} |`;
|
|
77
|
+
const body = normalizedRows.map((row) => `| ${row.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`);
|
|
78
|
+
return [border, header, border, ...body, border].join("\n");
|
|
79
|
+
}
|
|
80
|
+
function formatCell(value) {
|
|
81
|
+
if (value === null) {
|
|
82
|
+
return "NULL";
|
|
83
|
+
}
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === "string") {
|
|
88
|
+
return sanitizeCell(value);
|
|
89
|
+
}
|
|
90
|
+
if (typeof value === "number" ||
|
|
91
|
+
typeof value === "bigint" ||
|
|
92
|
+
typeof value === "boolean") {
|
|
93
|
+
return String(value);
|
|
94
|
+
}
|
|
95
|
+
return sanitizeCell(JSON.stringify(value) ?? String(value));
|
|
96
|
+
}
|
|
97
|
+
function sanitizeCell(value) {
|
|
98
|
+
return value.replace(/\r?\n/g, "\\n").replace(/\t/g, " ");
|
|
99
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { queryCommand } from "./studio/query.js";
|
|
3
|
+
export function studioCommand() {
|
|
4
|
+
const studio = new Command()
|
|
5
|
+
.name("studio")
|
|
6
|
+
.description("Perform data studio actions");
|
|
7
|
+
studio.addCommand(queryCommand());
|
|
8
|
+
return studio;
|
|
9
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const DEFAULT_BASE_URL = "https://api.getdx.com";
|
|
5
|
+
export function getConfigDir() {
|
|
6
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
7
|
+
if (xdg) {
|
|
8
|
+
return path.join(xdg, "dx");
|
|
9
|
+
}
|
|
10
|
+
return path.join(os.homedir(), ".config", "dx");
|
|
11
|
+
}
|
|
12
|
+
export function getConfigPath() {
|
|
13
|
+
return path.join(getConfigDir(), "config.json");
|
|
14
|
+
}
|
|
15
|
+
export function readConfig() {
|
|
16
|
+
const configPath = getConfigPath();
|
|
17
|
+
if (!fs.existsSync(configPath)) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
21
|
+
return JSON.parse(content);
|
|
22
|
+
}
|
|
23
|
+
export function writeConfig(config) {
|
|
24
|
+
fs.mkdirSync(getConfigDir(), { recursive: true });
|
|
25
|
+
fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
26
|
+
}
|
|
27
|
+
export function resolveBaseUrl() {
|
|
28
|
+
if (process.env.DX_BASE_URL) {
|
|
29
|
+
return normalizeBaseUrl(process.env.DX_BASE_URL);
|
|
30
|
+
}
|
|
31
|
+
return normalizeBaseUrl(readConfig().baseUrl || DEFAULT_BASE_URL);
|
|
32
|
+
}
|
|
33
|
+
export function persistBaseUrl(baseUrl) {
|
|
34
|
+
writeConfig({ ...readConfig(), baseUrl: normalizeBaseUrl(baseUrl) });
|
|
35
|
+
}
|
|
36
|
+
function normalizeBaseUrl(baseUrl) {
|
|
37
|
+
return baseUrl.replace(/\/+$/, "");
|
|
38
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const EXIT_CODES = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
ARGUMENT_ERROR: 2,
|
|
4
|
+
AUTHENTICATION_ERROR: 3,
|
|
5
|
+
RETRY_RECOMMENDED: 4,
|
|
6
|
+
};
|
|
7
|
+
export class CliError extends Error {
|
|
8
|
+
exitCode;
|
|
9
|
+
constructor(message, exitCode = EXIT_CODES.RETRY_RECOMMENDED) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "CliError";
|
|
12
|
+
this.exitCode = exitCode;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class HttpError extends CliError {
|
|
16
|
+
status;
|
|
17
|
+
body;
|
|
18
|
+
constructor(message, status, body, exitCode = 1) {
|
|
19
|
+
super(message, exitCode);
|
|
20
|
+
this.name = "HttpError";
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.body = body;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { HttpError } from "./errors.js";
|
|
2
|
+
export async function request(runtime, route, options = {}) {
|
|
3
|
+
const method = options.method || "GET";
|
|
4
|
+
const headers = new Headers({
|
|
5
|
+
Accept: "application/json",
|
|
6
|
+
"X-Client-Type": "dx-cli",
|
|
7
|
+
"X-Client-Version": runtime.version,
|
|
8
|
+
"User-Agent": `dx-cli/${runtime.version}`,
|
|
9
|
+
});
|
|
10
|
+
headers.set("Authorization", `Bearer ${runtime.token}`);
|
|
11
|
+
if (options.body !== undefined) {
|
|
12
|
+
headers.set("Content-Type", "application/json");
|
|
13
|
+
}
|
|
14
|
+
if (runtime.context.agent) {
|
|
15
|
+
headers.set("X-DX-Agent-Name", runtime.context.agent);
|
|
16
|
+
}
|
|
17
|
+
if (runtime.context.agentSessionId) {
|
|
18
|
+
headers.set("X-DX-Agent-Session-Id", runtime.context.agentSessionId);
|
|
19
|
+
}
|
|
20
|
+
const query = new URLSearchParams();
|
|
21
|
+
Object.entries(options.query || {}).forEach(([key, value]) => {
|
|
22
|
+
if (value !== undefined) {
|
|
23
|
+
query.set(key, String(value));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
const url = `${runtime.baseUrl}${route}${query.size > 0 ? `?${query.toString()}` : ""}`;
|
|
27
|
+
const requestBody = options.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
28
|
+
runtime.logger.debug("Sending HTTP request", {
|
|
29
|
+
body: options.body ?? null,
|
|
30
|
+
headers: redactHeaders(headers),
|
|
31
|
+
method,
|
|
32
|
+
url,
|
|
33
|
+
});
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(url, {
|
|
37
|
+
method,
|
|
38
|
+
headers,
|
|
39
|
+
body: requestBody,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new HttpError(`Request failed: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
const responseBodyText = await response.text();
|
|
46
|
+
const parsedResponseBody = parseResponseBody(responseBodyText);
|
|
47
|
+
runtime.logger.debug("Received HTTP response", {
|
|
48
|
+
body: parsedResponseBody,
|
|
49
|
+
headers: headersToObject(response.headers),
|
|
50
|
+
method,
|
|
51
|
+
status: response.status,
|
|
52
|
+
url,
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const message = extractErrorMessage(parsedResponseBody) ||
|
|
56
|
+
`Request failed with status ${response.status}`;
|
|
57
|
+
throw new HttpError(message, response.status, parsedResponseBody);
|
|
58
|
+
}
|
|
59
|
+
if (typeof parsedResponseBody === "string" &&
|
|
60
|
+
parsedResponseBody === responseBodyText) {
|
|
61
|
+
throw new HttpError("Invalid JSON response: Unexpected token");
|
|
62
|
+
}
|
|
63
|
+
const retryAfterMs = parseRetryAfterMs(response.headers);
|
|
64
|
+
const body = parsedResponseBody;
|
|
65
|
+
return retryAfterMs === undefined ? { body } : { body, retryAfterMs };
|
|
66
|
+
}
|
|
67
|
+
export function parseRetryAfterMs(headers) {
|
|
68
|
+
const retryAfter = headers.get("retry-after");
|
|
69
|
+
if (!retryAfter) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const seconds = Number(retryAfter);
|
|
73
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
74
|
+
return Math.ceil(seconds * 1000);
|
|
75
|
+
}
|
|
76
|
+
const retryAt = Date.parse(retryAfter);
|
|
77
|
+
if (Number.isNaN(retryAt)) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return Math.max(0, retryAt - Date.now());
|
|
81
|
+
}
|
|
82
|
+
function extractErrorMessage(body) {
|
|
83
|
+
if (!body || typeof body !== "object") {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const record = body;
|
|
87
|
+
if (record.error_details &&
|
|
88
|
+
typeof record.error_details === "object" &&
|
|
89
|
+
typeof record.error_details.message ===
|
|
90
|
+
"string") {
|
|
91
|
+
return record.error_details.message;
|
|
92
|
+
}
|
|
93
|
+
if (typeof record.error === "string") {
|
|
94
|
+
return record.error;
|
|
95
|
+
}
|
|
96
|
+
if (typeof record.message === "string") {
|
|
97
|
+
return record.message;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function parseResponseBody(bodyText) {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(bodyText);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return bodyText;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function redactHeaders(headers) {
|
|
110
|
+
const result = {};
|
|
111
|
+
headers.forEach((value, key) => {
|
|
112
|
+
result[key] =
|
|
113
|
+
key.toLowerCase() === "authorization"
|
|
114
|
+
? redactAuthorization(value)
|
|
115
|
+
: value;
|
|
116
|
+
});
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
function redactAuthorization(value) {
|
|
120
|
+
return value.replace(/^Bearer\s+.+$/i, "Bearer [REDACTED]");
|
|
121
|
+
}
|
|
122
|
+
function headersToObject(headers) {
|
|
123
|
+
const result = {};
|
|
124
|
+
headers.forEach((value, key) => {
|
|
125
|
+
result[key] = value;
|
|
126
|
+
});
|
|
127
|
+
return result;
|
|
128
|
+
}
|
package/dist/index.js
ADDED
package/dist/logger.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
2
|
+
const LOG_LEVEL_PRIORITY = {
|
|
3
|
+
debug: 10,
|
|
4
|
+
info: 20,
|
|
5
|
+
warn: 30,
|
|
6
|
+
error: 40,
|
|
7
|
+
};
|
|
8
|
+
export function createLogger(options) {
|
|
9
|
+
const configuredLevel = parseLogLevel(process.env.DX_LOG_LEVEL);
|
|
10
|
+
if (!configuredLevel) {
|
|
11
|
+
return createNoopLogger();
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
debug(message, fields) {
|
|
15
|
+
writeLog(configuredLevel, options, "debug", message, fields);
|
|
16
|
+
},
|
|
17
|
+
info(message, fields) {
|
|
18
|
+
writeLog(configuredLevel, options, "info", message, fields);
|
|
19
|
+
},
|
|
20
|
+
warn(message, fields) {
|
|
21
|
+
writeLog(configuredLevel, options, "warn", message, fields);
|
|
22
|
+
},
|
|
23
|
+
error(message, fields) {
|
|
24
|
+
writeLog(configuredLevel, options, "error", message, fields);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function parseLogLevel(value) {
|
|
29
|
+
if (!value) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const normalized = value.toLowerCase();
|
|
33
|
+
if (LOG_LEVELS.includes(normalized)) {
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function createNoopLogger() {
|
|
39
|
+
const noop = () => { };
|
|
40
|
+
return {
|
|
41
|
+
debug: noop,
|
|
42
|
+
error: noop,
|
|
43
|
+
info: noop,
|
|
44
|
+
warn: noop,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function writeLog(configuredLevel, options, level, message, fields) {
|
|
48
|
+
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[configuredLevel]) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const entry = {
|
|
52
|
+
fields,
|
|
53
|
+
level,
|
|
54
|
+
message,
|
|
55
|
+
time: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
if (options.json) {
|
|
58
|
+
process.stderr.write(JSON.stringify({
|
|
59
|
+
time: entry.time,
|
|
60
|
+
level: entry.level,
|
|
61
|
+
message: entry.message,
|
|
62
|
+
...(entry.fields ?? {}),
|
|
63
|
+
}) + "\n");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const renderedFields = entry.fields && Object.keys(entry.fields).length > 0
|
|
67
|
+
? ` ${JSON.stringify(entry.fields)}`
|
|
68
|
+
: "";
|
|
69
|
+
process.stderr.write(`${entry.time} ${entry.level.toUpperCase()} ${entry.message}${renderedFields}\n`);
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an object in plain JSON formatting.
|
|
3
|
+
*
|
|
4
|
+
* Should be used when the `--flag` is present.
|
|
5
|
+
*/
|
|
6
|
+
export function renderJson(value) {
|
|
7
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Render rich text to the terminal.
|
|
11
|
+
*
|
|
12
|
+
* Should be used when the `--flag` is NOT present.
|
|
13
|
+
*/
|
|
14
|
+
export function renderRichText(blocks) {
|
|
15
|
+
const normalizedBlocks = blocks
|
|
16
|
+
.filter((block) => block !== null)
|
|
17
|
+
.flatMap((block) => {
|
|
18
|
+
if (Array.isArray(block)) {
|
|
19
|
+
return block;
|
|
20
|
+
}
|
|
21
|
+
return [block];
|
|
22
|
+
});
|
|
23
|
+
const renderedBlocks = [];
|
|
24
|
+
for (const block of normalizedBlocks) {
|
|
25
|
+
const blockContent = block.render();
|
|
26
|
+
renderedBlocks.push(blockContent);
|
|
27
|
+
}
|
|
28
|
+
process.stdout.write(renderedBlocks.join("\n"));
|
|
29
|
+
}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolveBaseUrl } from "./config.js";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { getToken } from "./secrets.js";
|
|
4
|
+
import { createLogger } from "./logger.js";
|
|
5
|
+
import cliPackage from "../package.json" with { type: "json" };
|
|
6
|
+
export function buildLogger(context) {
|
|
7
|
+
return createLogger({ json: context.json || !process.stderr.isTTY });
|
|
8
|
+
}
|
|
9
|
+
export function buildRuntime(context) {
|
|
10
|
+
const baseUrl = resolveBaseUrl();
|
|
11
|
+
const token = getToken(baseUrl);
|
|
12
|
+
if (!token) {
|
|
13
|
+
throw new CliError("No API token configured. Run `dx auth login --token <token>` or set DX_API_TOKEN.");
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
baseUrl,
|
|
17
|
+
token,
|
|
18
|
+
context,
|
|
19
|
+
version: cliPackage.version,
|
|
20
|
+
logger: buildLogger(context),
|
|
21
|
+
};
|
|
22
|
+
}
|