diff --git a/docs/Commands.md b/docs/Commands.md index 890a12d..8a8e2f0 100644 --- a/docs/Commands.md +++ b/docs/Commands.md @@ -118,19 +118,4 @@ The `sk-az-tools` package may act as a CLI tool that provides various commands f **Description:** The `list-resource-permissions` command returns available delegated and application permissions exposed by a resource app. -## Table - -**Command name:** `table` - -**Usage:** `sk-az-tools table [--header|-H ] [global options]` - -**Options:** - -- `--header`, `-H` - Header definition. Possible values: - - `col1, col2, ...` - Column names separated by comma. The number of columns must match the number of columns in the table. - - `key1: Col1, key2: Col2, ...` - property names followed by column and the column name. The number of pairs must match the number of columns in the table. - - `auto`, `a` - header is generated automatically based on the first row of the table - - `original`, `o` - header is generated based on the original table (before any transformations) - -**Description:** The `table` command act as a filter that transforms JSON input into a Markdown table. It uses built-in Markdown table formatter, but on arbitrary JSON input. diff --git a/package-lock.json b/package-lock.json index 396fa07..bf6c5c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,20 @@ { "name": "@slawek/sk-az-tools", - "version": "0.3.3", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@slawek/sk-az-tools", - "version": "0.3.3", + "version": "0.4.0", "license": "MIT", "dependencies": { "@azure/identity": "^4.13.0", "@azure/msal-node": "^5.0.3", "@azure/msal-node-extensions": "^1.2.0", "@microsoft/microsoft-graph-client": "^3.0.7", + "@slawek/sk-tools": ">=0.1.0", "azure-devops-node-api": "^15.1.2", - "d3-dsv": "^3.0.1", - "jmespath": "^0.16.0", "minimatch": "^10.1.2", "open": "^10.1.0" }, @@ -23,8 +22,6 @@ "sk-az-tools": "dist/cli.js" }, "devDependencies": { - "@types/d3-dsv": "^3.0.7", - "@types/jmespath": "^0.15.2", "@types/node": "^24.0.0", "typescript": "^5.8.2" }, @@ -271,19 +268,18 @@ } } }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jmespath": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", - "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", - "dev": true, - "license": "MIT" + "node_modules/@slawek/sk-tools": { + "version": "0.1.0", + "resolved": "https://gitea.koszewscy.waw.pl/api/packages/slawek/npm/%40slawek%2Fsk-tools/-/0.1.0/sk-tools-0.1.0.tgz", + "integrity": "sha512-/55dkzgeoh3OWnxnT5jRrbj211YJ73QvZiXjVzfZSg5cdYd8mGumGPxl9Krx+VGaj5b6vTUyFTHafH5+rFewMg==", + "license": "MIT", + "dependencies": { + "d3-dsv": "^3.0.1", + "jmespath": "^0.16.0" + }, + "bin": { + "sk-tools": "dist/cli.js" + } }, "node_modules/@types/node": { "version": "24.11.0", diff --git a/package.json b/package.json index d4db2dc..f14fb7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slawek/sk-az-tools", - "version": "0.3.3", + "version": "0.4.0", "type": "module", "files": [ "dist", @@ -23,15 +23,12 @@ "@azure/msal-node": "^5.0.3", "@azure/msal-node-extensions": "^1.2.0", "@microsoft/microsoft-graph-client": "^3.0.7", + "@slawek/sk-tools": ">=0.1.0", "azure-devops-node-api": "^15.1.2", - "d3-dsv": "^3.0.1", - "jmespath": "^0.16.0", "minimatch": "^10.1.2", "open": "^10.1.0" }, "devDependencies": { - "@types/d3-dsv": "^3.0.7", - "@types/jmespath": "^0.15.2", "@types/node": "^24.0.0", "typescript": "^5.8.2" }, diff --git a/src/cli.ts b/src/cli.ts index e2f6c3b..9c321bc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,19 +12,19 @@ import { usageListResourcePermissions } from "./cli/commands/list-resource-permi import { usageLogin } from "./cli/commands/login.ts"; import { usageLogout } from "./cli/commands/logout.ts"; import { usageRest } from "./cli/commands/rest.ts"; -import { usageTable } from "./cli/commands/table.ts"; import { normalizeOutputFormat, - omitPermissionGuidColumns, outputFiltered, parseHeaderSpec, renderOutput, -} from "./cli/utils.ts"; +} from "@slawek/sk-tools"; +import { + omitPermissionGuidColumns, +} from "./cli/permission-utils.ts"; type CliValues = { help?: boolean; type?: string; - from?: string; method?: string; url?: string; "display-name"?: string; @@ -56,7 +56,6 @@ Commands: list-app-permissions List required permissions for an app list-app-grants List OAuth2 grants for an app list-resource-permissions List available permissions for a resource app - table Render stdin JSON as Markdown table Global options (all commands): -q, --query @@ -85,8 +84,6 @@ function usageCommand(command: string): string { return usageListAppGrants(); case "list-resource-permissions": return usageListResourcePermissions(); - case "table": - return usageTable(); default: return `Unknown command: ${command}\n\n${usage()}`; } @@ -110,7 +107,6 @@ async function main(): Promise { options: { help: { type: "boolean", short: "h" }, type: { type: "string", short: "t" }, - from: { type: "string", short: "F" }, method: { type: "string" }, url: { type: "string" }, "display-name": { type: "string", short: "n" }, diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 3afe738..a41e9b0 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -8,7 +8,6 @@ import { runListResourcePermissionsCommand } from "./commands/list-resource-perm import { runLoginCommand } from "./commands/login.ts"; import { runLogoutCommand } from "./commands/logout.ts"; import { runRestCommand } from "./commands/rest.ts"; -import { runTableCommand } from "./commands/table.ts"; import type { CommandValues } from "./commands/types.ts"; @@ -18,8 +17,6 @@ export async function runCommand(command: string, values: CommandValues): Promis return runLoginCommand(values); case "logout": return runLogoutCommand(values); - case "table": - return runTableCommand(values); case "list-apps": return runListAppsCommand(values); case "list-app-permissions": diff --git a/src/cli/commands/table.ts b/src/cli/commands/table.ts deleted file mode 100644 index 80ae31c..0000000 --- a/src/cli/commands/table.ts +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT - -import { readCsvFromStdin, readJsonFromStdin } from "../utils.ts"; - -import type { CommandValues } from "./types.ts"; - -export function usageTable(): string { - return `Usage: sk-az-tools table [--from|-F ] [--header|-H ] [global options] - -Options: - --from, -F Input format on stdin (default: json) - --header, -H Header mode: auto|a (default), original|o, or "col1, col2" or "key1: Label 1, key2: Label 2"`; -} - -export async function runTableCommand(values: CommandValues): Promise { - const from = (values.from ?? "json").toString().trim().toLowerCase(); - if (from === "json") { - return readJsonFromStdin(); - } - if (from === "csv") { - return readCsvFromStdin(","); - } - if (from === "tsv") { - return readCsvFromStdin("\t"); - } - - throw new Error(`Invalid --from '${values.from}'. Allowed: json, csv, tsv`); -} diff --git a/src/cli/commands/types.ts b/src/cli/commands/types.ts index f1ff9c5..6dabd1e 100644 --- a/src/cli/commands/types.ts +++ b/src/cli/commands/types.ts @@ -3,7 +3,6 @@ export type CommandValues = { [key: string]: string | boolean | undefined; type?: string; - from?: string; method?: string; url?: string; header?: string; diff --git a/src/cli/permission-utils.ts b/src/cli/permission-utils.ts new file mode 100644 index 0000000..8087249 --- /dev/null +++ b/src/cli/permission-utils.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +export function omitPermissionGuidColumns(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => omitPermissionGuidColumns(item)); + } + if (!value || typeof value !== "object") { + return value; + } + const { resourceAppId, permissionId, ...rest } = value as Record; + void resourceAppId; + void permissionId; + return rest; +} diff --git a/src/cli/utils.ts b/src/cli/utils.ts deleted file mode 100644 index a07f201..0000000 --- a/src/cli/utils.ts +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-License-Identifier: MIT - -import jmespath from "jmespath"; -import { dsvFormat } from "d3-dsv"; - -import { toMarkdownTable } from "../markdown.ts"; - -type HeaderSpec = - | { mode: "auto" } - | { mode: "original" } - | { mode: "list"; labels: string[] } - | { mode: "map"; map: Record }; - -type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv"; - -type Scalar = string | number | boolean | null | undefined; -type ScalarRow = Record; - -export function outputFiltered(object: unknown, query?: string): unknown { - return query - ? jmespath.search(object, query) - : object; -} - -export function parseHeaderSpec(headerValue?: string): HeaderSpec { - if (!headerValue) { - return { mode: "auto" }; - } - - const raw = headerValue.trim(); - if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") { - return { mode: "auto" }; - } - if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") { - return { mode: "original" }; - } - - 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: Record = {}; - 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 }; -} - -export function normalizeOutputFormat(outputValue?: string): OutputFormat { - if (outputValue == null) { - return "json"; - } - - const raw = outputValue.toLowerCase(); - if (raw === "json") { - throw new Error("JSON is the default output. Omit --output to use it."); - } - if (raw === "j") { - throw new Error("JSON is the default output. Omit --output to use it."); - } - if (raw === "table" || raw === "t") return "table"; - if (raw === "alignedtable" || raw === "at") return "alignedtable"; - if (raw === "prettytable" || raw === "pt") return "prettytable"; - if (raw === "tsv") return "tsv"; - throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv"); -} - -function isScalar(value: unknown): value is Scalar { - return value == null || typeof value !== "object"; -} - -function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } { - let rows: Array>; - if (Array.isArray(value)) { - rows = value.map((item) => - item && typeof item === "object" && !Array.isArray(item) - ? item as Record - : { value: item }, - ); - } else if (value && typeof value === "object") { - rows = [value as Record]; - } else { - rows = [{ value }]; - } - - if (rows.length === 0) { - return { - headers: ["result"], - rows: [{ result: "" }], - }; - } - - const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] - .filter((key) => - rows.every((row) => isScalar(row[key])), - ); - - if (headers.length === 0) { - return { - headers: ["result"], - rows: [{ result: "" }], - }; - } - - const scalarRows: ScalarRow[] = rows.map((row) => { - const result: ScalarRow = {}; - for (const [key, rowValue] of Object.entries(row)) { - if (isScalar(rowValue)) { - result[key] = rowValue; - } - } - return result; - }); - - return { headers, rows: scalarRows }; -} - -function toTsv(value: unknown): string { - const { headers, rows } = getScalarRowsAndHeaders(value); - const lines = rows.map((row) => - headers - .map((header) => (row[header] == null ? "" : String(row[header]).replaceAll("\t", " ").replaceAll("\n", " "))) - .join("\t"), - ); - return lines.join("\n"); -} - -export function omitPermissionGuidColumns(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => omitPermissionGuidColumns(item)); - } - if (!value || typeof value !== "object") { - return value; - } - const { resourceAppId, permissionId, ...rest } = value as Record; - void resourceAppId; - void permissionId; - return rest; -} - -export async function readJsonFromStdin(): Promise { - const input = await new Promise((resolve, reject) => { - let data = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk: string) => { - data += chunk; - }); - process.stdin.on("end", () => { - resolve(data); - }); - process.stdin.on("error", (err) => { - reject(err); - }); - }); - if (!input.trim()) { - throw new Error("No JSON input provided on stdin"); - } - - try { - return JSON.parse(input) as unknown; - } catch (err) { - throw new Error(`Invalid JSON input on stdin: ${(err as Error).message}`); - } -} - -export async function readCsvFromStdin(separator: string): Promise { - if (!separator) { - throw new Error("separator is required"); - } - if (separator.length !== 1) { - throw new Error("separator must be a single character"); - } - - const input = await new Promise((resolve, reject) => { - let data = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk: string) => { - data += chunk; - }); - process.stdin.on("end", () => { - resolve(data); - }); - process.stdin.on("error", (err) => { - reject(err); - }); - }); - if (!input.trim()) { - throw new Error("No separated values input provided on stdin"); - } - - try { - const parser = dsvFormat(separator); - const rows = parser.parse(input); - if (rows.columns.some((header: string) => header.trim() === "")) { - throw new Error("header row contains empty column name"); - } - return rows; - } catch (err) { - throw new Error(`Invalid separated input on stdin: ${(err as Error).message}`); - } -} - -export function renderOutput( - command: string, - output: unknown, - outputFormat: OutputFormat, - headerSpec: HeaderSpec, -): void { - if (outputFormat === "tsv") { - console.log(toTsv(output)); - return; - } - - if (command === "table") { - console.log(toMarkdownTable( - output, - outputFormat === "alignedtable" || outputFormat === "prettytable", - outputFormat === "prettytable", - headerSpec, - )); - } else if (outputFormat === "alignedtable") { - console.log(toMarkdownTable(output, true, false, headerSpec)); - } else if (outputFormat === "prettytable") { - console.log(toMarkdownTable(output, true, true, headerSpec)); - } else if (outputFormat === "table") { - console.log(toMarkdownTable(output, false, false, headerSpec)); - } else { - console.log(JSON.stringify(output, null, 2)); - } -} diff --git a/src/markdown.ts b/src/markdown.ts deleted file mode 100644 index bcba7ab..0000000 --- a/src/markdown.ts +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: MIT - -type Scalar = string | number | boolean | null | undefined; -type ScalarRow = Record; - -type HeaderSpec = - | { mode: "default" } - | { mode: "auto" } - | { mode: "original" } - | { mode: "list"; labels: string[] } - | { mode: "map"; map: Record }; - -function formatCell(value: unknown): string { - const text = value == null - ? "" - : String(value); - return text.replaceAll("|", "\\|").replaceAll("\n", "
"); -} - -function isGuid(value: unknown): value is string { - return typeof value === "string" - && /^[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: string): string { - 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 isScalar(value: unknown): value is Scalar { - return value == null || typeof value !== "object"; -} - -function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } { - let rows: Array>; - if (Array.isArray(value)) { - rows = value.map((item) => - item && typeof item === "object" && !Array.isArray(item) - ? item as Record - : { value: item }, - ); - } else if (value && typeof value === "object") { - rows = [value as Record]; - } else { - rows = [{ value }]; - } - - if (rows.length === 0) { - return { - headers: ["result"], - rows: [{ result: "" }], - }; - } - - const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] - .filter((key) => - rows.every((row) => isScalar(row[key])), - ); - - if (headers.length === 0) { - return { - headers: ["result"], - rows: [{ result: "" }], - }; - } - - const scalarRows: ScalarRow[] = rows.map((row) => { - const result: ScalarRow = {}; - for (const [key, rowValue] of Object.entries(row)) { - if (isScalar(rowValue)) { - result[key] = rowValue; - } - } - return result; - }); - - return { headers, rows: scalarRows }; -} - -export function toMarkdownTable( - value: unknown, - pretty = false, - quoteGuids = false, - headerSpec: HeaderSpec = { mode: "default" }, -): string { - 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" && headerSpec.labels[idx]) { - label = headerSpec.labels[idx]; - } else if (headerSpec.mode === "map" && headerSpec.map[key]) { - label = headerSpec.map[key]; - } - return { key, label }; - }); - - const renderCell = (raw: Scalar): string => { - const text = formatCell(raw); - return quoteGuids && isGuid(raw) ? `\`${text}\`` : text; - }; - - if (!pretty) { - const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`; - const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`; - const rowLines = rows.map((row) => - `| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |`, - ); - return [headerLine, separatorLine, ...rowLines].join("\n"); - } - - const widths = headerDefinitions.map((header, idx) => - Math.max( - header.label.length, - ...rows.map((row) => renderCell(row[headerDefinitions[idx].key]).length), - ) - ); - - const renderRow = (values: string[]): string => - `| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`; - - const headerLine = renderRow(headerDefinitions.map((h) => h.label)); - const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`; - const rowLines = rows.map((row) => - renderRow(headerDefinitions.map((header) => renderCell(row[header.key]))), - ); - - return [headerLine, separatorLine, ...rowLines].join("\n"); -}