Add REST command support with method and URL options
All checks were successful
build / build (push) Successful in 12s

This commit is contained in:
2026-03-05 23:18:47 +01:00
parent 9f023d44cc
commit 9581ee1a31
2 changed files with 137 additions and 1 deletions

View File

@@ -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 <value> Token type: azurerm|devops`;
}
function usageRest(): string {
return `Usage: sk-az-tools rest [--method <httpMethod>] --url <url> [--header <name: value>] [global options]
Options:
--method <httpMethod> HTTP method (default: GET; examples: GET, POST, PATCH, DELETE)
--url <url> Full URL to call
--header <name: value> 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 <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [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<void> {
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<void> {
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);
}

View File

@@ -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<string | null> {
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<T extends PermissionRow>(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<unknown> {
throw new Error(`Invalid --type '${values.type}'. Allowed: azurerm, devops`);
}
async function runRestCommand(values: CommandValues): Promise<unknown> {
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<unknown> {
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}`);
}