@get-dx/cli 0.8.0 → 0.10.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.
@@ -0,0 +1,224 @@
1
+ import { describeError, errorCode, timed } from "./format.js";
2
+ import { CHECK_TIMEOUT_MS, DOCTOR_HEADER, } from "./probes.js";
3
+ import { check } from "./types.js";
4
+ const TLS_VERIFY_HINT = "Your network may be intercepting HTTPS traffic with its own certificate.\nSet NODE_EXTRA_CA_CERTS to your corporate CA bundle (PEM), or ask your network team to allow {host}.";
5
+ const CONNECTION_RESET_HINT = "A firewall or proxy on your network is likely interrupting HTTPS traffic to {host}.\nAsk your network team to allow {host}, or set NODE_EXTRA_CA_CERTS to your corporate CA bundle.";
6
+ export async function checkNetwork(options) {
7
+ const { apiBaseUrl, webBaseUrl, version, probes } = options;
8
+ const checks = [];
9
+ let url;
10
+ try {
11
+ url = new URL(apiBaseUrl);
12
+ }
13
+ catch {
14
+ checks.push(check("dns", "DNS", "fail", `invalid API base URL "${apiBaseUrl}"`, {
15
+ hint: "Check DX_API_BASE_URL or the apiBaseUrl in your config file.",
16
+ }), await webAppCheck(webBaseUrl, version, probes));
17
+ return {
18
+ section: { id: "network", title: "Network", checks },
19
+ facts: { apiReachable: false },
20
+ };
21
+ }
22
+ const host = url.hostname;
23
+ const isHttps = url.protocol === "https:";
24
+ const port = url.port ? Number(url.port) : isHttps ? 443 : 80;
25
+ const fill = (text) => text.replaceAll("{host}", host);
26
+ let skipReason = null;
27
+ const skip = (id, label) => check(id, label, "skip", `skipped because ${skipReason}`);
28
+ // 1. DNS
29
+ try {
30
+ const { value: addresses, ms } = await timed(() => probes.lookup(host));
31
+ checks.push(check("dns", "DNS", "ok", `resolved to ${addresses.join(", ")} (${ms}ms)`, { data: { addresses, ms } }));
32
+ }
33
+ catch (error) {
34
+ const code = errorCode(error);
35
+ checks.push(check("dns", "DNS", "fail", describeError(error, "DNS lookup"), {
36
+ hint: code === "ENOTFOUND"
37
+ ? fill("{host} could not be resolved. Check the API base URL, your VPN, and your DNS settings.")
38
+ : "Check your network connection, VPN, and DNS settings.",
39
+ data: { code },
40
+ }));
41
+ skipReason = "DNS lookup failed";
42
+ }
43
+ // 2. TCP
44
+ if (skipReason) {
45
+ checks.push(skip("tcp", "TCP connect"));
46
+ }
47
+ else {
48
+ try {
49
+ const { ms } = await timed(() => probes.tcpConnect(host, port));
50
+ checks.push(check("tcp", "TCP connect", "ok", `port ${port} open (${ms}ms)`, {
51
+ data: { port, ms },
52
+ }));
53
+ }
54
+ catch (error) {
55
+ checks.push(check("tcp", "TCP connect", "fail", describeError(error, `connecting to port ${port}`), {
56
+ hint: fill(`A firewall may be blocking outbound connections to {host} on port ${port}.`),
57
+ data: { port, code: errorCode(error) },
58
+ }));
59
+ skipReason = "the TCP connection failed";
60
+ }
61
+ }
62
+ // 3. TLS
63
+ if (!isHttps) {
64
+ checks.push(check("tls", "TLS handshake", "skip", "skipped because the API base URL does not use HTTPS"));
65
+ }
66
+ else if (skipReason) {
67
+ checks.push(skip("tls", "TLS handshake"));
68
+ }
69
+ else {
70
+ const tlsCheck = await tlsHandshakeCheck(host, port, probes, fill);
71
+ checks.push(tlsCheck);
72
+ if (tlsCheck.status === "fail") {
73
+ skipReason = "the TLS handshake failed";
74
+ }
75
+ }
76
+ // 4. HTTPS request to the API
77
+ let apiReachable = false;
78
+ if (skipReason) {
79
+ checks.push(skip("https", "HTTPS"));
80
+ }
81
+ else {
82
+ const httpsCheck = await apiRequestCheck(apiBaseUrl, version, probes, fill);
83
+ checks.push(httpsCheck);
84
+ apiReachable = httpsCheck.status === "ok";
85
+ }
86
+ // 5. Web app, independent of the API checks
87
+ checks.push(await webAppCheck(webBaseUrl, version, probes));
88
+ return {
89
+ section: { id: "network", title: `Network (${host})`, checks },
90
+ facts: { apiReachable },
91
+ };
92
+ }
93
+ async function tlsHandshakeCheck(host, port, probes, fill) {
94
+ try {
95
+ const { value: result, ms } = await timed(() => probes.tlsHandshake(host, port));
96
+ const { certificate } = result;
97
+ const parts = [
98
+ result.protocol,
99
+ certificate.issuer ? `issuer "${certificate.issuer}"` : null,
100
+ certificate.validTo
101
+ ? `valid until ${certificate.validTo.slice(0, 10)}`
102
+ : null,
103
+ ].filter(Boolean);
104
+ const summary = `${parts.join("; ")} (${ms}ms)`;
105
+ const data = {
106
+ protocol: result.protocol,
107
+ subject: certificate.subject,
108
+ issuer: certificate.issuer,
109
+ valid_to: certificate.validTo,
110
+ authorized: result.authorized,
111
+ authorization_error: result.authorizationError,
112
+ root: result.rootIssuer,
113
+ root_is_bundled: result.rootIsBundled,
114
+ ms,
115
+ };
116
+ if (!result.authorized) {
117
+ return check("tls", "TLS handshake", "fail", `certificate not trusted (${result.authorizationError ?? "unknown error"}); ${summary}`, { hint: fill(TLS_VERIFY_HINT), data });
118
+ }
119
+ if (result.rootIsBundled === false) {
120
+ return check("tls", "TLS handshake", "warn", `trusted via a non-public CA "${result.rootIssuer}"; ${summary}`, {
121
+ hint: `Traffic appears to be intercepted by "${result.rootIssuer}". This works because that CA is trusted locally, but requests are being inspected by your network.`,
122
+ data,
123
+ });
124
+ }
125
+ return check("tls", "TLS handshake", "ok", summary, { data });
126
+ }
127
+ catch (error) {
128
+ const code = errorCode(error);
129
+ const reset = code === "ECONNRESET" || code === "EPIPE";
130
+ return check("tls", "TLS handshake", "fail", reset
131
+ ? `connection reset by peer during handshake (${code})`
132
+ : describeError(error, "TLS handshake"), {
133
+ hint: fill(reset ? CONNECTION_RESET_HINT : TLS_VERIFY_HINT),
134
+ data: { code },
135
+ });
136
+ }
137
+ }
138
+ async function apiRequestCheck(apiBaseUrl, version, probes, fill) {
139
+ const url = `${apiBaseUrl}/auth.info`;
140
+ try {
141
+ const { value: response, ms } = await timed(async () => {
142
+ const response = await probes.fetch(url, {
143
+ headers: {
144
+ ...clientHeaders(version),
145
+ ...DOCTOR_HEADER,
146
+ Accept: "application/json",
147
+ },
148
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
149
+ });
150
+ return { status: response.status, text: await response.text() };
151
+ });
152
+ const data = { url, status: response.status, ms };
153
+ if (!isJson(response.text)) {
154
+ return check("https", "HTTPS", "fail", `unexpected non-JSON response (HTTP ${response.status}) (${ms}ms)`, {
155
+ hint: fill("Something other than the DX API answered. A proxy, firewall, or captive portal may be intercepting requests to {host}."),
156
+ data,
157
+ });
158
+ }
159
+ if (response.status >= 500) {
160
+ return check("https", "HTTPS", "fail", `DX API returned HTTP ${response.status} (${ms}ms)`, {
161
+ hint: "The DX API is having problems. Try again in a few minutes.",
162
+ data,
163
+ });
164
+ }
165
+ const message = response.status === 401
166
+ ? `DX API responded (401 without token, as expected) (${ms}ms)`
167
+ : `DX API responded (HTTP ${response.status}) (${ms}ms)`;
168
+ return check("https", "HTTPS", "ok", message, { data });
169
+ }
170
+ catch (error) {
171
+ return check("https", "HTTPS", "fail", describeError(error, "request"), {
172
+ hint: fill("Requests to {host} are failing even though the connection succeeded. A proxy or firewall may be interfering."),
173
+ data: { url, code: errorCode(error) },
174
+ });
175
+ }
176
+ }
177
+ async function webAppCheck(webBaseUrl, version, probes) {
178
+ try {
179
+ const { value: status, ms } = await timed(async () => {
180
+ const response = await probes.fetch(webBaseUrl, {
181
+ headers: { ...clientHeaders(version), ...DOCTOR_HEADER },
182
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
183
+ });
184
+ await response.body?.cancel();
185
+ return response.status;
186
+ });
187
+ const data = { url: webBaseUrl, status, ms };
188
+ if (status >= 500) {
189
+ return check("web_app", "Web app reachable", "fail", `${webBaseUrl} returned HTTP ${status} (${ms}ms)`, {
190
+ hint: "The DX web app is having problems. Browser login may not work.",
191
+ data,
192
+ });
193
+ }
194
+ if (status >= 400) {
195
+ return check("web_app", "Web app reachable", "warn", `${webBaseUrl} returned HTTP ${status} (${ms}ms)`, {
196
+ hint: "The web app answered with an error. A proxy or firewall may be blocking it, and browser login may not work.",
197
+ data,
198
+ });
199
+ }
200
+ return check("web_app", "Web app reachable", "ok", `${webBaseUrl} responded ${status} (${ms}ms)`, { data });
201
+ }
202
+ catch (error) {
203
+ return check("web_app", "Web app reachable", "fail", `${webBaseUrl}: ${describeError(error, "request")}`, {
204
+ hint: "Browser login (`dx auth login` without --token) needs the web app. Check your network, or log in with --token instead.",
205
+ data: { url: webBaseUrl, code: errorCode(error) },
206
+ });
207
+ }
208
+ }
209
+ function clientHeaders(version) {
210
+ return {
211
+ "X-Client-Type": "dx-cli",
212
+ "X-Client-Version": version,
213
+ "User-Agent": `dx-cli/${version}`,
214
+ };
215
+ }
216
+ function isJson(text) {
217
+ try {
218
+ JSON.parse(text);
219
+ return true;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
@@ -0,0 +1,130 @@
1
+ import { X509Certificate } from "node:crypto";
2
+ import dns from "node:dns";
3
+ import net from "node:net";
4
+ import tls from "node:tls";
5
+ export const CHECK_TIMEOUT_MS = 10_000;
6
+ /** Headers sent on doctor's requests, so they can be told apart in logs. */
7
+ export const DOCTOR_HEADER = { "X-DX-Doctor": "1" };
8
+ export class ProbeTimeoutError extends Error {
9
+ timeoutMs;
10
+ constructor(timeoutMs) {
11
+ super(`timed out after ${timeoutMs / 1000}s`);
12
+ this.name = "ProbeTimeoutError";
13
+ this.timeoutMs = timeoutMs;
14
+ }
15
+ }
16
+ export const defaultProbes = {
17
+ lookup: (hostname) => withTimeout(dns.promises
18
+ .lookup(hostname, { all: true })
19
+ .then((results) => results.map((result) => result.address))),
20
+ tcpConnect,
21
+ tlsHandshake,
22
+ fetch: (input, init) => globalThis.fetch(input, init),
23
+ };
24
+ function withTimeout(promise) {
25
+ return new Promise((resolve, reject) => {
26
+ const timer = setTimeout(() => reject(new ProbeTimeoutError(CHECK_TIMEOUT_MS)), CHECK_TIMEOUT_MS);
27
+ timer.unref?.();
28
+ promise.then((value) => {
29
+ clearTimeout(timer);
30
+ resolve(value);
31
+ }, (error) => {
32
+ clearTimeout(timer);
33
+ reject(error);
34
+ });
35
+ });
36
+ }
37
+ function tcpConnect(hostname, port) {
38
+ return new Promise((resolve, reject) => {
39
+ const socket = net.connect({ host: hostname, port });
40
+ socket.setTimeout(CHECK_TIMEOUT_MS, () => {
41
+ socket.destroy();
42
+ reject(new ProbeTimeoutError(CHECK_TIMEOUT_MS));
43
+ });
44
+ socket.once("connect", () => {
45
+ socket.destroy();
46
+ resolve();
47
+ });
48
+ socket.once("error", (error) => {
49
+ socket.destroy();
50
+ reject(error);
51
+ });
52
+ });
53
+ }
54
+ function tlsHandshake(hostname, port) {
55
+ return new Promise((resolve, reject) => {
56
+ // Verify manually after the handshake so a bad chain is reported with its
57
+ // details instead of only as an error.
58
+ const socket = tls.connect({
59
+ host: hostname,
60
+ port,
61
+ servername: net.isIP(hostname) ? undefined : hostname,
62
+ rejectUnauthorized: false,
63
+ });
64
+ socket.setTimeout(CHECK_TIMEOUT_MS, () => {
65
+ socket.destroy();
66
+ reject(new ProbeTimeoutError(CHECK_TIMEOUT_MS));
67
+ });
68
+ socket.once("secureConnect", () => {
69
+ const peer = socket.getPeerCertificate(true);
70
+ const root = findChainRoot(peer);
71
+ const authorizationError = socket.authorizationError;
72
+ resolve({
73
+ protocol: socket.getProtocol() ?? null,
74
+ authorized: socket.authorized,
75
+ authorizationError: authorizationError
76
+ ? String(authorizationError.code ??
77
+ authorizationError)
78
+ : null,
79
+ certificate: {
80
+ subject: certificateName(peer.subject),
81
+ issuer: certificateName(peer.issuer),
82
+ validTo: peer.valid_to ? new Date(peer.valid_to).toISOString() : null,
83
+ },
84
+ rootIsBundled: root
85
+ ? bundledRootFingerprints().has(root.fingerprint256)
86
+ : null,
87
+ rootIssuer: root ? certificateName(root.subject) : null,
88
+ });
89
+ socket.destroy();
90
+ });
91
+ socket.once("error", (error) => {
92
+ socket.destroy();
93
+ reject(error);
94
+ });
95
+ });
96
+ }
97
+ /** The self-signed root at the top of a peer chain, if Node resolved one. */
98
+ function findChainRoot(certificate) {
99
+ let current = certificate;
100
+ const seen = new Set();
101
+ while (current?.fingerprint256 && !seen.has(current.fingerprint256)) {
102
+ seen.add(current.fingerprint256);
103
+ const issuer = current.issuerCertificate;
104
+ if (!issuer || issuer.fingerprint256 === current.fingerprint256) {
105
+ return issuer ? current : null;
106
+ }
107
+ current = issuer;
108
+ }
109
+ return null;
110
+ }
111
+ let bundledFingerprints;
112
+ function bundledRootFingerprints() {
113
+ bundledFingerprints ??= new Set(tls.rootCertificates.map((pem) => new X509Certificate(pem).fingerprint256));
114
+ return bundledFingerprints;
115
+ }
116
+ function certificateName(name) {
117
+ if (!name) {
118
+ return null;
119
+ }
120
+ const value = (key) => {
121
+ const field = name[key];
122
+ return Array.isArray(field) ? field[0] : field;
123
+ };
124
+ const commonName = value("CN");
125
+ const organization = value("O");
126
+ if (commonName && organization && commonName !== organization) {
127
+ return `${commonName} (${organization})`;
128
+ }
129
+ return commonName ?? organization ?? null;
130
+ }
@@ -0,0 +1,16 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { isSkillInstalled } from "../skill.js";
4
+ import { tildify } from "./format.js";
5
+ import { check } from "./types.js";
6
+ export async function checkSkill() {
7
+ const installed = await isSkillInstalled();
8
+ const skillPath = tildify(path.join(os.homedir(), ".agents", "skills", "dx-cli"));
9
+ return {
10
+ id: "skill",
11
+ title: "Agent skill",
12
+ checks: [
13
+ check("skill", "dx-cli skill", "ok", installed ? `installed (${skillPath})` : "not installed", { data: { installed, path: installed ? skillPath : null } }),
14
+ ],
15
+ };
16
+ }
@@ -0,0 +1,11 @@
1
+ export function check(id, label, status, message, options = {}) {
2
+ return {
3
+ id,
4
+ label,
5
+ status,
6
+ message,
7
+ hint: status === "warn" || status === "fail" ? (options.hint ?? null) : null,
8
+ source: options.source ?? null,
9
+ data: options.data ?? null,
10
+ };
11
+ }
package/dist/http.js CHANGED
@@ -2,6 +2,7 @@ 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({
5
+ ...options.headers,
5
6
  Accept: "application/json",
6
7
  "X-Client-Type": "dx-cli",
7
8
  "X-Client-Version": runtime.version,
@@ -40,6 +41,7 @@ export async function request(runtime, route, options = {}) {
40
41
  method,
41
42
  headers,
42
43
  body: requestBody,
44
+ signal: options.signal,
43
45
  });
44
46
  }
45
47
  catch (error) {
package/dist/secrets.js CHANGED
@@ -8,6 +8,35 @@ export async function getToken(baseUrl) {
8
8
  }
9
9
  return await getSecretStore().get(baseUrl);
10
10
  }
11
+ /**
12
+ * Like `getToken`, but reports where the token came from, and surfaces errors
13
+ * reading the OS secret store instead of treating them as a missing token.
14
+ */
15
+ export async function getTokenWithSource(baseUrl) {
16
+ if (process.env.DX_API_TOKEN) {
17
+ return { source: "env", token: process.env.DX_API_TOKEN };
18
+ }
19
+ let store;
20
+ try {
21
+ store = getSecretStore();
22
+ }
23
+ catch (error) {
24
+ return {
25
+ source: "error",
26
+ storeName: null,
27
+ error: error instanceof Error ? error.message : String(error),
28
+ };
29
+ }
30
+ const result = await store.probe(baseUrl);
31
+ switch (result.status) {
32
+ case "found":
33
+ return { source: "keychain", token: result.token, storeName: store.name };
34
+ case "missing":
35
+ return { source: "none", storeName: store.name };
36
+ case "error":
37
+ return { source: "error", storeName: store.name, error: result.error };
38
+ }
39
+ }
11
40
  export async function setToken(baseUrl, token) {
12
41
  await getSecretStore().set(baseUrl, token);
13
42
  }
@@ -26,9 +55,16 @@ function getSecretStore(platform = process.platform) {
26
55
  }
27
56
  throw new CliError(`Unsupported platform for secure token storage: ${platform}`);
28
57
  }
58
+ // `security` exits with this code when no matching keychain item exists.
59
+ const MACOS_ITEM_NOT_FOUND_EXIT_CODE = 44;
29
60
  function macosSecretStore() {
30
61
  return {
62
+ name: "macOS Keychain",
31
63
  async get(baseUrl) {
64
+ const result = await this.probe(baseUrl);
65
+ return result.status === "found" ? result.token : null;
66
+ },
67
+ async probe(baseUrl) {
32
68
  try {
33
69
  const result = await execa("security", [
34
70
  "find-generic-password",
@@ -38,10 +74,15 @@ function macosSecretStore() {
38
74
  baseUrl,
39
75
  "-w",
40
76
  ]);
41
- return result.stdout?.trim() ?? null;
77
+ const token = result.stdout?.trim();
78
+ return token ? { status: "found", token } : { status: "missing" };
42
79
  }
43
- catch {
44
- return null;
80
+ catch (error) {
81
+ if (isRecord(error) &&
82
+ error.exitCode === MACOS_ITEM_NOT_FOUND_EXIT_CODE) {
83
+ return { status: "missing" };
84
+ }
85
+ return { status: "error", error: describeStoreError(error) };
45
86
  }
46
87
  },
47
88
  async set(baseUrl, token) {
@@ -86,7 +127,12 @@ function macosSecretStore() {
86
127
  }
87
128
  function linuxSecretStore() {
88
129
  return {
130
+ name: "Linux Secret Service (secret-tool)",
89
131
  async get(baseUrl) {
132
+ const result = await this.probe(baseUrl);
133
+ return result.status === "found" ? result.token : null;
134
+ },
135
+ async probe(baseUrl) {
90
136
  try {
91
137
  const result = await execa("secret-tool", [
92
138
  "lookup",
@@ -95,10 +141,21 @@ function linuxSecretStore() {
95
141
  "account",
96
142
  baseUrl,
97
143
  ]);
98
- return result.stdout?.trim() ?? null;
144
+ const token = result.stdout?.trim();
145
+ return token ? { status: "found", token } : { status: "missing" };
99
146
  }
100
- catch {
101
- return null;
147
+ catch (error) {
148
+ if (hasErrorCode(error, "ENOENT")) {
149
+ return { status: "error", error: "`secret-tool` is not installed" };
150
+ }
151
+ // `secret-tool lookup` exits 1 without output when nothing matches.
152
+ if (isRecord(error) &&
153
+ error.exitCode === 1 &&
154
+ !errorStderr(error) &&
155
+ !isLinuxSecretServiceUnavailable(error)) {
156
+ return { status: "missing" };
157
+ }
158
+ return { status: "error", error: describeStoreError(error) };
102
159
  }
103
160
  },
104
161
  async set(baseUrl, token) {
@@ -116,12 +173,18 @@ function linuxSecretStore() {
116
173
  }
117
174
  function windowsSecretStore() {
118
175
  return {
176
+ name: "Windows Credential Manager",
119
177
  async get(baseUrl) {
178
+ const result = await this.probe(baseUrl);
179
+ return result.status === "found" ? result.token : null;
180
+ },
181
+ async probe(baseUrl) {
120
182
  try {
121
- return await crossKeychain.getPassword(SERVICE, encodeAccountName(baseUrl));
183
+ const token = await crossKeychain.getPassword(SERVICE, encodeAccountName(baseUrl));
184
+ return token ? { status: "found", token } : { status: "missing" };
122
185
  }
123
- catch {
124
- return null;
186
+ catch (error) {
187
+ return { status: "error", error: describeStoreError(error) };
125
188
  }
126
189
  },
127
190
  async set(baseUrl, token) {
@@ -191,6 +254,21 @@ function isLinuxSecretServiceUnavailable(error) {
191
254
  "No such secret service",
192
255
  ].some((message) => text.includes(message));
193
256
  }
257
+ function errorStderr(error) {
258
+ return typeof error.stderr === "string" ? error.stderr.trim() : "";
259
+ }
260
+ function describeStoreError(error) {
261
+ if (isRecord(error)) {
262
+ const stderr = errorStderr(error);
263
+ if (stderr) {
264
+ return stderr;
265
+ }
266
+ if (typeof error.shortMessage === "string") {
267
+ return error.shortMessage;
268
+ }
269
+ }
270
+ return error instanceof Error ? error.message : String(error);
271
+ }
194
272
  function formatErrorDetails(error) {
195
273
  if (!isRecord(error) || typeof error.stderr !== "string") {
196
274
  return null;
package/dist/ui/blocks.js CHANGED
@@ -104,3 +104,49 @@ export class JsonBlock extends AbstractBlock {
104
104
  return pc.cyan(JSON.stringify(this.value, null, 2)) + "\n";
105
105
  }
106
106
  }
107
+ function statusMarker(status) {
108
+ switch (status) {
109
+ case "ok":
110
+ return pc.green("[ OK ]");
111
+ case "warn":
112
+ return pc.yellow("[WARN]");
113
+ case "fail":
114
+ return pc.red("[FAIL]");
115
+ case "skip":
116
+ return pc.dim("[SKIP]");
117
+ }
118
+ }
119
+ /**
120
+ * A titled list of `[ OK ]` / `[WARN]` / `[FAIL]` / `[SKIP]` rows, e.g.:
121
+ *
122
+ * ```
123
+ * Network
124
+ * [ OK ] DNS resolved to 104.18.20.33
125
+ * [FAIL] TLS handshake connection reset by peer
126
+ * → A firewall or proxy is likely interrupting HTTPS traffic.
127
+ * ```
128
+ */
129
+ export class StatusListBlock extends AbstractBlock {
130
+ title;
131
+ rows;
132
+ options;
133
+ constructor(title, rows, options) {
134
+ super();
135
+ this.title = title;
136
+ this.rows = rows;
137
+ this.options = options;
138
+ }
139
+ render() {
140
+ const lines = [pc.bold(this.title)];
141
+ for (const row of this.rows) {
142
+ const label = padEnd(row.label, this.options.labelWidth);
143
+ lines.push(` ${statusMarker(row.status)} ${label}${row.detail}`);
144
+ if (row.hint) {
145
+ const [first, ...rest] = row.hint.split("\n");
146
+ lines.push(` → ${first}`);
147
+ lines.push(...rest.map((line) => ` ${line}`));
148
+ }
149
+ }
150
+ return lines.join("\n") + "\n";
151
+ }
152
+ }
package/dist/ui.js CHANGED
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
2
2
  import relativeTimePlugin from "dayjs/plugin/relativeTime.js";
3
3
  import pc from "picocolors";
4
4
  export { GLYPHS } from "./ui/glyphs.js";
5
- import { DescriptionListBlock, DescriptionListItemContainer, HeadingBlock, JsonBlock, ListItemContainer, ParagraphBlock, UnorderedListBlock, CodeBlock, } from "./ui/blocks.js";
5
+ import { DescriptionListBlock, DescriptionListItemContainer, HeadingBlock, JsonBlock, ListItemContainer, ParagraphBlock, UnorderedListBlock, CodeBlock, StatusListBlock, } from "./ui/blocks.js";
6
6
  dayjs.extend(relativeTimePlugin);
7
7
  // Block-level elements
8
8
  export function h1(text) {
@@ -49,6 +49,13 @@ export function dli(term, detailItems) {
49
49
  return new DescriptionListItemContainer(term, detailItems);
50
50
  }
51
51
  }
52
+ /**
53
+ * Create a titled list of `[ OK ]` / `[WARN]` / `[FAIL]` / `[SKIP]` rows, as
54
+ * used by `dx doctor`.
55
+ */
56
+ export function statusList(title, rows, options) {
57
+ return new StatusListBlock(title, rows, options);
58
+ }
52
59
  export function codeBlock(text) {
53
60
  return new CodeBlock(text);
54
61
  }
@@ -10,7 +10,7 @@ import * as ui from "./ui.js";
10
10
  import cliPackage from "../package.json" with { type: "json" };
11
11
  const VERSION_CHECK_INTERVAL_HOURS = 24;
12
12
  const SNOOZE_DAYS = 7;
13
- const SKIP_COMMANDS = new Set(["auth", "init"]);
13
+ const SKIP_COMMANDS = new Set(["auth", "doctor", "init"]);
14
14
  const SKIP_OPTIONS = new Set(["-h", "--help", "-v", "--version"]);
15
15
  /**
16
16
  * Compares two semver strings (e.g. "1.2.3").
@@ -52,10 +52,32 @@ export function shouldShowVersionPrompt(latestVersion, versionPromptSelection) {
52
52
  }
53
53
  return true;
54
54
  }
55
- async function fetchLatestVersion(runtime) {
56
- const response = await request(runtime, "/cli.currentVersion", { method: "GET" });
55
+ export async function fetchLatestVersion(runtime, options) {
56
+ const response = await request(runtime, "/cli.currentVersion", { ...options, method: "GET" });
57
57
  return response.body.versions.cli;
58
58
  }
59
+ const NPM_LATEST_URL = `https://registry.npmjs.org/${cliPackage.name}/latest`;
60
+ /**
61
+ * Fetch the latest published version from the npm registry. Unlike
62
+ * `fetchLatestVersion`, this does not need a DX API token.
63
+ */
64
+ export async function fetchLatestVersionFromNpm(options) {
65
+ const response = await fetch(NPM_LATEST_URL, {
66
+ headers: {
67
+ Accept: "application/json",
68
+ "User-Agent": `dx-cli/${cliPackage.version}`,
69
+ },
70
+ signal: options?.signal,
71
+ });
72
+ if (!response.ok) {
73
+ throw new Error(`npm registry returned HTTP ${response.status}`);
74
+ }
75
+ const body = (await response.json());
76
+ if (typeof body.version !== "string") {
77
+ throw new Error("npm registry response did not include a version");
78
+ }
79
+ return body.version;
80
+ }
59
81
  /**
60
82
  * Checks whether a newer CLI version is available.
61
83
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {