From 9581ee1a3181f174e39114e99597c93c95d29bab Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Thu, 5 Mar 2026 23:18:47 +0100 Subject: [PATCH] Add REST command support with method and URL options --- src/cli.ts | 24 +++++++++- src/cli/commands.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 3e66fca..e106946 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,8 @@ import { type CliValues = { help?: boolean; type?: string; + method?: string; + url?: string; "display-name"?: string; "app-id"?: string; resources?: string; @@ -39,6 +41,7 @@ Commands: login Authenticate selected resources logout Sign out and clear login state get-token Get access token (azurerm|devops) + rest Call REST API endpoint list-apps List Entra applications list-app-permissions List required permissions for an app list-app-grants List OAuth2 grants for an app @@ -88,6 +91,19 @@ Options: -t, --type Token type: azurerm|devops`; } +function usageRest(): string { + return `Usage: sk-az-tools rest [--method ] --url [--header ] [global options] + +Options: + --method HTTP method (default: GET; examples: GET, POST, PATCH, DELETE) + --url Full URL to call + --header Extra request header; example: "Content-Type: application/json" + +Authorization is added automatically for: + management.azure.com Uses azurerm token + dev.azure.com Uses devops token`; +} + function usageListAppPermissions(): string { return `Usage: sk-az-tools list-app-permissions --app-id|-i [--resolve|-r] [--short|-s] [--filter|-f ] [global options] @@ -131,6 +147,8 @@ function usageCommand(command: string): string { return usageLogout(); case "get-token": return usageGetToken(); + case "rest": + return usageRest(); case "list-app-permissions": return usageListAppPermissions(); case "list-app-grants": @@ -162,6 +180,8 @@ async function main(): Promise { options: { help: { type: "boolean", short: "h" }, type: { type: "string", short: "t" }, + method: { type: "string" }, + url: { type: "string" }, "display-name": { type: "string", short: "n" }, "app-id": { type: "string", short: "i" }, resources: { type: "string" }, @@ -194,7 +214,9 @@ async function main(): Promise { const output = command === "list-app-permissions" && typedValues.short ? omitPermissionGuidColumns(filtered) : filtered; - const headerSpec = parseHeaderSpec(typedValues.header); + const headerSpec = command === "rest" + ? parseHeaderSpec(undefined) + : parseHeaderSpec(typedValues.header); renderOutput(command, output, outputFormat, headerSpec); } diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 64d570f..b44b92e 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -18,6 +18,9 @@ import { readJsonFromStdin } from "./utils.ts"; type CommandValues = { [key: string]: string | boolean | undefined; type?: string; + method?: string; + url?: string; + header?: string; resources?: string; "use-device-code"?: boolean; "no-browser"?: boolean; @@ -39,6 +42,66 @@ type DisplayNameRow = { displayName?: string | null; }; +function parseHeaderLine(header?: string): { name: string; value: string } | null { + if (!header || header.trim() === "") { + return null; + } + + const separatorIndex = header.indexOf(":"); + if (separatorIndex < 1) { + throw new Error("--header must be in the format 'Name: Value'"); + } + + const name = header.slice(0, separatorIndex).trim(); + const value = header.slice(separatorIndex + 1).trim(); + if (!name || !value) { + throw new Error("--header must be in the format 'Name: Value'"); + } + + return { name, value }; +} + +function hasAuthorizationHeader(headers: Headers): boolean { + for (const headerName of headers.keys()) { + if (headerName.toLowerCase() === "authorization") { + return true; + } + } + + return false; +} + +async function getAutoAuthorizationHeader(url: URL): Promise { + const host = url.hostname.toLowerCase(); + if (host !== "management.azure.com" && host !== "dev.azure.com") { + return null; + } + + const config = await loadPublicConfig(); + if (!config.tenantId) { + throw new Error("tenantId is required"); + } + if (!config.clientId) { + throw new Error("clientId is required"); + } + + if (host === "management.azure.com") { + const result = await acquireResourceTokenFromLogin({ + tenantId: config.tenantId, + clientId: config.clientId, + resource: "arm", + }); + const accessToken = result?.accessToken; + if (!accessToken) { + throw new Error("Failed to obtain AzureRM token"); + } + return `Bearer ${accessToken}`; + } + + const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId); + return `Bearer ${accessToken}`; +} + function filterByPermissionName(rows: T[], pattern: string): T[] { return rows.filter((item) => minimatch(item.permissionValue ?? "", pattern, { nocase: true }) @@ -187,6 +250,55 @@ async function runGetTokenCommand(values: CommandValues): Promise { throw new Error(`Invalid --type '${values.type}'. Allowed: azurerm, devops`); } +async function runRestCommand(values: CommandValues): Promise { + const method = (values.method ?? "GET").toString().trim().toUpperCase() || "GET"; + const urlValue = (values.url ?? "").toString().trim(); + + if (!urlValue) { + throw new Error("--url is required for rest"); + } + + let targetUrl: URL; + try { + targetUrl = new URL(urlValue); + } catch { + throw new Error(`Invalid --url '${urlValue}'`); + } + + const headers = new Headers(); + const customHeader = parseHeaderLine(values.header); + if (customHeader) { + headers.set(customHeader.name, customHeader.value); + } + + if (!hasAuthorizationHeader(headers)) { + const authorization = await getAutoAuthorizationHeader(targetUrl); + if (authorization) { + headers.set("Authorization", authorization); + } + } + + const response = await fetch(targetUrl, { + method, + headers, + }); + + const contentType = response.headers.get("content-type") ?? ""; + let body: unknown; + if (contentType.toLowerCase().includes("application/json")) { + body = await response.json(); + } else { + body = await response.text(); + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body, + }; +} + export async function runCommand(command: string, values: CommandValues): Promise { switch (command) { case "login": @@ -205,6 +317,8 @@ export async function runCommand(command: string, values: CommandValues): Promis return runListResourcePermissionsCommand(values); case "get-token": return runGetTokenCommand(values); + case "rest": + return runRestCommand(values); default: throw new Error(`Unknown command: ${command}`); }