@get-dx/cli 0.6.0 → 0.6.2
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/commandHelpers.js +34 -1
- package/dist/commands/studio/query.js +2 -2
- package/dist/commands/studio/reports.js +2 -2
- package/dist/config.js +20 -4
- package/dist/http.js +32 -2
- package/dist/loginViaBrowser.js +10 -1
- package/package.json +1 -1
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
|
}
|
|
@@ -367,9 +367,9 @@ function studioReportToYaml(report) {
|
|
|
367
367
|
description: report.description ?? "",
|
|
368
368
|
markdown_notes: report.markdown_notes ?? "",
|
|
369
369
|
view_access_type: report.view_access_type,
|
|
370
|
-
viewer_emails: [],
|
|
370
|
+
viewer_emails: report.viewer_emails ?? [],
|
|
371
371
|
edit_access_type: report.edit_access_type,
|
|
372
|
-
editor_emails: [],
|
|
372
|
+
editor_emails: report.editor_emails ?? [],
|
|
373
373
|
tiles: report.tiles.map((tile) => ({
|
|
374
374
|
id: tile.id,
|
|
375
375
|
title: tile.title,
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CliError, EXIT_CODES } from "./errors.js";
|
|
5
|
-
const DEFAULT_API_BASE_URL = "https://api.getdx.com";
|
|
5
|
+
export const DEFAULT_API_BASE_URL = "https://api.getdx.com";
|
|
6
6
|
const DEFAULT_WEB_BASE_URL = "https://app.getdx.com";
|
|
7
7
|
// --- Config file I/O ---------------------------------------------------------
|
|
8
8
|
export function getConfigPath() {
|
|
@@ -14,7 +14,16 @@ export function readConfig() {
|
|
|
14
14
|
return {};
|
|
15
15
|
}
|
|
16
16
|
const content = fs.readFileSync(configPath, "utf8");
|
|
17
|
-
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = JSON.parse(content);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// The config file can be left empty or truncated if a previous `dx`
|
|
23
|
+
// process was killed mid-write, or two processes wrote concurrently.
|
|
24
|
+
// Treat it like a missing config rather than crashing every command.
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
18
27
|
const stored = {};
|
|
19
28
|
if (typeof raw.webBaseUrl === "string") {
|
|
20
29
|
stored.webBaseUrl = raw.webBaseUrl;
|
|
@@ -46,8 +55,15 @@ export function persistVersionPromptSelection(prompt) {
|
|
|
46
55
|
writeConfig(config);
|
|
47
56
|
}
|
|
48
57
|
export function writeConfig(config) {
|
|
49
|
-
|
|
50
|
-
fs.
|
|
58
|
+
const configDir = getConfigDir();
|
|
59
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
60
|
+
const configPath = getConfigPath();
|
|
61
|
+
// Write to a temp file and rename into place so a crash or a race between
|
|
62
|
+
// concurrent `dx` processes can't leave config.json empty or truncated —
|
|
63
|
+
// renames are atomic, so readers always see either the old or new content.
|
|
64
|
+
const tempPath = path.join(configDir, `.config.json.${process.pid}.${Date.now()}.tmp`);
|
|
65
|
+
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
66
|
+
fs.renameSync(tempPath, configPath);
|
|
51
67
|
}
|
|
52
68
|
export function persistBaseUrls(apiBaseUrl, webBaseUrl) {
|
|
53
69
|
writeConfig({
|
package/dist/http.js
CHANGED
|
@@ -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,42 @@ 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
|
+
/**
|
|
76
|
+
* Flatten an error thrown by `fetch` into a single readable message.
|
|
77
|
+
*
|
|
78
|
+
* Node reports every DNS, proxy, and TLS failure as `TypeError: fetch failed`
|
|
79
|
+
* and hangs the real reason off `cause`, so the top-level message alone can't
|
|
80
|
+
* distinguish an untrusted corporate root certificate from an unreachable host.
|
|
81
|
+
*/
|
|
82
|
+
export function describeFetchFailure(error) {
|
|
83
|
+
if (!(error instanceof Error)) {
|
|
84
|
+
return String(error);
|
|
85
|
+
}
|
|
86
|
+
const parts = [];
|
|
87
|
+
let current = error;
|
|
88
|
+
while (current instanceof Error) {
|
|
89
|
+
const { code } = current;
|
|
90
|
+
const part = code ? `${current.message} (${code})` : current.message;
|
|
91
|
+
if (!parts.includes(part)) {
|
|
92
|
+
parts.push(part);
|
|
93
|
+
}
|
|
94
|
+
current = current.cause;
|
|
95
|
+
}
|
|
96
|
+
// "fetch failed" carries no information once a cause is available.
|
|
97
|
+
const informative = parts.filter((part) => parts.length === 1 || part !== "fetch failed");
|
|
98
|
+
return informative.join(": ");
|
|
99
|
+
}
|
|
70
100
|
export function parseRetryAfterMs(headers) {
|
|
71
101
|
const retryAfter = headers.get("retry-after");
|
|
72
102
|
if (!retryAfter) {
|
package/dist/loginViaBrowser.js
CHANGED
|
@@ -49,7 +49,16 @@ export async function loginViaBrowser(webBaseUrl) {
|
|
|
49
49
|
error: new CliError(`Authentication failed: token exchange failed with status ${tokenExchangeResponse.status}`),
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
body = (await tokenExchangeResponse.json());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {
|
|
58
|
+
type: "ERROR",
|
|
59
|
+
error: new CliError("Authentication failed: token exchange returned an invalid response"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
53
62
|
return {
|
|
54
63
|
type: "SUCCESS",
|
|
55
64
|
token: body.access_token,
|