@get-dx/cli 0.3.8 → 0.5.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/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);
@@ -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() {
@@ -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");
@@ -0,0 +1,49 @@
1
+ # Data Studio report configuration
2
+ #
3
+ # Edit this file, then run:
4
+ #
5
+ # ```
6
+ # dx studio reports create --from-file <path>
7
+ # ```
8
+
9
+ name: ""
10
+ owner_email: ""
11
+ description: ""
12
+ markdown_notes: ""
13
+
14
+ # view_access_type: owner_and_direct_url_only, specific_users, or everyone
15
+ view_access_type: owner_and_direct_url_only
16
+ # Required only when view_access_type is specific_users
17
+ viewer_emails: []
18
+
19
+ # edit_access_type: read_only, owner_only, specific_users, or everyone
20
+ edit_access_type: read_only
21
+ # Required only when edit_access_type is specific_users
22
+ editor_emails: []
23
+
24
+ # Supported chart_type values: line, pie, stacked_bar, scatter, table
25
+ tiles:
26
+ - title: ""
27
+ sql: |-
28
+ SELECT 1 AS value
29
+ chart_type: table
30
+ chart_config: {}
31
+
32
+ # Line, stacked_bar, and scatter tiles require xAxis and yAxes.
33
+ # - title: ""
34
+ # sql: |-
35
+ # SELECT CURRENT_DATE AS week_start, 1 AS value
36
+ # chart_type: line
37
+ # chart_config:
38
+ # xAxis: week_start
39
+ # yAxes:
40
+ # - value
41
+
42
+ # Pie tiles require labelColumn and valueColumn.
43
+ # - title: ""
44
+ # sql: |-
45
+ # SELECT 'Example' AS label, 1 AS value
46
+ # chart_type: pie
47
+ # chart_config:
48
+ # labelColumn: label
49
+ # valueColumn: value
@@ -0,0 +1,399 @@
1
+ import fs from "fs";
2
+ import { Command } from "commander";
3
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import { createExampleText, getContext, parsePositiveIntOption, wrapAction, } from "../../commandHelpers.js";
5
+ import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
6
+ import { request } from "../../http.js";
7
+ import { renderJson, renderRichText } from "../../renderers.js";
8
+ import { buildRuntime } from "../../runtime.js";
9
+ import * as ui from "../../ui.js";
10
+ export function reportsCommand() {
11
+ const reports = new Command()
12
+ .name("reports")
13
+ .description("Manage Data Studio reports");
14
+ reports
15
+ .command("create")
16
+ .description("Create a Data Studio report from a YAML file or stdin")
17
+ .option("--from-file <path>", "Read a YAML file and create a report from its contents")
18
+ .option("--from-stdin", "Read YAML from stdin and create a report from its contents")
19
+ .addHelpText("afterAll", createExampleText([
20
+ {
21
+ label: "Create a studio report from a YAML file",
22
+ command: "dx studio reports create --from-file ./my-report.yaml",
23
+ },
24
+ {
25
+ label: "Create a studio report from stdin",
26
+ command: "cat ./my-report.yaml | dx studio reports create --from-stdin",
27
+ },
28
+ ]))
29
+ .action(wrapAction(async (options, command) => {
30
+ const modeCount = [options.fromFile, options.fromStdin].filter(Boolean).length;
31
+ if (modeCount === 0) {
32
+ throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
33
+ }
34
+ if (modeCount > 1) {
35
+ throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
36
+ }
37
+ const runtime = await buildRuntime(getContext(command));
38
+ const raw = options.fromFile
39
+ ? readYamlFile(options.fromFile)
40
+ : await readYamlStdin();
41
+ const payload = buildCreateReportPayload(raw);
42
+ const response = await createStudioReport(runtime, payload);
43
+ if (runtime.context.json) {
44
+ renderJson(response);
45
+ }
46
+ else {
47
+ renderStudioReportCreated(response.report);
48
+ }
49
+ }));
50
+ reports
51
+ .command("info")
52
+ .description("Retrieve details for an individual Data Studio report")
53
+ .argument("<id>", "Studio report ID")
54
+ .addHelpText("afterAll", createExampleText([
55
+ {
56
+ label: "Fetch info for a studio report",
57
+ command: "dx studio reports info s4525phi3dud",
58
+ },
59
+ {
60
+ label: "Fetch studio report info as JSON",
61
+ command: "dx --json studio reports info s4525phi3dud",
62
+ },
63
+ ]))
64
+ .action(wrapAction(async (id, _options, command) => {
65
+ const runtime = await buildRuntime(getContext(command));
66
+ const response = await getStudioReportInfo(runtime, id);
67
+ if (runtime.context.json) {
68
+ renderJson(response);
69
+ }
70
+ else {
71
+ renderStudioReportInfo(response.report);
72
+ }
73
+ }));
74
+ reports
75
+ .command("init")
76
+ .description("Write a Data Studio report YAML file, either from an existing report or as a blank template")
77
+ .argument("<path>", "File path to write the YAML to")
78
+ .option("--id <id>", "Fetch an existing report and use it as the template")
79
+ .addHelpText("afterAll", createExampleText([
80
+ {
81
+ label: "Write a blank studio report template",
82
+ command: "dx studio reports init ./my-report.yaml",
83
+ },
84
+ {
85
+ label: "Initialize from an existing studio report",
86
+ command: "dx studio reports init ./my-report.yaml --id s4525phi3dud",
87
+ },
88
+ {
89
+ label: "Create a studio report from the template",
90
+ command: "dx studio reports create --from-file ./my-report.yaml",
91
+ },
92
+ ]))
93
+ .action(wrapAction(async (path, options, command) => {
94
+ const runtime = await buildRuntime(getContext(command));
95
+ if (options.id) {
96
+ const id = options.id;
97
+ let reportResponse;
98
+ try {
99
+ reportResponse = await getStudioReportInfo(runtime, id);
100
+ }
101
+ catch (err) {
102
+ const exitCode = err instanceof HttpError &&
103
+ err.status !== undefined &&
104
+ err.status < 500
105
+ ? EXIT_CODES.ARGUMENT_ERROR
106
+ : EXIT_CODES.RETRY_RECOMMENDED;
107
+ throw new CliError(`Failed to fetch studio report "${id}": ${err instanceof Error ? err.message : String(err)}`, exitCode);
108
+ }
109
+ fs.writeFileSync(path, studioReportToYaml(reportResponse.report), "utf8");
110
+ if (runtime.context.json) {
111
+ renderJson({ ok: true, id, path });
112
+ }
113
+ else {
114
+ renderRichText([
115
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Studio report template written to ${ui.code(path)}.`),
116
+ ui.p(`Edit the file, then run: ${ui.code(`dx studio reports create --from-file ${path}`)}`),
117
+ ]);
118
+ }
119
+ }
120
+ else {
121
+ fs.writeFileSync(path, STUDIO_REPORT_BLANK_TEMPLATE_YAML, "utf8");
122
+ if (runtime.context.json) {
123
+ renderJson({ ok: true, path });
124
+ }
125
+ else {
126
+ renderRichText([
127
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Blank template written to ${ui.code(path)}.`),
128
+ ui.p(`Edit the file, then run: ${ui.code(`dx studio reports create --from-file ${path}`)}`),
129
+ ]);
130
+ }
131
+ }
132
+ }));
133
+ reports
134
+ .command("list")
135
+ .description("List Data Studio reports")
136
+ .option("--cursor <cursor>", "Cursor for the next page of results")
137
+ .option("--limit <n>", "Max reports per page (default is 50, max is 100)", (value) => parseLimitOption(value, "--limit"))
138
+ .option("--search-term <term>", "Search reports by name")
139
+ .addHelpText("afterAll", createExampleText([
140
+ {
141
+ label: "List studio reports",
142
+ command: "dx studio reports list",
143
+ },
144
+ {
145
+ label: "List studio reports as JSON",
146
+ command: "dx --json studio reports list",
147
+ },
148
+ {
149
+ label: "Search studio reports by name",
150
+ command: "dx studio reports list --search-term deployment",
151
+ },
152
+ {
153
+ label: "Fetch the next page using a cursor from the prior response",
154
+ command: "dx studio reports list --cursor rpt_123 --limit 100",
155
+ },
156
+ ]))
157
+ .action(wrapAction(async (options, command) => {
158
+ const runtime = await buildRuntime(getContext(command));
159
+ const response = await listStudioReports(runtime, {
160
+ cursor: parseOptionalTextOption(options.cursor),
161
+ limit: options.limit,
162
+ search_term: parseOptionalTextOption(options.searchTerm),
163
+ });
164
+ if (runtime.context.json) {
165
+ renderJson(response);
166
+ }
167
+ else {
168
+ renderStudioReports(response);
169
+ }
170
+ }));
171
+ reports
172
+ .command("update")
173
+ .description("Update a Data Studio report from a YAML file or stdin. The `init` command can be used to initialize the report file.")
174
+ .argument("<id>", "Studio report ID")
175
+ .option("--from-file <path>", "Read a YAML file and update the report with its contents")
176
+ .option("--from-stdin", "Read YAML from stdin and update the report with its contents")
177
+ .addHelpText("afterAll", createExampleText([
178
+ {
179
+ label: "Initialize a file first",
180
+ command: "dx studio reports init ./my-report.yaml --id s4525phi3dud",
181
+ },
182
+ {
183
+ label: "Update a studio report from a YAML file",
184
+ command: "dx studio reports update s4525phi3dud --from-file ./my-report.yaml",
185
+ },
186
+ {
187
+ label: "Update a studio report from stdin",
188
+ command: "cat ./my-report.yaml | dx studio reports update s4525phi3dud --from-stdin",
189
+ },
190
+ ]))
191
+ .action(wrapAction(async (id, options, command) => {
192
+ const modeCount = [options.fromFile, options.fromStdin].filter(Boolean).length;
193
+ if (modeCount === 0) {
194
+ throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
195
+ }
196
+ if (modeCount > 1) {
197
+ throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
198
+ }
199
+ const runtime = await buildRuntime(getContext(command));
200
+ const raw = options.fromFile
201
+ ? readYamlFile(options.fromFile)
202
+ : await readYamlStdin();
203
+ const payload = buildUpdateReportPayload(id, raw);
204
+ const response = await updateStudioReport(runtime, payload);
205
+ if (runtime.context.json) {
206
+ renderJson(response);
207
+ }
208
+ else {
209
+ renderStudioReportUpdated(response.report);
210
+ }
211
+ }));
212
+ return reports;
213
+ }
214
+ async function createStudioReport(runtime, payload) {
215
+ const response = await request(runtime, "/studio.reports.create", {
216
+ method: "POST",
217
+ body: payload,
218
+ });
219
+ return response.body;
220
+ }
221
+ async function updateStudioReport(runtime, payload) {
222
+ const response = await request(runtime, "/studio.reports.update", {
223
+ method: "POST",
224
+ body: payload,
225
+ });
226
+ return response.body;
227
+ }
228
+ async function getStudioReportInfo(runtime, id) {
229
+ const response = await request(runtime, "/studio.reports.info", {
230
+ method: "GET",
231
+ query: { id },
232
+ });
233
+ return response.body;
234
+ }
235
+ async function listStudioReports(runtime, options) {
236
+ const response = await request(runtime, "/studio.reports.list", {
237
+ method: "GET",
238
+ query: options,
239
+ });
240
+ return response.body;
241
+ }
242
+ function renderStudioReportCreated(report) {
243
+ renderRichText([
244
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Studio report created`),
245
+ renderStudioReport(report),
246
+ ]);
247
+ }
248
+ function renderStudioReportInfo(report) {
249
+ renderRichText([ui.h1("Studio Report"), renderStudioReport(report)]);
250
+ }
251
+ function renderStudioReportUpdated(report) {
252
+ renderRichText([
253
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Studio report updated`),
254
+ renderStudioReport(report),
255
+ ]);
256
+ }
257
+ function renderStudioReports(response) {
258
+ const blocks = [ui.h1("Studio Reports")];
259
+ blocks.push(ui.p(`Displaying ${ui.bold(response.reports.length.toString())} reports.`));
260
+ if (response.reports.length === 0) {
261
+ blocks.push(ui.p(ui.dim("(None)")));
262
+ }
263
+ for (const report of response.reports) {
264
+ blocks.push(...renderStudioReport(report));
265
+ }
266
+ const nextCursor = response.response_metadata?.next_cursor;
267
+ if (nextCursor) {
268
+ blocks.push(ui.p(`Next cursor: ${ui.code(nextCursor)} ${ui.dim(`(use --cursor ${nextCursor})`)}`));
269
+ }
270
+ renderRichText(blocks);
271
+ }
272
+ function renderStudioReport(report) {
273
+ const blocks = [
274
+ ui.h2(`${formatReportName(report)} (${ui.code(report.id)})`),
275
+ ui.dl([
276
+ ui.dli("URL", ui.link(report.url)),
277
+ ui.dli("Description", formatOptionalText(report.description)),
278
+ ui.dli("View access", formatViewAccessType(report.view_access_type)),
279
+ ui.dli("Edit access", formatEditAccessType(report.edit_access_type)),
280
+ ui.dli("Tiles", report.tiles.length.toString()),
281
+ ui.dli("Created", ui.timestampSummary(report.created_at)),
282
+ ui.dli("Updated", ui.timestampSummary(report.updated_at)),
283
+ ], { termWidth: 13 }),
284
+ ];
285
+ if (report.tiles.length > 0) {
286
+ blocks.push(ui.h3("Tiles"), ui.ul(report.tiles.map((tile) => ui.li(`${formatTileTitle(tile)} ${ui.dim(`(${tile.chart_type})`)}`))));
287
+ }
288
+ return blocks;
289
+ }
290
+ function parseOptionalTextOption(value) {
291
+ if (typeof value !== "string") {
292
+ return undefined;
293
+ }
294
+ const trimmed = value.trim();
295
+ return trimmed.length > 0 ? trimmed : undefined;
296
+ }
297
+ function parseLimitOption(value, flag) {
298
+ const limit = parsePositiveIntOption(value, flag);
299
+ if (limit > 100) {
300
+ throw new CliError(`${flag} must be at most 100`, EXIT_CODES.ARGUMENT_ERROR);
301
+ }
302
+ return limit;
303
+ }
304
+ function readYamlFile(filePath) {
305
+ let content;
306
+ try {
307
+ content = fs.readFileSync(filePath, "utf8");
308
+ }
309
+ catch (err) {
310
+ throw new CliError(`Could not read file "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
311
+ }
312
+ return parseYaml(content);
313
+ }
314
+ async function readYamlStdin() {
315
+ return new Promise((resolve, reject) => {
316
+ const chunks = [];
317
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
318
+ process.stdin.on("end", () => {
319
+ const content = Buffer.concat(chunks).toString("utf8");
320
+ try {
321
+ resolve(parseYaml(content));
322
+ }
323
+ catch (err) {
324
+ reject(new CliError(`Could not parse YAML from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR));
325
+ }
326
+ });
327
+ process.stdin.on("error", reject);
328
+ });
329
+ }
330
+ function buildCreateReportPayload(raw) {
331
+ const { id: _id, ...rest } = parseYamlObject(raw);
332
+ return rest;
333
+ }
334
+ function buildUpdateReportPayload(id, raw) {
335
+ return { ...parseYamlObject(raw), id };
336
+ }
337
+ function parseYamlObject(raw) {
338
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
339
+ throw new CliError("YAML content must be an object", EXIT_CODES.ARGUMENT_ERROR);
340
+ }
341
+ return raw;
342
+ }
343
+ const STUDIO_REPORT_BLANK_TEMPLATE_YAML = fs.readFileSync(new URL("./report-blank-template.yaml", import.meta.url), "utf8");
344
+ function studioReportToYaml(report) {
345
+ const payload = {
346
+ name: report.name ?? "",
347
+ owner_email: "",
348
+ description: report.description ?? "",
349
+ markdown_notes: report.markdown_notes ?? "",
350
+ view_access_type: report.view_access_type,
351
+ viewer_emails: [],
352
+ edit_access_type: report.edit_access_type,
353
+ editor_emails: [],
354
+ tiles: report.tiles.map((tile) => ({
355
+ title: tile.title,
356
+ sql: tile.sql,
357
+ chart_type: tile.chart_type,
358
+ chart_config: tile.chart_config,
359
+ })),
360
+ };
361
+ return stringifyYaml(payload, { blockQuote: "literal" });
362
+ }
363
+ function formatReportName(report) {
364
+ return report.name && report.name.trim().length > 0
365
+ ? report.name
366
+ : ui.dim("(Untitled)");
367
+ }
368
+ function formatTileTitle(tile) {
369
+ return tile.title && tile.title.trim().length > 0
370
+ ? tile.title
371
+ : ui.dim("(Untitled tile)");
372
+ }
373
+ function formatOptionalText(value) {
374
+ return value && value.trim().length > 0 ? value : ui.dim("(None)");
375
+ }
376
+ function formatViewAccessType(value) {
377
+ switch (value) {
378
+ case "owner_and_direct_url_only":
379
+ return "Visible via direct URL";
380
+ case "specific_users":
381
+ return "Visible to specific users";
382
+ case "everyone":
383
+ return "Visible to everyone";
384
+ default:
385
+ return value;
386
+ }
387
+ }
388
+ function formatEditAccessType(value) {
389
+ switch (value) {
390
+ case "everyone":
391
+ return "Editable by everyone";
392
+ case "specific_users":
393
+ return "Editable by specific users";
394
+ case "owner_only":
395
+ return "Editable by owner only";
396
+ default:
397
+ return value;
398
+ }
399
+ }
@@ -1,9 +1,11 @@
1
1
  import { Command } from "commander";
2
2
  import { queryCommand } from "./studio/query.js";
3
+ import { reportsCommand } from "./studio/reports.js";
3
4
  export function studioCommand() {
4
5
  const studio = new Command()
5
6
  .name("studio")
6
7
  .description("Perform data studio actions");
7
8
  studio.addCommand(queryCommand());
9
+ studio.addCommand(reportsCommand());
8
10
  return studio;
9
11
  }
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.3.8",
3
+ "version": "0.5.0",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {