From a22b762180de598f2ba017dee9ddd09eeb07750a Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 8 Feb 2026 11:42:47 +0100 Subject: [PATCH] feat(cli): add graph commands with query and table output --- package.json | 6 ++- src/cli.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++ src/graph/app.js | 85 +++++++++++++++++++++++++++++++++++++ src/markdown.js | 75 +++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 1 deletion(-) create mode 100755 src/cli.js create mode 100644 src/markdown.js diff --git a/package.json b/package.json index 5de6ddf..ee12e44 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,16 @@ "@azure/msal-node": "^5.0.3", "@azure/msal-node-extensions": "^1.2.0", "@microsoft/microsoft-graph-client": "^3.0.7", - "azure-devops-node-api": "^15.1.2" + "azure-devops-node-api": "^15.1.2", + "jmespath": "^0.16.0" }, "author": { "name": "Sławomir Koszewski", "email": "slawek@koszewscy.waw.pl" }, + "bin": { + "sk-az-tools": "./src/cli.js" + }, "license": "MIT", "exports": { ".": "./src/index.js", diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..8d4215d --- /dev/null +++ b/src/cli.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +import { parseArgs } from "node:util"; +import jmespath from "jmespath"; + +import { loadPublicConfig } from "./index.js"; +import { getGraphClient } from "./graph/auth.js"; +import { listApps, listAppPermissions, listAppGrants } from "./graph/app.js"; +import { toMarkdownTable } from "./markdown.js"; + +function usage() { + return `Usage: sk-az-tools [options] + +Commands: + list-apps [--display-name ] + list-app-permissions --app-id + list-app-grants --app-id + +Options: + --display-name Filter apps by exact display name + --app-id Application (client) ID + --query Filter output JSON using JMESPath + --output Output format: json|table|prettytable (default: json) + -h, --help Show this help message`; +} + +function outputFiltered(object, query) { + return query + ? jmespath.search(object, query) + : object; +} + +async function main() { + const argv = process.argv.slice(2); + const command = argv[0]; + if (command === "-h" || command === "--help") { + console.log(usage()); + process.exit(0); + } + + const { values } = parseArgs({ + args: argv.slice(1), + options: { + help: { type: "boolean", short: "h" }, + "display-name": { type: "string" }, + "app-id": { type: "string" }, + query: { type: "string" }, + output: { type: "string" }, + }, + strict: true, + allowPositionals: false, + }); + + if (values.help || !command) { + console.log(usage()); + process.exit(0); + } + const outputFormat = values.output ?? "json"; + if (outputFormat !== "json" && outputFormat !== "table" && outputFormat !== "prettytable") { + throw new Error("--output must be one of: json, table, prettytable"); + } + + const config = await loadPublicConfig(); + const { client } = await getGraphClient({ + tenantId: config.tenantId, + clientId: config.clientId, + }); + + let result; + switch (command) { + case "list-apps": + result = await listApps(client, { + displayName: values["display-name"], + }); + break; + case "list-app-permissions": + if (!values["app-id"]) { + throw new Error("--app-id is required for list-app-permissions"); + } + result = await listAppPermissions(client, values["app-id"]); + break; + case "list-app-grants": + if (!values["app-id"]) { + throw new Error("--app-id is required for list-app-grants"); + } + result = await listAppGrants(client, values["app-id"]); + break; + default: + throw new Error(`Unknown command: ${command}`); + } + + const filtered = outputFiltered(result, values.query); + if (outputFormat === "prettytable") { + console.log(toMarkdownTable(filtered, true)); + } else if (outputFormat === "table") { + console.log(toMarkdownTable(filtered)); + } else { + console.log(JSON.stringify(filtered, null, 2)); + } +} + +main().catch((err) => { + console.error(`Error: ${err.message}`); + console.error(usage()); + process.exit(1); +}); diff --git a/src/graph/app.js b/src/graph/app.js index 3494afd..0baa03a 100644 --- a/src/graph/app.js +++ b/src/graph/app.js @@ -30,3 +30,88 @@ export async function createApp(client, displayName) { export async function deleteApp(client, appObjectId) { await client.api(`/applications/${appObjectId}`).delete(); } + +/** + * List Azure applications, optionally filtered by display name. + * + * @param { Object } client + * @param { Object } [options] + * @param { string } [options.displayName] + * @returns { Promise } + */ +export async function listApps(client, options = {}) { + const { displayName } = options; + let request = client.api("/applications"); + + if (displayName) { + request = request.filter(`displayName eq '${displayName}'`); + } + + const result = await request.get(); + return Array.isArray(result?.value) ? result.value : []; +} + +/** + * List required resource access configuration for an application by appId. + * + * @param { Object } client + * @param { string } appId + * @returns { Promise } + */ +export async function listAppPermissions(client, appId) { + if (!appId) { + throw new Error("appId is required"); + } + + const result = await client + .api("/applications") + .filter(`appId eq '${appId}'`) + .select("id,appId,displayName,requiredResourceAccess") + .get(); + + const app = Array.isArray(result?.value) && result.value.length > 0 + ? result.value[0] + : null; + + if (!app) { + return []; + } + + return Array.isArray(app.requiredResourceAccess) + ? app.requiredResourceAccess + : []; +} + +/** + * List delegated OAuth2 permission grants for an application by appId. + * + * @param { Object } client + * @param { string } appId + * @returns { Promise } + */ +export async function listAppGrants(client, appId) { + if (!appId) { + throw new Error("appId is required"); + } + + const spResult = await client + .api("/servicePrincipals") + .filter(`appId eq '${appId}'`) + .select("id,appId,displayName") + .get(); + + const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0 + ? spResult.value[0] + : null; + + if (!servicePrincipal?.id) { + return []; + } + + const grantsResult = await client + .api("/oauth2PermissionGrants") + .filter(`clientId eq '${servicePrincipal.id}'`) + .get(); + + return Array.isArray(grantsResult?.value) ? grantsResult.value : []; +} diff --git a/src/markdown.js b/src/markdown.js new file mode 100644 index 0000000..e922ccb --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,75 @@ +function formatCell(value) { + const text = value == null + ? "" + : String(value); + return text.replaceAll("|", "\\|").replaceAll("\n", "
"); +} + +function getScalarRowsAndHeaders(value) { + let rows; + if (Array.isArray(value)) { + rows = value.map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? item + : { value: item } + ); + } else if (value && typeof value === "object") { + rows = [value]; + } 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) => { + const value = row[key]; + return value == null || typeof value !== "object"; + }) + ); + + if (headers.length === 0) { + return { + headers: ["result"], + rows: [{ result: "" }], + }; + } + + return { headers, rows }; +} + +export function toMarkdownTable(value, pretty = false) { + const { headers, rows } = getScalarRowsAndHeaders(value); + if (!pretty) { + const headerLine = `| ${headers.join(" | ")} |`; + const separatorLine = `| ${headers.map(() => "---").join(" | ")} |`; + const rowLines = rows.map((row) => + `| ${headers.map((key) => formatCell(row[key])).join(" | ")} |` + ); + return [headerLine, separatorLine, ...rowLines].join("\n"); + } + + const widths = headers.map((header, idx) => + Math.max( + header.length, + ...rows.map((row) => formatCell(row[headers[idx]]).length), + ) + ); + + const renderRow = (values) => + `| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`; + + const headerLine = renderRow(headers); + const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`; + const rowLines = rows.map((row) => + renderRow(headers.map((header) => formatCell(row[header]))) + ); + + return [headerLine, separatorLine, ...rowLines].join("\n"); +}