@get-dx/cli 0.4.0 → 0.5.1

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/cli.js CHANGED
@@ -11,6 +11,7 @@ import { workflowsCommand } from "./commands/workflows.js";
11
11
  import { handleError } from "./commandHelpers.js";
12
12
  import cliPackage from "../package.json" with { type: "json" };
13
13
  import { handleTemporaryBaseUrlMigration } from "./config.js";
14
+ import { checkForNewVersion, performUpdate, promptVersionUpdate, } from "./versionCheck.js";
14
15
  export async function run(argv = process.argv) {
15
16
  try {
16
17
  handleTemporaryBaseUrlMigration(argv);
@@ -19,7 +20,20 @@ export async function run(argv = process.argv) {
19
20
  program.outputHelp();
20
21
  return;
21
22
  }
23
+ let versionCheckResult = { shouldUpdate: false };
24
+ try {
25
+ const versionStatus = await checkForNewVersion(argv);
26
+ if (versionStatus.status === "available") {
27
+ versionCheckResult = await promptVersionUpdate(versionStatus.latestVersion);
28
+ }
29
+ }
30
+ catch {
31
+ // Never let a failure with version check or upgrade prompt block a command
32
+ }
22
33
  await program.parseAsync(argv);
34
+ if (versionCheckResult.shouldUpdate && versionCheckResult.latestVersion) {
35
+ await performUpdate(versionCheckResult.latestVersion);
36
+ }
23
37
  }
24
38
  catch (error) {
25
39
  handleError(error, undefined, argv);
@@ -2,7 +2,7 @@ import { password, select } from "@inquirer/prompts";
2
2
  import { Command } from "commander";
3
3
  import { deleteToken, setToken } from "../secrets.js";
4
4
  import { renderJson } from "../renderers.js";
5
- import { renderAuthInfo, renderLoggedOut } from "./authRendering.js";
5
+ import { renderAuthInfo, renderAuthWhoami, renderLoggedOut, } from "./authRendering.js";
6
6
  import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
7
7
  import { persistBaseUrls, resolveApiBaseUrl, deriveBaseUrlsFromEnv, } from "../config.js";
8
8
  import { CliError } from "../errors.js";
@@ -51,12 +51,12 @@ export function authCommand() {
51
51
  }
52
52
  else {
53
53
  token = await password({
54
- message: "Paste your account web API token here:",
54
+ message: "Paste your personal access token or organization token here:",
55
55
  mask: true,
56
56
  });
57
57
  }
58
58
  if (!token) {
59
- throw new CliError("Account web API token or personal access token is required");
59
+ throw new CliError("Personal access token or organization token is required");
60
60
  }
61
61
  }
62
62
  const runtime = await buildRuntime(context, {
@@ -128,6 +128,29 @@ export function authCommand() {
128
128
  renderAuthInfo(response, runtime.token, runtime.apiBaseUrl);
129
129
  }
130
130
  }));
131
+ auth
132
+ .command("whoami")
133
+ .description("Show user and team details for the authenticated token")
134
+ .addHelpText("afterAll", createExampleText([
135
+ {
136
+ label: "Fetch user and team details",
137
+ command: "dx auth whoami",
138
+ },
139
+ {
140
+ label: "Fetch user and team details as JSON",
141
+ command: "dx auth whoami --json",
142
+ },
143
+ ]))
144
+ .action(wrapAction(async (_options, command) => {
145
+ const runtime = await buildRuntime(getContext(command));
146
+ const response = await getAuthWhoami(runtime);
147
+ if (runtime.context.json) {
148
+ renderJson(response);
149
+ }
150
+ else {
151
+ renderAuthWhoami(response);
152
+ }
153
+ }));
131
154
  return auth;
132
155
  }
133
156
  // TODO: should we move this somewhere more central, since `init` needs it?
@@ -137,3 +160,9 @@ export async function getAuthInfo(runtime) {
137
160
  });
138
161
  return response.body;
139
162
  }
163
+ export async function getAuthWhoami(runtime) {
164
+ const response = await request(runtime, "/auth.whoami", {
165
+ method: "GET",
166
+ });
167
+ return response.body;
168
+ }
@@ -37,10 +37,51 @@ function scopesContent(scopes) {
37
37
  ];
38
38
  }
39
39
  }
40
+ export function renderAuthWhoami(response) {
41
+ const blocks = [];
42
+ // Auth section
43
+ blocks.push(ui.h3("Auth"));
44
+ blocks.push(ui.dl([
45
+ ui.dli("Account", response.account.name),
46
+ ui.dli("Token type", tokenTypeName(response.auth_token_type)),
47
+ ], { termWidth: 12 }));
48
+ // User section
49
+ blocks.push(ui.h3("Current user"));
50
+ if (response.user) {
51
+ blocks.push(ui.dl([
52
+ ui.dli("ID", ui.code(response.user.id)),
53
+ ui.dli("Name", response.user.name),
54
+ ui.dli("Email", response.user.email),
55
+ ], { termWidth: 7 }));
56
+ }
57
+ else {
58
+ blocks.push(ui.p(ui.dim("Not available for organization tokens")));
59
+ }
60
+ // Team section
61
+ blocks.push(ui.h3("Team"));
62
+ if (response.team) {
63
+ blocks.push(ui.dl([
64
+ ui.dli("ID", ui.code(response.team.id)),
65
+ ui.dli("Name", response.team.name),
66
+ ui.dli("Lead", formatUser(response.team.lead)),
67
+ ], { termWidth: 6 }));
68
+ if (response.team.contributors.length > 0) {
69
+ blocks.push(ui.p(ui.bold("Contributors:"), false));
70
+ blocks.push(ui.ul(response.team.contributors.map((c) => ui.li(formatUser(c)))));
71
+ }
72
+ }
73
+ else {
74
+ blocks.push(ui.p(ui.dim("Not available for organization tokens")));
75
+ }
76
+ renderRichText(blocks);
77
+ }
78
+ function formatUser(user) {
79
+ return `${user.name} ${ui.dim(`(${user.id}, ${user.email})`)}`;
80
+ }
40
81
  function tokenTypeName(tokenType) {
41
82
  switch (tokenType) {
42
83
  case "account_web_api_token":
43
- return "Account-level web API token";
84
+ return "Organization token";
44
85
  case "personal_access_token":
45
86
  return "Personal access token";
46
87
  default:
@@ -1,6 +1,3 @@
1
- import { access } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
1
  import { password, confirm, select } from "@inquirer/prompts";
5
2
  import { Command } from "commander";
6
3
  import { execa } from "execa";
@@ -13,6 +10,7 @@ import { renderRichText } from "../renderers.js";
13
10
  import * as ui from "../ui.js";
14
11
  import { CliError } from "../errors.js";
15
12
  import { deriveBaseUrlsFromEnv, persistBaseUrls } from "../config.js";
13
+ import { isSkillInstalled } from "../skill.js";
16
14
  import { setToken } from "../secrets.js";
17
15
  import { renderWelcomeAnimation } from "../welcomeAnimation.js";
18
16
  export function initCommand() {
@@ -85,12 +83,12 @@ async function attemptLogin(apiBaseUrl, webBaseUrl, context) {
85
83
  }
86
84
  else {
87
85
  token = await password({
88
- message: "Paste your account web API token here:",
86
+ message: "Paste your personal access token or organization token here:",
89
87
  mask: true,
90
88
  });
91
89
  }
92
90
  if (!token) {
93
- throw new CliError("Account web API token is required");
91
+ throw new CliError("Personal access token or organization token is required");
94
92
  }
95
93
  const runtime = await buildRuntime(context, {
96
94
  apiBaseUrl,
@@ -113,10 +111,7 @@ async function optionallySetupSkill(runtime) {
113
111
  ui.h1(`Checking for the DX skill...`),
114
112
  ui.blankLine(),
115
113
  ]);
116
- const skillPath = join(homedir(), ".agents", "skills", "dx-cli");
117
- const skillInstalled = await access(skillPath)
118
- .then(() => true)
119
- .catch(() => false);
114
+ const skillInstalled = await isSkillInstalled();
120
115
  if (skillInstalled) {
121
116
  renderRichText([ui.p(`The DX skill is already installed.`)]);
122
117
  runtime.logger.debug("To update: npx skills update dx-cli --global");
package/dist/config.js CHANGED
@@ -15,17 +15,36 @@ export function readConfig() {
15
15
  }
16
16
  const content = fs.readFileSync(configPath, "utf8");
17
17
  const raw = JSON.parse(content);
18
- const api = raw.apiBaseUrl;
19
- const web = raw.webBaseUrl;
20
18
  const stored = {};
21
- if (web) {
22
- stored.webBaseUrl = web;
19
+ if (typeof raw.webBaseUrl === "string") {
20
+ stored.webBaseUrl = raw.webBaseUrl;
21
+ }
22
+ if (typeof raw.apiBaseUrl === "string") {
23
+ stored.apiBaseUrl = raw.apiBaseUrl;
24
+ }
25
+ if (raw.currentVersionCache && typeof raw.currentVersionCache === "object") {
26
+ stored.currentVersionCache = raw.currentVersionCache;
23
27
  }
24
- if (api) {
25
- stored.apiBaseUrl = api;
28
+ if (raw.versionPromptSelection &&
29
+ typeof raw.versionPromptSelection === "object") {
30
+ stored.versionPromptSelection =
31
+ raw.versionPromptSelection;
26
32
  }
27
33
  return stored;
28
34
  }
35
+ export function persistCurrentVersionCache(cache) {
36
+ writeConfig({ ...readConfig(), currentVersionCache: cache });
37
+ }
38
+ export function persistVersionPromptSelection(prompt) {
39
+ const config = readConfig();
40
+ if (prompt === undefined) {
41
+ delete config.versionPromptSelection;
42
+ }
43
+ else {
44
+ config.versionPromptSelection = prompt;
45
+ }
46
+ writeConfig(config);
47
+ }
29
48
  /**
30
49
  * Responsible for handling the migration from `baseUrl` to `apiBaseUrl` and `webBaseUrl`,
31
50
  * so the upgrade to 0.3.x will be non-breaking for `cloud` and `dedicated` deployments.
package/dist/skill.js ADDED
@@ -0,0 +1,9 @@
1
+ import { access } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export async function isSkillInstalled() {
5
+ const skillPath = join(homedir(), ".agents", "skills", "dx-cli");
6
+ return access(skillPath)
7
+ .then(() => true)
8
+ .catch(() => false);
9
+ }
@@ -0,0 +1,203 @@
1
+ import { select } from "@inquirer/prompts";
2
+ import dayjs from "dayjs";
3
+ import { execa } from "execa";
4
+ import { persistCurrentVersionCache, persistVersionPromptSelection, readConfig, } from "./config.js";
5
+ import { request } from "./http.js";
6
+ import { renderRichText } from "./renderers.js";
7
+ import { buildRuntimeSafe } from "./runtime.js";
8
+ import { isSkillInstalled } from "./skill.js";
9
+ import * as ui from "./ui.js";
10
+ import cliPackage from "../package.json" with { type: "json" };
11
+ const VERSION_CHECK_INTERVAL_HOURS = 24;
12
+ const SNOOZE_DAYS = 7;
13
+ const SKIP_COMMANDS = new Set(["auth", "init"]);
14
+ /**
15
+ * Compares two semver strings (e.g. "1.2.3").
16
+ * Returns a negative number if a < b, 0 if equal, positive if a > b.
17
+ */
18
+ export function compareVersions(a, b) {
19
+ const parse = (v) => v.split(".").map((n) => parseInt(n, 10));
20
+ const [aMajor = 0, aMinor = 0, aPatch = 0] = parse(a);
21
+ const [bMajor = 0, bMinor = 0, bPatch = 0] = parse(b);
22
+ return aMajor - bMajor || aMinor - bMinor || aPatch - bPatch;
23
+ }
24
+ /**
25
+ * Returns true if a version fetch should be attempted — i.e. no cached result
26
+ * exists or the cached result is older than VERSION_CHECK_INTERVAL_HOURS.
27
+ */
28
+ export function shouldPerformVersionCheck(config) {
29
+ const cache = config.currentVersionCache;
30
+ if (!cache?.updatedAt) {
31
+ return true;
32
+ }
33
+ const age = dayjs().diff(dayjs(cache.updatedAt), "hour");
34
+ return age >= VERSION_CHECK_INTERVAL_HOURS;
35
+ }
36
+ /**
37
+ * Returns true if the upgrade prompt should be shown to the user, taking into
38
+ * account any previously persisted SKIP or SNOOZE state.
39
+ */
40
+ export function shouldShowVersionPrompt(latestVersion, versionPromptSelection) {
41
+ if (!versionPromptSelection) {
42
+ return true;
43
+ }
44
+ if (versionPromptSelection.type === "SKIP" &&
45
+ versionPromptSelection.skipVersion === latestVersion) {
46
+ return false;
47
+ }
48
+ if (versionPromptSelection.type === "SNOOZE" &&
49
+ dayjs().isBefore(dayjs(versionPromptSelection.snoozeUntil))) {
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+ async function fetchLatestVersion(runtime) {
55
+ const response = await request(runtime, "/cli.currentVersion", { method: "GET" });
56
+ return response.body.versions.cli;
57
+ }
58
+ /**
59
+ * Checks whether a newer CLI version is available.
60
+ *
61
+ * Returns:
62
+ * "available" — a newer version exists and the user should be prompted
63
+ * "up-to-date" — the installed version is current (or the user has snoozed/skipped)
64
+ * "disabled" — the check was skipped or could not run (env var, skip command, no token, network error)
65
+ */
66
+ export async function checkForNewVersion(argv) {
67
+ if (process.env.DX_DISABLE_VERSION_CHECK) {
68
+ return { status: "disabled" };
69
+ }
70
+ const topLevelCommand = argv.slice(2).find((arg) => !arg.startsWith("-"));
71
+ if (topLevelCommand && SKIP_COMMANDS.has(topLevelCommand)) {
72
+ return { status: "disabled" };
73
+ }
74
+ const context = {
75
+ json: argv.includes("--json"),
76
+ agent: undefined,
77
+ agentSessionId: undefined,
78
+ };
79
+ const runtime = await buildRuntimeSafe(context);
80
+ if (!runtime) {
81
+ return { status: "disabled" };
82
+ }
83
+ const config = readConfig();
84
+ let latestVersion;
85
+ if (!shouldPerformVersionCheck(config)) {
86
+ latestVersion = config.currentVersionCache.contents.cli;
87
+ }
88
+ else {
89
+ try {
90
+ latestVersion = await fetchLatestVersion(runtime);
91
+ }
92
+ catch {
93
+ return { status: "disabled" };
94
+ }
95
+ persistCurrentVersionCache({
96
+ updatedAt: new Date().toISOString(),
97
+ contents: { cli: latestVersion },
98
+ });
99
+ }
100
+ if (compareVersions(latestVersion, cliPackage.version) <= 0) {
101
+ return { status: "up-to-date" };
102
+ }
103
+ if (!shouldShowVersionPrompt(latestVersion, readConfig().versionPromptSelection)) {
104
+ return { status: "up-to-date" };
105
+ }
106
+ return { status: "available", latestVersion };
107
+ }
108
+ /**
109
+ * Notifies the user that a new version is available and, in interactive
110
+ * sessions, prompts them to update, snooze, or skip.
111
+ *
112
+ * Returns a result indicating whether the user chose to update immediately,
113
+ * so the caller can invoke performUpdate() after the command finishes.
114
+ */
115
+ export async function promptVersionUpdate(latestVersion) {
116
+ const noUpdate = { shouldUpdate: false };
117
+ const currentVersion = cliPackage.version;
118
+ const isInteractive = process.stdin.isTTY && process.stderr.isTTY;
119
+ if (!isInteractive) {
120
+ renderRichText([
121
+ ui.blankLine(),
122
+ ui.p(ui.warning(`A new version of the DX CLI is available: ${currentVersion} → ${latestVersion}`)),
123
+ ui.p(`Run: ${ui.code(`npm install -g @get-dx/cli@${latestVersion}`)}`),
124
+ ui.blankLine(),
125
+ ], { useStderr: true });
126
+ return noUpdate;
127
+ }
128
+ renderRichText([
129
+ ui.blankLine(),
130
+ ui.p(ui.warning(`A new version of the DX CLI is available: ${currentVersion} → ${latestVersion}`)),
131
+ ui.blankLine(),
132
+ ], { useStderr: true });
133
+ const choice = await select({
134
+ message: "What would you like to do?",
135
+ choices: [
136
+ { name: "Update now", value: "update" },
137
+ { name: `Remind me later (in ${SNOOZE_DAYS} days)`, value: "snooze" },
138
+ { name: "Skip this version", value: "skip" },
139
+ ],
140
+ });
141
+ if (choice === "update") {
142
+ persistVersionPromptSelection(undefined);
143
+ return { shouldUpdate: true, latestVersion };
144
+ }
145
+ if (choice === "snooze") {
146
+ persistVersionPromptSelection({
147
+ type: "SNOOZE",
148
+ snoozeUntil: dayjs().add(SNOOZE_DAYS, "day").toISOString(),
149
+ });
150
+ }
151
+ else {
152
+ persistVersionPromptSelection({ type: "SKIP", skipVersion: latestVersion });
153
+ }
154
+ return noUpdate;
155
+ }
156
+ /**
157
+ * Performs the actual CLI + skill upgrade after the user's command has finished.
158
+ */
159
+ export async function performUpdate(latestVersion) {
160
+ if (await isSkillInstalled()) {
161
+ renderRichText([
162
+ ui.blankLine(),
163
+ ui.p(ui.bold("Updating the DX skill.")),
164
+ ui.p("This will run the following command:", false),
165
+ ui.codeBlock("npx --yes -- skills@latest update dx-cli --global"),
166
+ ui.blankLine(),
167
+ ], {
168
+ useStderr: true,
169
+ });
170
+ try {
171
+ await execa({
172
+ stdout: "inherit",
173
+ stderr: "inherit",
174
+ stdin: "inherit",
175
+ }) `npx --yes -- skills@latest update dx-cli --global`;
176
+ }
177
+ catch {
178
+ renderRichText([
179
+ ui.p(ui.warning("Warning: failed to update the DX skill. You can update it manually with: " +
180
+ ui.code("npx skills update dx-cli --global"))),
181
+ ], { useStderr: true });
182
+ }
183
+ }
184
+ renderRichText([
185
+ ui.blankLine(),
186
+ ui.p(ui.bold(`Updating the DX CLI to ${latestVersion}.`)),
187
+ ui.p("This will run the following command:", false),
188
+ ui.codeBlock(`npm install -g @get-dx/cli@${latestVersion}`),
189
+ ui.blankLine(),
190
+ ], { useStderr: true });
191
+ try {
192
+ await execa({
193
+ stdout: "inherit",
194
+ stderr: "inherit",
195
+ }) `npm install -g @get-dx/cli@${latestVersion}`;
196
+ }
197
+ catch {
198
+ renderRichText([
199
+ ui.p(ui.warning(`Warning: failed to update the DX CLI. You can update it manually with: ` +
200
+ ui.code(`npm install -g @get-dx/cli@${latestVersion}`))),
201
+ ], { useStderr: true });
202
+ }
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {