@get-dx/cli 0.7.0 → 0.9.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,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.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {