diff --git a/package.json b/package.json index 1508f41..ecfe3a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slawek/sk-az-tools", - "version": "0.2.0", + "version": "0.2.1", "type": "module", "files": [ "dist", @@ -11,7 +11,8 @@ "clean": "rm -rf dist", "build": "npm run clean && tsc && chmod +x dist/cli.js", "build:watch": "tsc --watch", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "create-pca": "node dist/create-pca.js" }, "engines": { "node": ">=24.0.0" diff --git a/src/create-pca.ts b/src/create-pca.ts new file mode 100644 index 0000000..e5de63c --- /dev/null +++ b/src/create-pca.ts @@ -0,0 +1,267 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import { spawnSync } from "node:child_process"; +import { parseArgs } from "node:util"; + +type RunAzOptions = { + quiet?: boolean; + allowFailure?: boolean; +}; + +type RunAzResult = { + status: number; + stdout: string; + stderr: string; +}; + +function runAz(args: string[], options: RunAzOptions = {}): RunAzResult { + const result = spawnSync("az", args, { + encoding: "utf8", + stdio: options.quiet + ? ["ignore", "ignore", "ignore"] + : ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0 && options.allowFailure !== true) { + throw new Error( + (result.stderr || "").trim() || `az ${args.join(" ")} failed`, + ); + } + + return { + status: result.status ?? 1, + stdout: (result.stdout || "").trim(), + stderr: (result.stderr || "").trim(), + }; +} + +async function main(): Promise { + const usageText = `Usage: ${path.basename(process.argv[1])} [options] +Options: + -c, --config Write JSON config to file (optional) + -h, --help Show this help message and exit`; + + let values: Record; + let positionals: string[]; + try { + ({ values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + help: { type: "boolean", short: "h" }, + config: { type: "string", short: "c" }, + }, + strict: true, + allowPositionals: true, + })); + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + console.error(usageText); + process.exit(1); + } + + if (values.help) { + console.log(usageText); + process.exit(0); + } + + if (positionals.length > 1) { + console.error( + "Error: Too many positional arguments. Only one app name positional argument is allowed.", + ); + console.error(usageText); + process.exit(1); + } + + const appName = positionals[0] || ""; + const configPath = typeof values.config === "string" ? values.config : ""; + + if (!appName) { + console.error("Error: Application name is required."); + console.error(usageText); + process.exit(1); + } + + let appId = runAz([ + "ad", + "app", + "list", + "--display-name", + appName, + "--query", + "[0].appId", + "-o", + "tsv", + ]).stdout; + + if (appId) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + const answer = await new Promise((resolve) => { + rl.question( + `Application '${appName}' already exists. Update it? [y/N]: `, + (answerValue) => { + rl.close(); + resolve(answerValue.trim()); + }, + ); + }); + + if (!/^(yes|y)$/i.test(answer)) { + console.error("Canceled."); + process.exit(0); + } + } + + if (!appId) { + appId = runAz([ + "ad", + "app", + "create", + "--display-name", + appName, + "--query", + "appId", + "-o", + "tsv", + ]).stdout; + + if (!appId) { + console.error(`Error: Failed to create application '${appName}'.`); + process.exit(1); + } + } + + const requiredResourceAccess = [ + { + resourceAppId: "00000003-0000-0000-c000-000000000000", + resourceAccess: [ + { id: "0e263e50-5827-48a4-b97c-d940288653c7", type: "Scope" }, + ], + }, + { + resourceAppId: "499b84ac-1321-427f-aa17-267ca6975798", + resourceAccess: [ + { id: "ee69721e-6c3a-468f-a9ec-302d16a4c599", type: "Scope" }, + ], + }, + { + resourceAppId: "797f4846-ba00-4fd7-ba43-dac1f8f63013", + resourceAccess: [ + { id: "41094075-9dad-400e-a0bd-54e686782033", type: "Scope" }, + ], + }, + ]; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pca-")); + const requiredResourceAccessFile = path.join( + tempDir, + "required-resource-accesses.json", + ); + + try { + fs.writeFileSync( + requiredResourceAccessFile, + JSON.stringify(requiredResourceAccess, null, 2), + "utf8", + ); + + try { + runAz( + [ + "ad", + "app", + "update", + "--id", + appId, + "--sign-in-audience", + "AzureADMyOrg", + "--is-fallback-public-client", + "true", + "--required-resource-accesses", + `@${requiredResourceAccessFile}`, + "--public-client-redirect-uris", + "http://localhost", + `msal${appId}://auth`, + "--enable-access-token-issuance", + "true", + "--enable-id-token-issuance", + "true", + ], + { quiet: true }, + ); + } catch { + console.error( + `Error: Failed to configure application '${appName}' (${appId}).`, + ); + process.exit(1); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + runAz(["ad", "sp", "create", "--id", appId], { + quiet: true, + allowFailure: true, + }); + + const adminConsentResult = runAz( + ["ad", "app", "permission", "admin-consent", "--id", appId], + { quiet: true, allowFailure: true }, + ); + if (adminConsentResult.status !== 0) { + console.warn( + `Warning: Failed to grant admin consent for '${appName}' (${appId}). Continuing without failing.`, + ); + if (adminConsentResult.stderr) { + console.warn(adminConsentResult.stderr); + } + } + + const tenantId = runAz([ + "account", + "show", + "--query", + "tenantId", + "-o", + "tsv", + ]).stdout; + if (!tenantId) { + console.error( + "Error: Failed to resolve tenantId from current Azure CLI context.", + ); + process.exit(1); + } + + const configTemplate = JSON.stringify( + { + tenantId, + clientId: appId, + }, + null, + 2, + ); + + if (configPath) { + const targetPath = path.resolve(configPath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${configTemplate}\n`, "utf8"); + } + + console.log(configTemplate); +} + +main().catch((err) => { + console.error(`Error: ${(err as Error).message}`); + process.exit(1); +});