@get-dx/cli 0.3.8 → 0.4.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,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@get-dx/cli",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "DX CLI",
5
5
  "author": "DX <developers@getdx.com>",
6
6
  "bugs": {