@get-dx/cli 0.1.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,480 @@
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
+ import { renderScorecard, renderScorecardList } from "./scorecardsRendering.js";
11
+ export function scorecardsCommand() {
12
+ const scorecards = new Command()
13
+ .name("scorecards")
14
+ .description("Manage scorecards");
15
+ scorecards
16
+ .command("create")
17
+ .description("Create a new scorecard from a YAML file or stdin. The `init` command can be used to generate a starting template.")
18
+ .option("--from-file <path>", "Read a YAML file and create a scorecard from its contents")
19
+ .option("--from-stdin", "Read YAML from stdin and create a scorecard from its contents")
20
+ .addHelpText("afterAll", createExampleText([
21
+ {
22
+ label: "Generate a blank template first",
23
+ command: "dx scorecards init ./my-scorecard.yaml",
24
+ },
25
+ {
26
+ label: "Create a scorecard from a YAML file",
27
+ command: "dx scorecards create --from-file ./my-scorecard.yaml",
28
+ },
29
+ {
30
+ label: "Create a scorecard from stdin",
31
+ command: "cat ./my-scorecard.yaml | dx scorecards create --from-stdin",
32
+ },
33
+ ]))
34
+ .action(wrapAction(async (options, command) => {
35
+ const modeCount = [options.fromFile, options.fromStdin].filter(Boolean).length;
36
+ if (modeCount === 0) {
37
+ throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
38
+ }
39
+ if (modeCount > 1) {
40
+ throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
41
+ }
42
+ const runtime = buildRuntime(getContext(command));
43
+ let raw;
44
+ if (options.fromFile) {
45
+ raw = readYamlFile(options.fromFile);
46
+ }
47
+ else {
48
+ raw = await readYamlStdin();
49
+ }
50
+ const payload = buildCreatePayload(raw);
51
+ const response = await createScorecard(runtime, payload);
52
+ if (runtime.context.json) {
53
+ renderJson({ ok: true, scorecard: response.scorecard });
54
+ }
55
+ else {
56
+ renderScorecard(response.scorecard, null, `${ui.success(ui.GLYPHS.CHECK)} Scorecard created`);
57
+ }
58
+ }));
59
+ scorecards
60
+ .command("delete")
61
+ .description("Delete a scorecard")
62
+ .argument("<id>", "The unique ID of the scorecard")
63
+ .addHelpText("afterAll", createExampleText([
64
+ {
65
+ label: "Delete a scorecard",
66
+ command: "dx scorecards delete pht2wuldvjcb",
67
+ },
68
+ {
69
+ label: "Delete a scorecard and return JSON",
70
+ command: "dx scorecards delete pht2wuldvjcb --json",
71
+ },
72
+ ]))
73
+ .action(wrapAction(async (id, _options, command) => {
74
+ const runtime = buildRuntime(getContext(command));
75
+ await deleteScorecard(runtime, id);
76
+ if (runtime.context.json) {
77
+ renderJson({ ok: true });
78
+ }
79
+ else {
80
+ renderRichText([
81
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Scorecard deleted`),
82
+ ]);
83
+ }
84
+ }));
85
+ scorecards
86
+ .command("info")
87
+ .description("Retrieve details about a specific scorecard, including its defined levels and checks")
88
+ .argument("<id>", "The unique ID of the scorecard")
89
+ .option("--include <include>", "Show only these comma-separated sections: core, owners, checks")
90
+ .addHelpText("afterAll", createExampleText([
91
+ {
92
+ label: "Fetch info for a scorecard",
93
+ command: "dx scorecards info qjfj1a6cmit4",
94
+ },
95
+ {
96
+ label: "Fetch scorecard info and return as JSON",
97
+ command: "dx scorecards info qjfj1a6cmit4 --json",
98
+ },
99
+ {
100
+ label: "Fetch only the core fields and checks",
101
+ command: "dx scorecards info qjfj1a6cmit4 --include core,checks",
102
+ },
103
+ ]))
104
+ .action(wrapAction(async (id, options, command) => {
105
+ const includeSections = parseScorecardIncludeSections(options.include);
106
+ const runtime = buildRuntime(getContext(command));
107
+ const response = await getScorecard(runtime, id);
108
+ if (runtime.context.json) {
109
+ const processedScorecard = processScorecardIncludes(response.scorecard, includeSections);
110
+ renderJson({ ok: true, scorecard: processedScorecard });
111
+ }
112
+ else {
113
+ renderScorecard(response.scorecard, includeSections);
114
+ }
115
+ }));
116
+ scorecards
117
+ .command("init")
118
+ .description("Write a scorecard YAML file, either from an existing scorecard or as a blank template")
119
+ .argument("<path>", "File path to write the YAML to")
120
+ .option("--id <id>", "Fetch an existing scorecard and use it as the template")
121
+ .addHelpText("afterAll", createExampleText([
122
+ {
123
+ label: "Write a blank scorecard template",
124
+ command: "dx scorecards init ./my-scorecard.yaml",
125
+ },
126
+ {
127
+ label: "Initialize from an existing scorecard",
128
+ command: "dx scorecards init ./my-scorecard.yaml --id qjfj1a6cmit4",
129
+ },
130
+ ]))
131
+ .action(wrapAction(async (path, options, command) => {
132
+ const runtime = buildRuntime(getContext(command));
133
+ if (options.id) {
134
+ const id = options.id;
135
+ let scorecardResponse;
136
+ try {
137
+ scorecardResponse = await getScorecard(runtime, id);
138
+ }
139
+ catch (err) {
140
+ const exitCode = err instanceof HttpError &&
141
+ err.status !== undefined &&
142
+ err.status < 500
143
+ ? EXIT_CODES.ARGUMENT_ERROR
144
+ : EXIT_CODES.RETRY_RECOMMENDED;
145
+ throw new CliError(`Failed to fetch scorecard "${id}": ${err instanceof Error ? err.message : String(err)}`, exitCode);
146
+ }
147
+ const yaml = scorecardToYaml(scorecardResponse.scorecard);
148
+ fs.writeFileSync(path, yaml, "utf8");
149
+ if (runtime.context.json) {
150
+ renderJson({ ok: true, id, path });
151
+ }
152
+ else {
153
+ renderRichText([
154
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Scorecard written to ${ui.code(path)}.`),
155
+ ui.p(`Edit the file, then run: ${ui.code(`dx scorecards update ${id} --from-file ${path}`)}`),
156
+ ]);
157
+ }
158
+ }
159
+ else {
160
+ fs.writeFileSync(path, SCORECARD_BLANK_TEMPLATE_YAML, "utf8");
161
+ if (runtime.context.json) {
162
+ renderJson({ ok: true, path });
163
+ }
164
+ else {
165
+ renderRichText([
166
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Blank template written to ${ui.code(path)}.`),
167
+ ui.p(`Edit the file, then run: ${ui.code(`dx scorecards create --from-file ${path}`)}`),
168
+ ]);
169
+ }
170
+ }
171
+ }));
172
+ scorecards
173
+ .command("list")
174
+ .description("List scorecards")
175
+ .option("--cursor <cursor>", "Cursor for the next page of results")
176
+ .option("--limit <n>", "Max scorecards per page (default is 50)", (value) => parsePositiveIntOption(value, "--limit"))
177
+ .option("--include-unpublished", "Include draft scorecards in addition to published ones")
178
+ .option("--include <include>", "Show only these comma-separated sections: core, owners, checks")
179
+ .addHelpText("afterAll", createExampleText([
180
+ {
181
+ label: "List all published scorecards",
182
+ command: "dx scorecards list",
183
+ },
184
+ {
185
+ label: "List scorecards including drafts",
186
+ command: "dx scorecards list --include-unpublished",
187
+ },
188
+ {
189
+ label: "List with a limit and return JSON",
190
+ command: "dx scorecards list --limit 10 --json",
191
+ },
192
+ {
193
+ label: "Fetch the next page using a cursor from the prior response",
194
+ command: "dx scorecards list --cursor xuvkgfq9t0ty",
195
+ },
196
+ {
197
+ label: "List scorecards showing only core fields",
198
+ command: "dx scorecards list --include core",
199
+ },
200
+ ]))
201
+ .action(wrapAction(async (options, command) => {
202
+ const includeSections = parseScorecardIncludeSections(options.include);
203
+ const runtime = buildRuntime(getContext(command));
204
+ const response = await listScorecards(runtime, {
205
+ cursor: options.cursor,
206
+ limit: options.limit,
207
+ include_unpublished: options.includeUnpublished,
208
+ });
209
+ if (runtime.context.json) {
210
+ const processedScorecards = response.scorecards.map((sc) => processScorecardIncludes(sc, includeSections));
211
+ renderJson({ ...response, scorecards: processedScorecards });
212
+ }
213
+ else {
214
+ renderScorecardList(response.scorecards, response.response_metadata?.next_cursor ?? null, includeSections);
215
+ }
216
+ }));
217
+ scorecards
218
+ .command("update")
219
+ .description("Update an existing scorecard from a YAML file or stdin. The `init` command can be used to initialize the scorecard file.")
220
+ .argument("<id>", "The unique ID of the scorecard")
221
+ .option("--from-file <path>", "Read a YAML file and update the scorecard with its contents")
222
+ .option("--from-stdin", "Read YAML from stdin and update the scorecard with its contents")
223
+ .addHelpText("afterAll", createExampleText([
224
+ {
225
+ label: "Initialize a file first",
226
+ command: "dx scorecards init ./my-scorecard.yaml --id qjfj1a6cmit4",
227
+ },
228
+ {
229
+ label: "Update a scorecard from a YAML file",
230
+ command: "dx scorecards update qjfj1a6cmit4 --from-file ./my-scorecard.yaml",
231
+ },
232
+ {
233
+ label: "Update a scorecard from stdin",
234
+ command: "cat ./my-scorecard.yaml | dx scorecards update qjfj1a6cmit4 --from-stdin",
235
+ },
236
+ ]))
237
+ .action(wrapAction(async (id, options, command) => {
238
+ const modeCount = [options.fromFile, options.fromStdin].filter(Boolean).length;
239
+ if (modeCount === 0) {
240
+ throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
241
+ }
242
+ if (modeCount > 1) {
243
+ throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
244
+ }
245
+ const runtime = buildRuntime(getContext(command));
246
+ let raw;
247
+ if (options.fromFile) {
248
+ raw = readYamlFile(options.fromFile);
249
+ }
250
+ else {
251
+ raw = await readYamlStdin();
252
+ }
253
+ const payload = buildUpdatePayload(id, raw);
254
+ const response = await updateScorecard(runtime, payload);
255
+ if (runtime.context.json) {
256
+ renderJson({ ok: true, scorecard: response.scorecard });
257
+ }
258
+ else {
259
+ renderScorecard(response.scorecard, null, `${ui.success(ui.GLYPHS.CHECK)} Scorecard updated`);
260
+ }
261
+ }));
262
+ return scorecards;
263
+ }
264
+ export async function deleteScorecard(runtime, id) {
265
+ const response = await request(runtime, "/scorecards.delete", {
266
+ method: "POST",
267
+ query: { id },
268
+ });
269
+ return response.body;
270
+ }
271
+ export async function createScorecard(runtime, payload) {
272
+ const response = await request(runtime, "/scorecards.create", {
273
+ method: "POST",
274
+ body: payload,
275
+ });
276
+ return response.body;
277
+ }
278
+ function buildCreatePayload(raw) {
279
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
280
+ throw new CliError("YAML content must be an object", EXIT_CODES.ARGUMENT_ERROR);
281
+ }
282
+ const { id: _id, ...rest } = raw;
283
+ return rest;
284
+ }
285
+ export async function getScorecard(runtime, id) {
286
+ const response = await request(runtime, "/scorecards.info", {
287
+ method: "GET",
288
+ query: { id },
289
+ });
290
+ return response.body;
291
+ }
292
+ export async function listScorecards(runtime, params) {
293
+ const query = {};
294
+ if (params.cursor !== undefined)
295
+ query.cursor = params.cursor;
296
+ if (params.limit !== undefined)
297
+ query.limit = params.limit;
298
+ if (params.include_unpublished)
299
+ query.include_unpublished = true;
300
+ const response = await request(runtime, "/scorecards.list", {
301
+ method: "GET",
302
+ query,
303
+ });
304
+ return response.body;
305
+ }
306
+ export async function updateScorecard(runtime, payload) {
307
+ const response = await request(runtime, "/scorecards.update", {
308
+ method: "POST",
309
+ body: payload,
310
+ });
311
+ return response.body;
312
+ }
313
+ // --- YAML helpers ---
314
+ const SCORECARD_BLANK_TEMPLATE_YAML = fs.readFileSync(new URL("./scorecard-blank-template.yaml", import.meta.url), "utf8");
315
+ /**
316
+ * These keys are ignored when initializing a file to update
317
+ */
318
+ const SCORECARD_IGNORED_KEYS = [
319
+ // Read-only: managed through other parts of the app, not the scorecards API
320
+ "admins",
321
+ // Deprecated fields
322
+ "evaluation_frequency_hours",
323
+ "entity_filter_type_ids",
324
+ // Reporting on state
325
+ "sql_errors",
326
+ "max_entity_points",
327
+ ];
328
+ const SCORECARD_TRANSFORM_KEYS = {
329
+ editors: (value) => value.map((editor) => editor.id),
330
+ tags: (value) => value.map((tag) => ({ value: tag.value })),
331
+ levels: (value) => {
332
+ if (!Array.isArray(value)) {
333
+ return value;
334
+ }
335
+ else {
336
+ return value.map((level) => {
337
+ return {
338
+ ...level,
339
+ key: toSnakeCase(level.name),
340
+ };
341
+ });
342
+ }
343
+ },
344
+ check_groups: (value) => {
345
+ if (!Array.isArray(value)) {
346
+ return value;
347
+ }
348
+ else {
349
+ return value.map((checkGroup) => {
350
+ return {
351
+ ...checkGroup,
352
+ key: toSnakeCase(checkGroup.name),
353
+ };
354
+ });
355
+ }
356
+ },
357
+ checks: (value) => {
358
+ return value.map((check) => {
359
+ const { level, check_group, ...rest } = check;
360
+ const payload = rest;
361
+ if (level) {
362
+ payload.scorecard_level_key = toSnakeCase(level.name);
363
+ }
364
+ else if (check_group) {
365
+ payload.scorecard_check_group_key = toSnakeCase(check_group.name);
366
+ }
367
+ return payload;
368
+ });
369
+ },
370
+ };
371
+ function scorecardToYaml(scorecard) {
372
+ const obj = { ...scorecard };
373
+ for (const key of SCORECARD_IGNORED_KEYS) {
374
+ delete obj[key];
375
+ }
376
+ const objLoose = obj;
377
+ for (const key of Object.keys(SCORECARD_TRANSFORM_KEYS)) {
378
+ const transformFn = SCORECARD_TRANSFORM_KEYS[key];
379
+ if (transformFn) {
380
+ objLoose[key] = transformFn(objLoose[key]);
381
+ }
382
+ }
383
+ return stringifyYaml(obj, { blockQuote: "literal" });
384
+ }
385
+ function readYamlFile(filePath) {
386
+ let content;
387
+ try {
388
+ content = fs.readFileSync(filePath, "utf8");
389
+ }
390
+ catch (err) {
391
+ throw new CliError(`Could not read file "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
392
+ }
393
+ return parseYaml(content);
394
+ }
395
+ async function readYamlStdin() {
396
+ return new Promise((resolve, reject) => {
397
+ const chunks = [];
398
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
399
+ process.stdin.on("end", () => {
400
+ const content = Buffer.concat(chunks).toString("utf8");
401
+ try {
402
+ resolve(parseYaml(content));
403
+ }
404
+ catch (err) {
405
+ reject(new CliError(`Failed to parse YAML from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR));
406
+ }
407
+ });
408
+ process.stdin.on("error", (err) => {
409
+ reject(new CliError(`Failed to read from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR));
410
+ });
411
+ });
412
+ }
413
+ function buildUpdatePayload(id, raw) {
414
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
415
+ throw new CliError("YAML content must be an object", EXIT_CODES.ARGUMENT_ERROR);
416
+ }
417
+ return { ...raw, id };
418
+ }
419
+ function toSnakeCase(str) {
420
+ return (str
421
+ // First, handle camelCase/PascalCase transitions
422
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
423
+ // Then convert to lowercase
424
+ .toLowerCase()
425
+ // Replace sequences of non-alphanumeric characters with single underscore
426
+ .replace(/[^a-z0-9]+/g, "_")
427
+ // Remove leading and trailing underscores
428
+ .replace(/^_+|_+$/g, ""));
429
+ }
430
+ // --- Include helpers ---
431
+ const SCORECARD_INCLUDE_SECTIONS = ["core", "owners", "checks"];
432
+ const SCORECARD_SECTION_KEYS = {
433
+ core: [
434
+ "id",
435
+ "name",
436
+ "description",
437
+ "type",
438
+ "published",
439
+ "entity_filter_type",
440
+ "entity_filter_sql",
441
+ "entity_filter_type_ids",
442
+ "tags",
443
+ "sql_errors",
444
+ "levels",
445
+ "empty_level_label",
446
+ "empty_level_color",
447
+ "check_groups",
448
+ ],
449
+ owners: ["admins", "editors"],
450
+ checks: ["checks"],
451
+ };
452
+ function assignScorecardKey(out, src, key) {
453
+ out[key] = src[key];
454
+ }
455
+ function processScorecardIncludes(scorecard, includeSections) {
456
+ if (includeSections === null) {
457
+ return scorecard;
458
+ }
459
+ const out = {};
460
+ for (const section of includeSections) {
461
+ for (const key of SCORECARD_SECTION_KEYS[section]) {
462
+ if (key in scorecard) {
463
+ assignScorecardKey(out, scorecard, key);
464
+ }
465
+ }
466
+ }
467
+ return out;
468
+ }
469
+ function parseScorecardIncludeSections(include) {
470
+ if (!include) {
471
+ return null;
472
+ }
473
+ const results = include.split(",");
474
+ for (const result of results) {
475
+ if (!SCORECARD_INCLUDE_SECTIONS.includes(result)) {
476
+ throw new CliError(`Invalid --include "${result}". Expected one of: ${SCORECARD_INCLUDE_SECTIONS.join(", ")}`, EXIT_CODES.ARGUMENT_ERROR);
477
+ }
478
+ }
479
+ return results;
480
+ }
@@ -0,0 +1,219 @@
1
+ import { renderRichText } from "../renderers.js";
2
+ import * as ui from "../ui.js";
3
+ export function renderScorecardList(scorecards, nextCursor, includeSections) {
4
+ const shouldIncludeAll = includeSections === null;
5
+ const blocks = [ui.h1("Scorecards")];
6
+ blocks.push(ui.p(`Displaying ${ui.bold(scorecards.length.toString())} scorecards.`));
7
+ if (nextCursor) {
8
+ blocks.push(ui.p(`Next cursor: ${ui.code(nextCursor)}`));
9
+ }
10
+ for (const scorecard of scorecards) {
11
+ blocks.push(ui.h2(`${scorecard.name} (${ui.code(scorecard.id)})`));
12
+ if (shouldIncludeAll || includeSections.includes("core")) {
13
+ blocks.push(ui.h3("Basic details"));
14
+ blocks.push(...basicDetailsContent(scorecard));
15
+ blocks.push(ui.h3("Entity filter"));
16
+ blocks.push(...entityFilterContent(scorecard));
17
+ if (scorecard.type === "LEVEL") {
18
+ blocks.push(ui.h3("Levels"));
19
+ blocks.push(...levelsContent(scorecard));
20
+ }
21
+ else {
22
+ blocks.push(ui.h3("Check groups"));
23
+ blocks.push(...checkGroupsContent(scorecard.check_groups));
24
+ }
25
+ }
26
+ if (shouldIncludeAll || includeSections.includes("owners")) {
27
+ blocks.push(ui.h3("Owners"));
28
+ blocks.push(...ownersContent(scorecard));
29
+ }
30
+ if (shouldIncludeAll || includeSections.includes("checks")) {
31
+ blocks.push(ui.h3("Checks"));
32
+ blocks.push(...checksContent(scorecard));
33
+ }
34
+ }
35
+ renderRichText(blocks);
36
+ }
37
+ export function renderScorecard(scorecard, includeSections, title = "Scorecard Information") {
38
+ const shouldIncludeAll = includeSections === null;
39
+ const blocks = [ui.h1(title)];
40
+ if (shouldIncludeAll || includeSections.includes("core")) {
41
+ blocks.push(ui.h2("Basic details"));
42
+ blocks.push(...basicDetailsContent(scorecard));
43
+ blocks.push(ui.h2("Entity filter"));
44
+ blocks.push(...entityFilterContent(scorecard));
45
+ if (scorecard.type === "LEVEL") {
46
+ blocks.push(ui.h2("Levels"));
47
+ blocks.push(...levelsContent(scorecard));
48
+ }
49
+ else {
50
+ blocks.push(ui.h2("Check groups"));
51
+ blocks.push(...checkGroupsContent(scorecard.check_groups));
52
+ }
53
+ }
54
+ if (shouldIncludeAll || includeSections.includes("owners")) {
55
+ blocks.push(ui.h2("Owners"));
56
+ blocks.push(...ownersContent(scorecard));
57
+ }
58
+ if (shouldIncludeAll || includeSections.includes("checks")) {
59
+ blocks.push(ui.h2(`Checks`));
60
+ blocks.push(...checksContent(scorecard));
61
+ }
62
+ renderRichText(blocks);
63
+ }
64
+ function basicDetailsContent(scorecard) {
65
+ const items = [
66
+ ui.dli("ID", [ui.p(ui.code(scorecard.id), false)]),
67
+ ui.dli("Name", [ui.p(scorecard.name, false)]),
68
+ ui.dli("Description", [
69
+ ui.p(scorecard.description || ui.dim("(None)"), false),
70
+ ]),
71
+ ui.dli("Type", [
72
+ ui.p(scorecard.type ? ui.code(scorecard.type) : ui.dim("(None)"), false),
73
+ ]),
74
+ ui.dli("Published", [
75
+ ui.p(scorecard.published ? ui.success("Yes") : ui.dim("No (draft)"), false),
76
+ ]),
77
+ ];
78
+ if (scorecard.tags && scorecard.tags.length > 0) {
79
+ items.push(ui.dli("Tags", [
80
+ ui.p(scorecard.tags.map((t) => t.value).join(", "), false),
81
+ ]));
82
+ }
83
+ return [ui.dl(items, { termWidth: 14 })];
84
+ }
85
+ function entityFilterContent(scorecard) {
86
+ const blocks = [];
87
+ if (scorecard.entity_filter_type === "entity_types") {
88
+ blocks.push(ui.p(`Filter: ${ui.code("entity_types")}`));
89
+ if (scorecard.entity_filter_type_ids &&
90
+ scorecard.entity_filter_type_ids.length > 0) {
91
+ blocks.push(ui.ul(scorecard.entity_filter_type_ids.map((id) => ui.li([ui.p(ui.code(id), false)]))));
92
+ }
93
+ else {
94
+ blocks.push(ui.p(ui.dim("(No entity types selected)")));
95
+ }
96
+ }
97
+ else if (scorecard.entity_filter_type === "sql") {
98
+ blocks.push(ui.p(`Filter: ${ui.code("sql")}`));
99
+ if (scorecard.entity_filter_sql) {
100
+ blocks.push(truncatedSqlBlock(scorecard.entity_filter_sql));
101
+ }
102
+ else {
103
+ blocks.push(ui.p(ui.dim("(No SQL provided)")));
104
+ }
105
+ }
106
+ else if (scorecard.entity_filter_type) {
107
+ blocks.push(ui.p(`Filter: ${ui.code(scorecard.entity_filter_type)}`));
108
+ }
109
+ return blocks;
110
+ }
111
+ function levelsContent(scorecard) {
112
+ const levels = scorecard.levels ?? [];
113
+ if (levels.length === 0) {
114
+ return [ui.p(ui.dim("(No levels defined)"))];
115
+ }
116
+ const blocks = [
117
+ ui.ul(levels.map((level) => ui.li([ui.p(`${ui.bold(level.name)} ${ui.dim(level.color)}`, false)]))),
118
+ ];
119
+ if (scorecard.empty_level_label) {
120
+ const colorSuffix = scorecard.empty_level_color
121
+ ? ` ${ui.dim(scorecard.empty_level_color)}`
122
+ : "";
123
+ blocks.push(ui.p(`Empty level: ${ui.bold(scorecard.empty_level_label)}${colorSuffix}`));
124
+ }
125
+ return blocks;
126
+ }
127
+ function checkGroupsContent(checkGroups) {
128
+ if (checkGroups.length === 0) {
129
+ return [ui.p(ui.dim("(No check groups defined)"))];
130
+ }
131
+ return [ui.ul(checkGroups.map((g) => ui.li([ui.p(ui.bold(g.name), false)])))];
132
+ }
133
+ function ownersContent(scorecard) {
134
+ const blocks = [];
135
+ const adminCount = scorecard.admins?.length ?? 0;
136
+ const editorCount = scorecard.editors?.length ?? 0;
137
+ if (adminCount === 0 && editorCount === 0) {
138
+ return [ui.p(ui.dim("(No owners assigned)"))];
139
+ }
140
+ if (scorecard.admins && adminCount > 0) {
141
+ blocks.push(ui.p(ui.bold("Admins:")));
142
+ blocks.push(ui.ul(scorecard.admins.map((u) => ui.li([ui.p(`${u.name} (${ui.code(u.email)})`, false)]))));
143
+ }
144
+ if (scorecard.editors && editorCount > 0) {
145
+ blocks.push(ui.p(ui.bold("Editors:")));
146
+ blocks.push(ui.ul(scorecard.editors.map((u) => ui.li([ui.p(`${u.name} (${ui.code(u.email)})`, false)]))));
147
+ }
148
+ return blocks;
149
+ }
150
+ function checksContent(scorecard) {
151
+ const checks = scorecard.checks ?? [];
152
+ if (checks.length === 0) {
153
+ return [ui.p(ui.dim("(No checks defined)"))];
154
+ }
155
+ const blocks = [];
156
+ for (const check of checks) {
157
+ blocks.push(...checkContent(check, scorecard.type));
158
+ }
159
+ return blocks;
160
+ }
161
+ function checkContent(check, scorecardType) {
162
+ const statusBadge = check.published ? "" : ` ${ui.dim("[draft]")}`;
163
+ const blocks = [ui.h3(`${check.name}${statusBadge}`)];
164
+ // Core fields dl
165
+ const dlItems = [ui.dli("ID", [ui.p(ui.code(check.id), false)])];
166
+ if (scorecardType === "LEVEL" && check.level) {
167
+ dlItems.push(ui.dli("Level", [ui.p(check.level.name, false)]));
168
+ }
169
+ if (scorecardType === "POINTS") {
170
+ if (check.check_group) {
171
+ dlItems.push(ui.dli("Check group", [ui.p(check.check_group.name, false)]));
172
+ }
173
+ if (check.points !== undefined && check.points !== null) {
174
+ dlItems.push(ui.dli("Points", [ui.p(check.points.toString(), false)]));
175
+ }
176
+ }
177
+ dlItems.push(ui.dli("Doc URL", [
178
+ ui.p(check.external_url ? ui.link(check.external_url) : ui.dim("(none)"), false),
179
+ ]));
180
+ if (check.estimated_dev_days !== null) {
181
+ dlItems.push(ui.dli("Est. dev days", [
182
+ ui.p(check.estimated_dev_days?.toString() ?? ui.dim("(none)"), false),
183
+ ]));
184
+ }
185
+ blocks.push(ui.dl(dlItems, { termWidth: 14 }));
186
+ // Description
187
+ if (check.description) {
188
+ blocks.push(ui.p(ui.bold("Description:"), false));
189
+ blocks.push(ui.p(check.description));
190
+ }
191
+ // Query
192
+ blocks.push(ui.p(ui.bold("Query:")));
193
+ blocks.push(truncatedSqlBlock(check.sql));
194
+ // Output settings
195
+ if (check.output_enabled) {
196
+ const outputParts = [`Output: ${ui.code(check.output_type ?? "unknown")}`];
197
+ if (check.output_aggregation) {
198
+ outputParts.push(ui.dim(`(aggregation: ${check.output_aggregation})`));
199
+ }
200
+ blocks.push(ui.p(outputParts.join(" ")));
201
+ }
202
+ // Entity filter
203
+ if (check.filter_sql) {
204
+ blocks.push(ui.p(ui.bold("Entity filter:")));
205
+ blocks.push(truncatedSqlBlock(check.filter_sql));
206
+ if (check.filter_message) {
207
+ blocks.push(ui.p(`Excluded message: ${ui.dim(check.filter_message)}`, false));
208
+ }
209
+ }
210
+ return blocks;
211
+ }
212
+ function truncatedSqlBlock(sql) {
213
+ const lines = sql.split("\n");
214
+ const firstLine = lines[0];
215
+ const isLong = firstLine.length > 80 || lines.length > 1;
216
+ const truncated = firstLine.length > 80 ? firstLine.slice(0, 80) : firstLine;
217
+ const display = isLong ? truncated + ui.dim(" …") : truncated;
218
+ return ui.p(ui.code(display));
219
+ }