diff --git a/src/cli.js b/src/cli.js index 68a6472..a2fefbf 100755 --- a/src/cli.js +++ b/src/cli.js @@ -22,7 +22,7 @@ Commands: list-apps [--display-name|-n ] list-app-permissions --app-id|-i [--resolve|-r] [--short|-s] list-app-grants --app-id|-i - table [--pretty|-p] [--quote-guids|-g] + table [--pretty|-p] [--quote-guids|-g] [--header|-H ] Options: -n, --display-name Filter apps by exact display name @@ -32,6 +32,7 @@ Options: -q, --query Filter output JSON using JMESPath -p, --pretty Use normalized column widths for Markdown table output -g, --quote-guids In pretty tables, wrap GUID values in backticks + -H, --header Header mode/spec: auto|a OR "col1, col2" OR "key1: Label 1, key2: Label 2" -o, --output Output format: json|table|prettytable (default: json) -h, --help Show this help message`; } @@ -42,6 +43,40 @@ function outputFiltered(object, query) { : object; } +function parseHeaderSpec(headerValue) { + if (!headerValue) { + return { mode: "default" }; + } + + const raw = headerValue.trim(); + if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") { + return { mode: "auto" }; + } + + const parts = raw.split(",").map((p) => p.trim()).filter(Boolean); + const isMap = parts.some((p) => p.includes(":")); + + if (!isMap) { + return { mode: "list", labels: parts }; + } + + const map = {}; + for (const part of parts) { + const idx = part.indexOf(":"); + if (idx < 0) { + throw new Error(`Invalid --header mapping segment: '${part}'`); + } + const key = part.slice(0, idx).trim(); + const label = part.slice(idx + 1).trim(); + if (!key || !label) { + throw new Error(`Invalid --header mapping segment: '${part}'`); + } + map[key] = label; + } + + return { mode: "map", map }; +} + function omitPermissionGuidColumns(value) { if (Array.isArray(value)) { return value.map((item) => omitPermissionGuidColumns(item)); @@ -97,6 +132,7 @@ async function main() { query: { type: "string", short: "q" }, pretty: { type: "boolean", short: "p" }, "quote-guids": { type: "boolean", short: "g" }, + header: { type: "string", short: "H" }, output: { type: "string", short: "o" }, }, strict: true, @@ -165,12 +201,23 @@ async function main() { const output = command === "list-app-permissions" && values.short ? omitPermissionGuidColumns(filtered) : filtered; + const headerSpec = parseHeaderSpec(values.header); if (command === "table") { - console.log(toMarkdownTable(output, Boolean(values.pretty), Boolean(values["quote-guids"]))); + console.log(toMarkdownTable( + output, + Boolean(values.pretty), + Boolean(values["quote-guids"]), + headerSpec, + )); } else if (outputFormat === "prettytable") { - console.log(toMarkdownTable(output, true, Boolean(values["quote-guids"]))); + console.log(toMarkdownTable( + output, + true, + Boolean(values["quote-guids"]), + headerSpec, + )); } else if (outputFormat === "table") { - console.log(toMarkdownTable(output)); + console.log(toMarkdownTable(output, false, false, headerSpec)); } else { console.log(JSON.stringify(output, null, 2)); } diff --git a/src/markdown.js b/src/markdown.js index 8b7a634..6e1e637 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -12,6 +12,19 @@ function isGuid(value) { && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); } +function toAutoHeaderLabel(key) { + const withSpaces = String(key) + .replace(/[_-]+/g, " ") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/\s+/g, " ") + .trim(); + return withSpaces + .split(" ") + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(" "); +} + function getScalarRowsAndHeaders(value) { let rows; if (Array.isArray(value)) { @@ -52,35 +65,48 @@ function getScalarRowsAndHeaders(value) { } export function toMarkdownTable(value, pretty = false, quoteGuids = false) { + const headerSpec = arguments[3] ?? { mode: "default" }; const { headers, rows } = getScalarRowsAndHeaders(value); + const headerDefinitions = headers.map((key, idx) => { + let label = key; + if (headerSpec?.mode === "auto") { + label = toAutoHeaderLabel(key); + } else if (headerSpec?.mode === "list" && Array.isArray(headerSpec.labels) && headerSpec.labels[idx]) { + label = headerSpec.labels[idx]; + } else if (headerSpec?.mode === "map" && headerSpec.map && headerSpec.map[key]) { + label = headerSpec.map[key]; + } + return { key, label }; + }); + const renderCell = (raw) => { const text = formatCell(raw); return quoteGuids && isGuid(raw) ? `\`${text}\`` : text; }; if (!pretty) { - const headerLine = `| ${headers.join(" | ")} |`; - const separatorLine = `| ${headers.map(() => "---").join(" | ")} |`; + const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`; + const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`; const rowLines = rows.map((row) => - `| ${headers.map((key) => formatCell(row[key])).join(" | ")} |` + `| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |` ); return [headerLine, separatorLine, ...rowLines].join("\n"); } - const widths = headers.map((header, idx) => + const widths = headerDefinitions.map((header, idx) => Math.max( - header.length, - ...rows.map((row) => renderCell(row[headers[idx]]).length), + header.label.length, + ...rows.map((row) => renderCell(row[headerDefinitions[idx].key]).length), ) ); const renderRow = (values) => `| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`; - const headerLine = renderRow(headers); + const headerLine = renderRow(headerDefinitions.map((h) => h.label)); const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`; const rowLines = rows.map((row) => - renderRow(headers.map((header) => renderCell(row[header]))) + renderRow(headerDefinitions.map((header) => renderCell(row[header.key]))) ); return [headerLine, separatorLine, ...rowLines].join("\n");