feat(cli): add graph commands with query and table output
This commit is contained in:
@@ -11,12 +11,16 @@
|
|||||||
"@azure/msal-node": "^5.0.3",
|
"@azure/msal-node": "^5.0.3",
|
||||||
"@azure/msal-node-extensions": "^1.2.0",
|
"@azure/msal-node-extensions": "^1.2.0",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@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": {
|
"author": {
|
||||||
"name": "Sławomir Koszewski",
|
"name": "Sławomir Koszewski",
|
||||||
"email": "slawek@koszewscy.waw.pl"
|
"email": "slawek@koszewscy.waw.pl"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"sk-az-tools": "./src/cli.js"
|
||||||
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.js",
|
".": "./src/index.js",
|
||||||
|
|||||||
106
src/cli.js
Executable file
106
src/cli.js
Executable file
@@ -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 <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
list-apps [--display-name <name>]
|
||||||
|
list-app-permissions --app-id <appId>
|
||||||
|
list-app-grants --app-id <appId>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--display-name <name> Filter apps by exact display name
|
||||||
|
--app-id <appId> Application (client) ID
|
||||||
|
--query <jmespath> Filter output JSON using JMESPath
|
||||||
|
--output <format> 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);
|
||||||
|
});
|
||||||
@@ -30,3 +30,88 @@ export async function createApp(client, displayName) {
|
|||||||
export async function deleteApp(client, appObjectId) {
|
export async function deleteApp(client, appObjectId) {
|
||||||
await client.api(`/applications/${appObjectId}`).delete();
|
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<Array> }
|
||||||
|
*/
|
||||||
|
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<Array> }
|
||||||
|
*/
|
||||||
|
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<Array> }
|
||||||
|
*/
|
||||||
|
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 : [];
|
||||||
|
}
|
||||||
|
|||||||
75
src/markdown.js
Normal file
75
src/markdown.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
function formatCell(value) {
|
||||||
|
const text = value == null
|
||||||
|
? ""
|
||||||
|
: String(value);
|
||||||
|
return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user