diff --git a/.npmignore b/.npmignore index f489581..061c5e1 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,11 @@ -docs +src/ +*.ts +tsconfig.json +scripts/ +Dockerfile +.git +.gitignore +artifacts/ node_modules -package-lock.json \ No newline at end of file +package-lock.json +docs \ No newline at end of file diff --git a/README.md b/README.md index 40c2214..255cca8 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,33 @@ This repository contains a collection of NodeJS modules that facilitate interaction with Azure and Graph authentication and management of selected Entra ID objects and Azure resources. +## Installation + +```bash +npm install @slawek/sk-az-tools +``` + +## Development + +### Build from TypeScript + +```bash +npm run build +``` + +### Watch mode + +```bash +npm run build:watch +``` + +### CLI smoke check + +```bash +node dist/cli.js --help +``` + +## Publishing + +The package is published from compiled output in `dist/`. See `docs/PACKAGING.md` for the complete release workflow. + diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 2a36ded..62e2407 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -1,94 +1,54 @@ -# Developing `hello-world` (ESM) Summary +# Packaging sk-az-tools -## Minimal Layout -- `package.json`, `README.md`, `src/index.js` (ESM only). -- `package.json` uses `"type": "module"` and explicit `exports`. -- `files` allow-list to control shipped content. +## Build model -Example `package.json`: -```json -{ - "name": "hello-world", - "version": "1.0.0", - "type": "module", - "exports": { - ".": "./src/index.js" - }, - "files": ["src", "README.md", "package.json"], - "engines": { - "node": ">=18" - } -} -``` +- Source lives in `src/` as TypeScript (`.ts`). +- Runtime package is compiled to `dist/` using `npm run build`. +- Public package entrypoints (`exports` and `bin`) point to `dist/**`. -Example `src/index.js`: -```js -export function helloWorld() { - console.log("Hello World!!!"); -} -``` +## Package surface -## Sub-modules (Subpath Exports) -- Expose sub-modules using explicit subpaths in `exports`. -- Keep public API small and intentional. +- `exports` defines what consumers can import. +- `files` controls what is shipped to npm. +- Current shipping content is `dist`, `README.md`, and `LICENSE`. -Example: -```json -{ - "exports": { - ".": "./src/index.js", - "./greetings": "./src/greetings.js", - "./callouts": "./src/callouts.js" - } -} -``` +## Development workflow -## `exports` vs `files` -- `exports` defines the public import surface (what consumers can import). -- `files` defines what gets packaged; supports globs and negation. +Build once: -Example: -```json -{ - "files": ["dist/**", "README.md", "!dist/**/*.map"] -} -``` - -## Dev Workflow (Separate Repo, Live Updates) -- Use `npm link` for live-edit development across repos. - -Publisher repo: ```bash -npm link +npm run build ``` -Consumer repo: +Build in watch mode: + ```bash -npm link hello-world +npm run build:watch ``` -Notes: -- If you build to `dist/`, run a watch build so the consumer sees updates. -- Unlink when done: +Smoke check CLI output: + ```bash -npm unlink hello-world +node dist/cli.js --help ``` -## Distribution as a `.tgz` Artifact -- Create a tarball with `npm pack` and distribute the `.tgz` file. -- Install directly from the tarball path. -- Use `npm pack --dry-run` to verify contents before sharing. -- The `.tgz` is written to the current working directory where `npm pack` is run. -- You can redirect output with `--pack-destination` (or `pack-destination` config). -- Ship `package.json` in the artifact, but exclude `package-lock.json` (keep it in the repo for development only). +## Publish checklist -Commands: -```bash -npm pack -npm install ./hello-world-1.0.0.tgz -``` +1. Run `npm run build` and ensure TypeScript compiles without errors. +2. Verify package content with `npm pack --dry-run`. +3. Create artifact: `npm pack --pack-destination ./artifacts`. +4. Optionally install the artifact locally and validate CLI/imports. + +## Tarball usage + +Create package tarball: -Example with output directory: ```bash npm pack --pack-destination ./artifacts ``` + +Install from tarball: + +```bash +npm install ./artifacts/@slawek/sk-az-tools-.tgz +``` diff --git a/package.json b/package.json index 2db088c..d2bb5ff 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,17 @@ "name": "@slawek/sk-az-tools", "version": "0.1.0", "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc", + "build:watch": "tsc --watch", + "prepublishOnly": "npm run build" + }, "engines": { "node": ">=24.0.0" }, @@ -13,20 +24,26 @@ "@microsoft/microsoft-graph-client": "^3.0.7", "azure-devops-node-api": "^15.1.2", "jmespath": "^0.16.0", - "minimatch": "^10.1.2" + "minimatch": "^10.1.2", + "open": "^10.1.0" + }, + "devDependencies": { + "@types/jmespath": "^0.15.2", + "@types/node": "^24.0.0", + "typescript": "^5.8.2" }, "author": { "name": "Sławomir Koszewski", "email": "slawek@koszewscy.waw.pl" }, "bin": { - "sk-az-tools": "./src/cli.js" + "sk-az-tools": "./dist/cli.js" }, "license": "MIT", "exports": { - ".": "./src/index.js", - "./azure": "./src/azure/index.js", - "./graph": "./src/graph/index.js", - "./devops": "./src/devops/index.js" + ".": "./dist/index.js", + "./azure": "./dist/azure/index.js", + "./graph": "./dist/graph/index.js", + "./devops": "./dist/devops/index.js" } } diff --git a/src/azure/client-auth.js b/src/azure/client-auth.ts similarity index 75% rename from src/azure/client-auth.js rename to src/azure/client-auth.ts index f86cc7c..6f782c5 100644 --- a/src/azure/client-auth.js +++ b/src/azure/client-auth.ts @@ -2,7 +2,18 @@ import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity"; -export async function getCredential(credentialType, options) { +type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode"; + +type CredentialOptions = { + tenantId?: string; + clientId?: string; + clientSecret?: string; +}; + +export async function getCredential( + credentialType: CredentialType, + options: CredentialOptions, +): Promise { switch (credentialType) { case "d": case "default": diff --git a/src/azure/index.d.ts b/src/azure/index.d.ts deleted file mode 100644 index ab0c014..0000000 --- a/src/azure/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -// \ No newline at end of file diff --git a/src/azure/index.js b/src/azure/index.ts similarity index 75% rename from src/azure/index.js rename to src/azure/index.ts index 3bb69c5..2bd1f74 100644 --- a/src/azure/index.js +++ b/src/azure/index.ts @@ -2,12 +2,11 @@ /** * @module azure - * + * * This module provides authentication functionalities for Azure services. - * */ -export { getCredential } from "./client-auth.js"; +export { getCredential } from "./client-auth.ts"; export { loginInteractive, loginDeviceCode, @@ -15,4 +14,4 @@ export { logout, parseResources, acquireResourceTokenFromLogin, -} from "./pca-auth.js"; +} from "./pca-auth.ts"; diff --git a/src/azure/pca-auth.js b/src/azure/pca-auth.ts similarity index 69% rename from src/azure/pca-auth.js rename to src/azure/pca-auth.ts index dd7eee8..9065c4a 100644 --- a/src/azure/pca-auth.js +++ b/src/azure/pca-auth.ts @@ -6,20 +6,76 @@ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import path from "node:path"; import { PublicClientApplication } from "@azure/msal-node"; +import type { + AccountInfo, + AuthenticationResult, + ICachePlugin, + TokenCacheContext, +} from "@azure/msal-node"; import os from "node:os"; const RESOURCE_SCOPE_BY_NAME = { graph: "https://graph.microsoft.com/.default", devops: "499b84ac-1321-427f-aa17-267ca6975798/.default", arm: "https://management.azure.com/.default", -}; +} as const; -const DEFAULT_RESOURCES = ["graph", "devops", "arm"]; +type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME; + +const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"]; const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login"; const BROWSER_KEYWORDS = Object.keys(apps).sort(); +const OPEN_APPS = apps as Record; const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]); -function getCacheRoot() { +type SessionState = { + activeAccountUpn: string | null; +}; + +type BrowserOptions = { + browser?: string; + browserProfile?: string; +}; + +type LoginInteractiveOptions = { + tenantId?: string; + clientId?: string; + scopes: string[]; + showAuthUrlOnly?: boolean; + browser?: string; + browserProfile?: string; +}; + +type LoginDeviceCodeOptions = { + tenantId?: string; + clientId?: string; + scopes: string[]; +}; + +type LoginOptions = { + tenantId?: string; + clientId?: string; + resourcesCsv?: string; + useDeviceCode?: boolean; + noBrowser?: boolean; + browser?: string; + browserProfile?: string; +}; + +type AcquireResourceTokenOptions = { + tenantId?: string; + clientId?: string; + resource?: string; +}; + +type LogoutOptions = { + tenantId?: string; + clientId?: string; + clearAll?: boolean; + userPrincipalName?: string; +}; + +function getCacheRoot(): string { const isWindows = process.platform === "win32"; const userRoot = isWindows ? process.env.LOCALAPPDATA || os.homedir() @@ -30,14 +86,14 @@ function getCacheRoot() { : path.join(userRoot, ".config", "sk-az-tools"); } -function getSessionFilePath() { +function getSessionFilePath(): string { return path.join(getCacheRoot(), "login-session.json"); } -async function readSessionState() { +async function readSessionState(): Promise { try { const sessionJson = await readFile(getSessionFilePath(), "utf8"); - const parsed = JSON.parse(sessionJson); + const parsed = JSON.parse(sessionJson) as { activeAccountUpn?: unknown }; return { activeAccountUpn: typeof parsed?.activeAccountUpn === "string" @@ -45,40 +101,40 @@ async function readSessionState() { : null, }; } catch (err) { - if (err?.code === "ENOENT") { + if ((err as { code?: string } | null)?.code === "ENOENT") { return { activeAccountUpn: null }; } throw err; } } -async function writeSessionState(state) { +async function writeSessionState(state: SessionState): Promise { const sessionPath = getSessionFilePath(); await mkdir(path.dirname(sessionPath), { recursive: true }); await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8"); } -async function clearSessionState() { +async function clearSessionState(): Promise { try { await unlink(getSessionFilePath()); } catch (err) { - if (err?.code !== "ENOENT") { + if ((err as { code?: string } | null)?.code !== "ENOENT") { throw err; } } } -function normalizeUpn(upn) { +function normalizeUpn(upn: unknown): string { return typeof upn === "string" ? upn.trim().toLowerCase() : ""; } -function writeStderr(message) { +function writeStderr(message: string): void { process.stderr.write(`${message}\n`); } -function getBrowserAppName(browser) { +function getBrowserAppName(browser?: string): string | readonly string[] | undefined { if (!browser || browser.trim() === "") { - return null; + return undefined; } const keyword = BROWSER_KEYWORDS.find( @@ -90,10 +146,10 @@ function getBrowserAppName(browser) { ); } - return apps[keyword]; + return OPEN_APPS[keyword]; } -function getBrowserKeyword(browser) { +function getBrowserKeyword(browser?: string): string { if (!browser || browser.trim() === "") { return ""; } @@ -109,14 +165,13 @@ function getBrowserKeyword(browser) { return keyword.toLowerCase(); } -function getBrowserOpenOptions({ browser, browserProfile }) { +function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters[1] { const browserName = getBrowserAppName(browser); - const options = browserName - ? { wait: false, app: { name: browserName } } - : { wait: false }; if (!browserProfile || browserProfile.trim() === "") { - return options; + return browserName + ? { wait: false, app: { name: browserName } } + : { wait: false }; } const browserKeyword = getBrowserKeyword(browser); @@ -126,11 +181,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) { ); } - options.app.arguments = [`--profile-directory=${browserProfile.trim()}`]; - return options; + if (!browserName) { + throw new Error("--browser-profile requires --browser"); + } + + return { + wait: false, + app: { + name: browserName, + arguments: [`--profile-directory=${browserProfile.trim()}`], + }, + }; } -function validateBrowserOptions({ browser, browserProfile }) { +function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): void { if (browser && browser.trim() !== "") { getBrowserAppName(browser); } @@ -145,7 +209,7 @@ function validateBrowserOptions({ browser, browserProfile }) { } } -export function parseResources(resourcesCsv) { +export function parseResources(resourcesCsv?: string): ResourceName[] { if (!resourcesCsv || resourcesCsv.trim() === "") { return [...DEFAULT_RESOURCES]; } @@ -156,24 +220,24 @@ export function parseResources(resourcesCsv) { .filter(Boolean); const unique = [...new Set(resources)]; - const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]); + const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name)); if (invalid.length > 0) { throw new Error( `Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`, ); } - return unique; + return unique as ResourceName[]; } -function fileCachePlugin(cachePath) { +function fileCachePlugin(cachePath: string): ICachePlugin { return { - beforeCacheAccess: async (ctx) => { + beforeCacheAccess: async (ctx: TokenCacheContext) => { if (fs.existsSync(cachePath)) { ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8")); } }, - afterCacheAccess: async (ctx) => { + afterCacheAccess: async (ctx: TokenCacheContext) => { if (!ctx.cacheHasChanged) return; fs.mkdirSync(path.dirname(cachePath), { recursive: true }); fs.writeFileSync(cachePath, ctx.tokenCache.serialize()); @@ -182,10 +246,10 @@ function fileCachePlugin(cachePath) { }; } -async function createPca({ tenantId, clientId }) { +async function createPca({ tenantId, clientId }: { tenantId: string; clientId: string }): Promise { const cacheRoot = getCacheRoot(); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); - let cachePlugin; + let cachePlugin: ICachePlugin; try { const { DataProtectionScope, @@ -201,7 +265,7 @@ async function createPca({ tenantId, clientId }) { usePlaintextFileOnLinux: true, }); cachePlugin = new PersistenceCachePlugin(persistence); - } catch (err) { + } catch { // Fallback when msal-node-extensions/keytar/libsecret are unavailable. cachePlugin = fileCachePlugin(cachePath); } @@ -217,7 +281,15 @@ async function createPca({ tenantId, clientId }) { }); } -async function acquireTokenWithCache({ pca, scopes, account }) { +async function acquireTokenWithCache({ + pca, + scopes, + account, +}: { + pca: PublicClientApplication; + scopes: string[]; + account?: AccountInfo | null; +}): Promise { if (account) { try { return await pca.acquireTokenSilent({ @@ -244,7 +316,13 @@ async function acquireTokenWithCache({ pca, scopes, account }) { return null; } -async function findAccountByUpn({ pca, upn }) { +async function findAccountByUpn({ + pca, + upn, +}: { + pca: PublicClientApplication; + upn: string | null; +}): Promise { const normalized = normalizeUpn(upn); if (!normalized) { return null; @@ -264,11 +342,12 @@ export async function loginInteractive({ showAuthUrlOnly = false, browser, browserProfile, -}) { +}: LoginInteractiveOptions): Promise { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); - if (!Array.isArray(scopes) || scopes.length === 0) + if (!Array.isArray(scopes) || scopes.length === 0) { throw new Error("scopes[] is required"); + } validateBrowserOptions({ browser, browserProfile }); const pca = await createPca({ tenantId, clientId }); @@ -276,33 +355,34 @@ export async function loginInteractive({ const cached = await acquireTokenWithCache({ pca, scopes }); if (cached) return cached; - return await pca.acquireTokenInteractive({ + return pca.acquireTokenInteractive({ scopes, - openBrowser: async (url) => { + openBrowser: async (url: string) => { if (showAuthUrlOnly) { writeStderr(`Visit:\n${url}`); return; } const options = getBrowserOpenOptions({ browser, browserProfile }); - return open(url, options).catch(() => { + await open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); }, }); } -export async function loginDeviceCode({ tenantId, clientId, scopes }) { +export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); - if (!Array.isArray(scopes) || scopes.length === 0) + if (!Array.isArray(scopes) || scopes.length === 0) { throw new Error("scopes[] is required"); + } const pca = await createPca({ tenantId, clientId }); const cached = await acquireTokenWithCache({ pca, scopes }); if (cached) return cached; - return await pca.acquireTokenByDeviceCode({ + return pca.acquireTokenByDeviceCode({ scopes, deviceCodeCallback: (response) => { writeStderr(response.message); @@ -318,7 +398,12 @@ export async function login({ noBrowser = false, browser, browserProfile, -}) { +}: LoginOptions): Promise<{ + accountUpn: string | null; + resources: Array<{ resource: string; expiresOn: string | null }>; + flow: "device-code" | "interactive"; + browserLaunchAttempted: boolean; +}> { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); validateBrowserOptions({ browser, browserProfile }); @@ -332,8 +417,8 @@ export async function login({ upn: session.activeAccountUpn, }); - const results = []; - let selectedAccount = preferredAccount; + const results: Array<{ resource: string; expiresOn: string | null }> = []; + let selectedAccount: AccountInfo | null = preferredAccount; for (let index = 0; index < resources.length; index += 1) { const resource = resources[index]; const scope = [scopes[index]]; @@ -354,13 +439,13 @@ export async function login({ } else { token = await pca.acquireTokenInteractive({ scopes: scope, - openBrowser: async (url) => { + openBrowser: async (url: string) => { if (noBrowser) { writeStderr(`Visit:\n${url}`); return; } const options = getBrowserOpenOptions({ browser, browserProfile }); - return open(url, options).catch(() => { + await open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); }, @@ -395,16 +480,17 @@ export async function acquireResourceTokenFromLogin({ tenantId, clientId, resource, -}) { +}: AcquireResourceTokenOptions): Promise { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); if (!resource) throw new Error("resource is required"); - const scope = RESOURCE_SCOPE_BY_NAME[resource]; - if (!scope) { + if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) { throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`); } + const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName]; + const session = await readSessionState(); if (!session.activeAccountUpn) { throw new Error(LOGIN_REQUIRED_MESSAGE); @@ -434,7 +520,7 @@ export async function logout({ clientId, clearAll = false, userPrincipalName, -}) { +}: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); @@ -450,7 +536,7 @@ export async function logout({ await clearSessionState(); return { clearedAll: true, - signedOut: accounts.map((account) => account.username).filter(Boolean), + signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)), }; } @@ -468,6 +554,6 @@ export async function logout({ await clearSessionState(); return { clearedAll: false, - signedOut: [accountToSignOut.username].filter(Boolean), + signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)), }; } diff --git a/src/cli.js b/src/cli.ts old mode 100755 new mode 100644 similarity index 78% rename from src/cli.js rename to src/cli.ts index 4e1fb93..97d2009 --- a/src/cli.js +++ b/src/cli.ts @@ -3,16 +3,35 @@ import { parseArgs } from "node:util"; -import { runCommand } from "./cli/commands.js"; +import { runCommand } from "./cli/commands.ts"; import { normalizeOutputFormat, omitPermissionGuidColumns, outputFiltered, parseHeaderSpec, renderOutput, -} from "./cli/utils.js"; +} from "./cli/utils.ts"; -function usage() { +type CliValues = { + help?: boolean; + "display-name"?: string; + "app-id"?: string; + resources?: string; + "use-device-code"?: boolean; + "no-browser"?: boolean; + browser?: string; + "browser-profile"?: string; + all?: boolean; + resolve?: boolean; + short?: boolean; + filter?: string; + query?: string; + header?: string; + output?: string; + [key: string]: string | boolean | undefined; +}; + +function usage(): string { return `Usage: sk-az-tools [options] Commands: @@ -33,7 +52,7 @@ Use: sk-az-tools --help or: sk-az-tools --help`; } -function usageListApps() { +function usageListApps(): string { return `Usage: sk-az-tools list-apps [--display-name|-n ] [--app-id|-i ] [--filter|-f ] [global options] Options: @@ -42,7 +61,7 @@ Options: -f, --filter Filter by app display name glob`; } -function usageLogin() { +function usageLogin(): string { return `Usage: sk-az-tools login [--resources ] [--use-device-code] [--no-browser] [--browser ] [--browser-profile ] [global options] Options: @@ -53,14 +72,14 @@ Options: --browser-profile Chromium profile name (e.g. Default, "Profile 1")`; } -function usageLogout() { +function usageLogout(): string { return `Usage: sk-az-tools logout [--all] [global options] Options: --all Clear login state and remove all cached accounts`; } -function usageListAppPermissions() { +function usageListAppPermissions(): string { return `Usage: sk-az-tools list-app-permissions --app-id|-i [--resolve|-r] [--short|-s] [--filter|-f ] [global options] Options: @@ -70,14 +89,14 @@ Options: -f, --filter Filter by permission name glob`; } -function usageListAppGrants() { +function usageListAppGrants(): string { return `Usage: sk-az-tools list-app-grants --app-id|-i [global options] Options: -i, --app-id Application (client) ID (required)`; } -function usageListResourcePermissions() { +function usageListResourcePermissions(): string { return `Usage: sk-az-tools list-resource-permissions [--app-id|-i | --display-name|-n ] [--filter|-f ] [global options] Options: @@ -86,14 +105,14 @@ Options: -f, --filter Filter by permission name glob`; } -function usageTable() { +function usageTable(): string { return `Usage: sk-az-tools table [--header|-H ] [global options] Options: -H, --header Header mode/spec: auto|a (default), original|o, OR "col1, col2" OR "key1: Label 1, key2: Label 2"`; } -function usageCommand(command) { +function usageCommand(command: string): string { switch (command) { case "login": return usageLogin(); @@ -114,7 +133,7 @@ function usageCommand(command) { } } -async function main() { +async function main(): Promise { const argv = process.argv.slice(2); const command = argv[0]; if (!command) { @@ -150,24 +169,27 @@ async function main() { allowPositionals: false, }); - if (values.help) { + const typedValues = values as CliValues; + + if (typedValues.help) { console.log(usageCommand(command)); process.exit(0); } - const outputFormat = normalizeOutputFormat(values.output); - const result = await runCommand(command, values); - const filtered = outputFiltered(result, values.query); - const output = command === "list-app-permissions" && values.short + const outputFormat = normalizeOutputFormat(typedValues.output); + const result = await runCommand(command, typedValues); + const filtered = outputFiltered(result, typedValues.query); + const output = command === "list-app-permissions" && typedValues.short ? omitPermissionGuidColumns(filtered) : filtered; - const headerSpec = parseHeaderSpec(values.header); + const headerSpec = parseHeaderSpec(typedValues.header); renderOutput(command, output, outputFormat, headerSpec); } -main().catch((err) => { - console.error(`Error: ${err.message}`); +main().catch((err: unknown) => { + const error = err as Error; + console.error(`Error: ${error.message}`); console.error(usage()); process.exit(1); }); diff --git a/src/cli/commands.js b/src/cli/commands.ts similarity index 68% rename from src/cli/commands.js rename to src/cli/commands.ts index 1f45c24..16fcf5b 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.ts @@ -2,32 +2,55 @@ import { minimatch } from "minimatch"; -import { loadPublicConfig } from "../index.js"; -import { getGraphClient } from "../graph/auth.js"; -import { login, logout } from "../azure/index.js"; +import { loadPublicConfig } from "../index.ts"; +import { getGraphClient } from "../graph/auth.ts"; +import { login, logout } from "../azure/index.ts"; import { listApps, listAppPermissions, listAppPermissionsResolved, listAppGrants, listResourcePermissions, -} from "../graph/app.js"; -import { readJsonFromStdin } from "./utils.js"; +} from "../graph/app.ts"; +import { readJsonFromStdin } from "./utils.ts"; -function filterByPermissionName(rows, pattern) { +type CommandValues = { + [key: string]: string | boolean | undefined; + resources?: string; + "use-device-code"?: boolean; + "no-browser"?: boolean; + browser?: string; + "browser-profile"?: string; + all?: boolean; + "display-name"?: string; + "app-id"?: string; + filter?: string; + resolve?: boolean; +}; + +type PermissionRow = { + permissionValue?: string | null; + permissionDisplayName?: string | null; +}; + +type DisplayNameRow = { + displayName?: string | null; +}; + +function filterByPermissionName(rows: T[], pattern: string): T[] { return rows.filter((item) => minimatch(item.permissionValue ?? "", pattern, { nocase: true }) - || minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true }) + || minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true }), ); } -function filterByDisplayName(rows, pattern) { +function filterByDisplayName(rows: T[], pattern: string): T[] { return rows.filter((item) => - minimatch(item.displayName ?? "", pattern, { nocase: true }) + minimatch(item.displayName ?? "", pattern, { nocase: true }), ); } -async function getGraphClientFromPublicConfig() { +async function getGraphClientFromPublicConfig(): Promise<{ client: any }> { const config = await loadPublicConfig(); return getGraphClient({ tenantId: config.tenantId, @@ -35,11 +58,11 @@ async function getGraphClientFromPublicConfig() { }); } -async function runTableCommand() { +async function runTableCommand(): Promise { return readJsonFromStdin(); } -async function runLoginCommand(values) { +async function runLoginCommand(values: CommandValues): Promise { const config = await loadPublicConfig(); return login({ tenantId: config.tenantId, @@ -52,7 +75,7 @@ async function runLoginCommand(values) { }); } -async function runLogoutCommand(values) { +async function runLogoutCommand(values: CommandValues): Promise { const config = await loadPublicConfig(); return logout({ tenantId: config.tenantId, @@ -61,7 +84,7 @@ async function runLogoutCommand(values) { }); } -async function runListAppsCommand(values) { +async function runListAppsCommand(values: CommandValues): Promise { const { client } = await getGraphClientFromPublicConfig(); let result = await listApps(client, { displayName: values["display-name"], @@ -76,7 +99,7 @@ async function runListAppsCommand(values) { return result; } -async function runListAppPermissionsCommand(values) { +async function runListAppPermissionsCommand(values: CommandValues): Promise { if (!values["app-id"]) { throw new Error("--app-id is required for list-app-permissions"); } @@ -91,7 +114,7 @@ async function runListAppPermissionsCommand(values) { return result; } -async function runListAppGrantsCommand(values) { +async function runListAppGrantsCommand(values: CommandValues): Promise { if (!values["app-id"]) { throw new Error("--app-id is required for list-app-grants"); } @@ -100,7 +123,7 @@ async function runListAppGrantsCommand(values) { return listAppGrants(client, values["app-id"]); } -async function runListResourcePermissionsCommand(values) { +async function runListResourcePermissionsCommand(values: CommandValues): Promise { if (!values["app-id"] && !values["display-name"]) { throw new Error("--app-id or --display-name is required for list-resource-permissions"); } @@ -119,7 +142,7 @@ async function runListResourcePermissionsCommand(values) { return result; } -export async function runCommand(command, values) { +export async function runCommand(command: string, values: CommandValues): Promise { switch (command) { case "login": return runLoginCommand(values); diff --git a/src/cli/utils.js b/src/cli/utils.ts similarity index 65% rename from src/cli/utils.js rename to src/cli/utils.ts index 1ee3053..6aa5f98 100644 --- a/src/cli/utils.js +++ b/src/cli/utils.ts @@ -2,15 +2,26 @@ import jmespath from "jmespath"; -import { toMarkdownTable } from "../markdown.js"; +import { toMarkdownTable } from "../markdown.ts"; -export function outputFiltered(object, query) { +type HeaderSpec = + | { mode: "auto" } + | { mode: "original" } + | { mode: "list"; labels: string[] } + | { mode: "map"; map: Record }; + +type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv"; + +type Scalar = string | number | boolean | null | undefined; +type ScalarRow = Record; + +export function outputFiltered(object: unknown, query?: string): unknown { return query ? jmespath.search(object, query) : object; } -export function parseHeaderSpec(headerValue) { +export function parseHeaderSpec(headerValue?: string): HeaderSpec { if (!headerValue) { return { mode: "auto" }; } @@ -30,7 +41,7 @@ export function parseHeaderSpec(headerValue) { return { mode: "list", labels: parts }; } - const map = {}; + const map: Record = {}; for (const part of parts) { const idx = part.indexOf(":"); if (idx < 0) { @@ -47,7 +58,7 @@ export function parseHeaderSpec(headerValue) { return { mode: "map", map }; } -export function normalizeOutputFormat(outputValue) { +export function normalizeOutputFormat(outputValue?: string): OutputFormat { if (outputValue == null) { return "json"; } @@ -66,16 +77,20 @@ export function normalizeOutputFormat(outputValue) { throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv"); } -function getScalarRowsAndHeaders(value) { - let rows; +function isScalar(value: unknown): value is Scalar { + return value == null || typeof value !== "object"; +} + +function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } { + let rows: Array>; if (Array.isArray(value)) { rows = value.map((item) => item && typeof item === "object" && !Array.isArray(item) - ? item + ? item as Record : { value: item }, ); } else if (value && typeof value === "object") { - rows = [value]; + rows = [value as Record]; } else { rows = [{ value }]; } @@ -89,10 +104,7 @@ function getScalarRowsAndHeaders(value) { const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] .filter((key) => - rows.every((row) => { - const v = row[key]; - return v == null || typeof v !== "object"; - }), + rows.every((row) => isScalar(row[key])), ); if (headers.length === 0) { @@ -102,10 +114,20 @@ function getScalarRowsAndHeaders(value) { }; } - return { headers, rows }; + const scalarRows: ScalarRow[] = rows.map((row) => { + const result: ScalarRow = {}; + for (const [key, rowValue] of Object.entries(row)) { + if (isScalar(rowValue)) { + result[key] = rowValue; + } + } + return result; + }); + + return { headers, rows: scalarRows }; } -function toTsv(value) { +function toTsv(value: unknown): string { const { headers, rows } = getScalarRowsAndHeaders(value); const lines = rows.map((row) => headers @@ -115,22 +137,24 @@ function toTsv(value) { return lines.join("\n"); } -export function omitPermissionGuidColumns(value) { +export function omitPermissionGuidColumns(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) => omitPermissionGuidColumns(item)); } if (!value || typeof value !== "object") { return value; } - const { resourceAppId, permissionId, ...rest } = value; + const { resourceAppId, permissionId, ...rest } = value as Record; + void resourceAppId; + void permissionId; return rest; } -export async function readJsonFromStdin() { - const input = await new Promise((resolve, reject) => { +export async function readJsonFromStdin(): Promise { + const input = await new Promise((resolve, reject) => { let data = ""; process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { + process.stdin.on("data", (chunk: string) => { data += chunk; }); process.stdin.on("end", () => { @@ -145,13 +169,18 @@ export async function readJsonFromStdin() { } try { - return JSON.parse(input); + return JSON.parse(input) as unknown; } catch (err) { - throw new Error(`Invalid JSON input on stdin: ${err.message}`); + throw new Error(`Invalid JSON input on stdin: ${(err as Error).message}`); } } -export function renderOutput(command, output, outputFormat, headerSpec) { +export function renderOutput( + command: string, + output: unknown, + outputFormat: OutputFormat, + headerSpec: HeaderSpec, +): void { if (outputFormat === "tsv") { console.log(toTsv(output)); return; diff --git a/src/devops/index.d.ts b/src/devops/index.d.ts deleted file mode 100644 index ab0c014..0000000 --- a/src/devops/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -// \ No newline at end of file diff --git a/src/devops/index.js b/src/devops/index.js deleted file mode 100644 index a6b66e7..0000000 --- a/src/devops/index.js +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: MIT - -/** - * A DevOps helpers module. - */ - -import { loginInteractive } from "../azure/index.js"; -import * as azdev from "azure-devops-node-api"; - -const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"]; - -/** - * Get Azure DevOps API token. - * - * @param { string } tenantId - The Azure AD tenant ID - * @param { string } clientId - The Azure AD client ID - * @returns { Promise } Azure DevOps API access token - */ - -export async function getDevOpsApiToken(tenantId, clientId) { - const result = await loginInteractive({ - tenantId, - clientId, - scopes: AZURE_DEVOPS_SCOPES, - }); - - const accessToken = result?.accessToken; - - if(!accessToken) { - throw new Error("Failed to obtain Azure DevOps API token"); - } - - return accessToken; -} - -/** - * Get Azure DevOps clients - Core and Git. - * - * @param { string } orgUrl - The Azure DevOps organization URL - * @param { string } tenantId - The Azure AD tenant ID - * @param { string } clientId - The Azure AD client ID - * @returns { Promise<{ coreClient: Object, gitClient: Object }> } - */ - -export async function getDevOpsClients(orgUrl, tenantId, clientId) { - const accessToken = await getDevOpsApiToken(tenantId, clientId); - - const authHandler = azdev.getBearerHandler(accessToken); - const connection = new azdev.WebApi(orgUrl, authHandler); - - const coreClient = await connection.getCoreApi(); - const gitClient = await connection.getGitApi(); - - return { coreClient, gitClient }; -} diff --git a/src/devops/index.ts b/src/devops/index.ts new file mode 100644 index 0000000..60fdda7 --- /dev/null +++ b/src/devops/index.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +/** + * A DevOps helpers module. + */ + +import { loginInteractive } from "../azure/index.ts"; +import * as azdev from "azure-devops-node-api"; + +const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"]; + +type LoginInteractiveResult = { + accessToken?: string; +}; + +export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise { + const result = await loginInteractive({ + tenantId, + clientId, + scopes: AZURE_DEVOPS_SCOPES, + }) as LoginInteractiveResult; + + const accessToken = result?.accessToken; + + if (!accessToken) { + throw new Error("Failed to obtain Azure DevOps API token"); + } + + return accessToken; +} + +export async function getDevOpsClients(orgUrl: string, tenantId: string, clientId: string): Promise<{ coreClient: unknown; gitClient: unknown }> { + const accessToken = await getDevOpsApiToken(tenantId, clientId); + + const authHandler = azdev.getBearerHandler(accessToken); + const connection = new azdev.WebApi(orgUrl, authHandler); + + const coreClient = await connection.getCoreApi(); + const gitClient = await connection.getGitApi(); + + return { coreClient, gitClient }; +} diff --git a/src/graph/app.js b/src/graph/app.ts similarity index 61% rename from src/graph/app.js rename to src/graph/app.ts index 9cbe8a6..4fcb0ee 100644 --- a/src/graph/app.js +++ b/src/graph/app.ts @@ -1,51 +1,77 @@ // SPDX-License-Identifier: MIT -/** - * Get an Azure application by its display name. - * - * @param { Object } client - * @param { string } displayName - * @returns { Promise } - */ -export async function getApp(client, displayName) { +type GraphObject = Record; + +type GraphResult = { + value?: T[]; +}; + +type AppQueryOptions = { + displayName?: string; + appId?: string; +}; + +type RequiredResourceAccessItem = { + type?: string; + id?: string; +}; + +type RequiredResourceAccess = { + resourceAppId?: string; + resourceAccess?: RequiredResourceAccessItem[]; +}; + +type GraphPermission = { + id?: string; + value?: string; + displayName?: string; + adminConsentDisplayName?: string; + userConsentDisplayName?: string; + isEnabled?: boolean; +}; + +type ServicePrincipal = { + id?: string; + appId?: string; + displayName?: string; + oauth2PermissionScopes?: GraphPermission[]; + appRoles?: GraphPermission[]; +}; + +type ResourcePermissionsOptions = { + appId?: string; + displayName?: string; +}; + +export async function getApp(client: any, displayName: string): Promise { const result = await client .api("/applications") .filter(`displayName eq '${displayName}'`) - .get(); + .get() as GraphResult; - // Return the first application found or null if none exists - return result.value.length > 0 ? result.value[0] : null; + return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null; } -export async function createApp(client, displayName) { +export async function createApp(client: any, displayName: string): Promise { const app = await client.api("/applications").post({ displayName, - }); + }) as GraphObject; - if (!app || !app.appId) { + if (!app || typeof app.appId !== "string") { throw new Error("Failed to create application"); } return app; } -export async function deleteApp(client, appObjectId) { +export async function deleteApp(client: any, appObjectId: string): Promise { await client.api(`/applications/${appObjectId}`).delete(); } -/** - * List Azure applications, optionally filtered by display name and/or app ID. - * - * @param { Object } client - * @param { Object } [options] - * @param { string } [options.displayName] - * @param { string } [options.appId] - * @returns { Promise } - */ -export async function listApps(client, options = {}) { +export async function listApps(client: any, options: AppQueryOptions = {}): Promise { const { displayName, appId } = options; let request = client.api("/applications"); - const filters = []; + const filters: string[] = []; if (displayName) { filters.push(`displayName eq '${displayName}'`); @@ -58,18 +84,11 @@ export async function listApps(client, options = {}) { request = request.filter(filters.join(" and ")); } - const result = await request.get(); + const result = await request.get() as GraphResult; 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) { +export async function listAppPermissions(client: any, appId: string): Promise { if (!appId) { throw new Error("appId is required"); } @@ -78,7 +97,7 @@ export async function listAppPermissions(client, appId) { .api("/applications") .filter(`appId eq '${appId}'`) .select("id,appId,displayName,requiredResourceAccess") - .get(); + .get() as GraphResult; const app = Array.isArray(result?.value) && result.value.length > 0 ? result.value[0] @@ -88,19 +107,13 @@ export async function listAppPermissions(client, appId) { return []; } - return Array.isArray(app.requiredResourceAccess) - ? app.requiredResourceAccess + const requiredResourceAccess = app.requiredResourceAccess; + return Array.isArray(requiredResourceAccess) + ? requiredResourceAccess as RequiredResourceAccess[] : []; } -/** - * List required resource access in a resolved, human-readable form. - * - * @param { Object } client - * @param { string } appId - * @returns { Promise } - */ -export async function listAppPermissionsResolved(client, appId) { +export async function listAppPermissionsResolved(client: any, appId: string): Promise>> { const requiredResourceAccess = await listAppPermissions(client, appId); if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) { return []; @@ -109,7 +122,7 @@ export async function listAppPermissionsResolved(client, appId) { const resourceAppIds = [...new Set( requiredResourceAccess .map((entry) => entry?.resourceAppId) - .filter(Boolean), + .filter((value): value is string => typeof value === "string" && value.length > 0), )]; const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => { @@ -117,17 +130,21 @@ export async function listAppPermissionsResolved(client, appId) { .api("/servicePrincipals") .filter(`appId eq '${resourceAppId}'`) .select("appId,displayName,oauth2PermissionScopes,appRoles") - .get(); + .get() as GraphResult; const sp = Array.isArray(result?.value) && result.value.length > 0 ? result.value[0] : null; const scopesById = new Map( - (sp?.oauth2PermissionScopes ?? []).map((scope) => [scope.id, scope]), + (sp?.oauth2PermissionScopes ?? []) + .filter((scope) => typeof scope.id === "string") + .map((scope) => [scope.id as string, scope]), ); const rolesById = new Map( - (sp?.appRoles ?? []).map((role) => [role.id, role]), + (sp?.appRoles ?? []) + .filter((role) => typeof role.id === "string") + .map((role) => [role.id as string, role]), ); return { @@ -142,9 +159,10 @@ export async function listAppPermissionsResolved(client, appId) { resourceDefinitions.map((entry) => [entry.resourceAppId, entry]), ); - const rows = []; + const rows: Array> = []; for (const resourceEntry of requiredResourceAccess) { - const resourceMeta = byResourceAppId.get(resourceEntry.resourceAppId); + const resourceAppId = resourceEntry.resourceAppId ?? ""; + const resourceMeta = byResourceAppId.get(resourceAppId); const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess) ? resourceEntry.resourceAccess : []; @@ -153,8 +171,8 @@ export async function listAppPermissionsResolved(client, appId) { const permissionType = item?.type ?? null; const permissionId = item?.id ?? null; const resolved = permissionType === "Scope" - ? resourceMeta?.scopesById.get(permissionId) - : resourceMeta?.rolesById.get(permissionId); + ? resourceMeta?.scopesById.get(permissionId ?? "") + : resourceMeta?.rolesById.get(permissionId ?? ""); rows.push({ resourceAppId: resourceEntry.resourceAppId ?? null, @@ -174,14 +192,7 @@ export async function listAppPermissionsResolved(client, appId) { return rows; } -/** - * List delegated OAuth2 permission grants for an application by appId. - * - * @param { Object } client - * @param { string } appId - * @returns { Promise } - */ -export async function listAppGrants(client, appId) { +export async function listAppGrants(client: any, appId: string): Promise { if (!appId) { throw new Error("appId is required"); } @@ -190,7 +201,7 @@ export async function listAppGrants(client, appId) { .api("/servicePrincipals") .filter(`appId eq '${appId}'`) .select("id,appId,displayName") - .get(); + .get() as GraphResult; const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0 ? spResult.value[0] @@ -203,21 +214,12 @@ export async function listAppGrants(client, appId) { const grantsResult = await client .api("/oauth2PermissionGrants") .filter(`clientId eq '${servicePrincipal.id}'`) - .get(); + .get() as GraphResult; return Array.isArray(grantsResult?.value) ? grantsResult.value : []; } -/** - * List available delegated scopes and app roles for a resource app. - * - * @param { Object } client - * @param { Object } options - * @param { string } [options.appId] - * @param { string } [options.displayName] - * @returns { Promise } - */ -export async function listResourcePermissions(client, options = {}) { +export async function listResourcePermissions(client: any, options: ResourcePermissionsOptions = {}): Promise>> { const { appId, displayName } = options; if (!appId && !displayName) { throw new Error("appId or displayName is required"); @@ -233,9 +235,9 @@ export async function listResourcePermissions(client, options = {}) { request = request.filter(`displayName eq '${displayName}'`); } - const result = await request.get(); + const result = await request.get() as GraphResult; const servicePrincipals = Array.isArray(result?.value) ? result.value : []; - const rows = []; + const rows: Array> = []; for (const sp of servicePrincipals) { for (const scope of sp?.oauth2PermissionScopes ?? []) { diff --git a/src/graph/auth.js b/src/graph/auth.js deleted file mode 100644 index 5abdd9e..0000000 --- a/src/graph/auth.js +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: MIT - -import { Client } from "@microsoft/microsoft-graph-client"; -import { acquireResourceTokenFromLogin } from "../azure/index.js"; - -/** - * Initialize and return a Microsoft Graph client - * along with the authentication token. - * - * @param { Object } options - Options for authentication - * @param { string } options.tenantId - The Azure AD tenant ID - * @param { string } options.clientId - The Azure AD client ID - * @returns { Promise<{ graphApiToken: Object, client: Object }> } An object containing the Graph API token and client - */ -export async function getGraphClient({ tenantId, clientId }) { - const graphApiToken = await acquireResourceTokenFromLogin({ - tenantId, - clientId, - resource: "graph", - }); - - const client = Client.init({ - authProvider: (done) => { - done(null, graphApiToken.accessToken); - }, - }); - - return { graphApiToken, client }; -} diff --git a/src/graph/auth.ts b/src/graph/auth.ts new file mode 100644 index 0000000..8469c92 --- /dev/null +++ b/src/graph/auth.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT + +import { Client } from "@microsoft/microsoft-graph-client"; +import { acquireResourceTokenFromLogin } from "../azure/index.ts"; + +type GraphClientOptions = { + tenantId?: string; + clientId?: string; +}; + +type GraphApiToken = { + accessToken: string; + [key: string]: unknown; +}; + +export async function getGraphClient({ tenantId, clientId }: GraphClientOptions): Promise<{ graphApiToken: GraphApiToken; client: any }> { + const graphApiToken = await acquireResourceTokenFromLogin({ + tenantId, + clientId, + resource: "graph", + }) as GraphApiToken; + + const client = Client.init({ + authProvider: (done) => { + done(null, graphApiToken.accessToken); + }, + }); + + return { graphApiToken, client }; +} diff --git a/src/graph/index.d.ts b/src/graph/index.d.ts deleted file mode 100644 index ab0c014..0000000 --- a/src/graph/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -// \ No newline at end of file diff --git a/src/graph/index.js b/src/graph/index.js deleted file mode 100644 index c85abf8..0000000 --- a/src/graph/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-License-Identifier: MIT - -export * from "./auth.js"; -export * from "./app.js"; -export * from "./sp.js"; diff --git a/src/graph/index.ts b/src/graph/index.ts new file mode 100644 index 0000000..728e430 --- /dev/null +++ b/src/graph/index.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT + +export * from "./auth.ts"; +export * from "./app.ts"; +export * from "./sp.ts"; diff --git a/src/graph/sp.js b/src/graph/sp.js deleted file mode 100644 index c690c2e..0000000 --- a/src/graph/sp.js +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT - -export async function getServicePrincipal(client, appId) { - const result = await client - .api("/servicePrincipals") - .filter(`appId eq '${appId}'`) - .get(); - - // Return the first service principal found or null if none exists - return result.value.length > 0 ? result.value[0] : null; -} - -export async function createSp(client, appId) { - const sp = await client.api("/servicePrincipals").post({ - appId, - }); - - if (!sp || !sp.id) { - throw new Error("Failed to create service principal"); - } - - return sp; -} - -export async function deleteSp(client, spId) { - await client.api(`/servicePrincipals/${spId}`).delete(); -} diff --git a/src/graph/sp.ts b/src/graph/sp.ts new file mode 100644 index 0000000..79aa004 --- /dev/null +++ b/src/graph/sp.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT + +type GraphResult> = { + value?: T[]; +}; + +export async function getServicePrincipal(client: any, appId: string): Promise | null> { + const result = await client + .api("/servicePrincipals") + .filter(`appId eq '${appId}'`) + .get() as GraphResult; + + return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null; +} + +export async function createSp(client: any, appId: string): Promise> { + const sp = await client.api("/servicePrincipals").post({ + appId, + }) as Record; + + if (!sp || typeof sp.id !== "string") { + throw new Error("Failed to create service principal"); + } + + return sp; +} + +export async function deleteSp(client: any, spId: string): Promise { + await client.api(`/servicePrincipals/${spId}`).delete(); +} diff --git a/src/index.d.ts b/src/index.d.ts deleted file mode 100644 index 8337712..0000000 --- a/src/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -// diff --git a/src/index.js b/src/index.ts similarity index 53% rename from src/index.js rename to src/index.ts index 4545ccb..3396585 100644 --- a/src/index.js +++ b/src/index.ts @@ -4,7 +4,17 @@ import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -export function getUserConfigDir() { +type Config = { + tenantId?: string; + clientId?: string; +}; + +type ConfigCandidate = { + tenantId?: unknown; + clientId?: unknown; +}; + +export function getUserConfigDir(): string { if (process.platform === "win32") { return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"); } @@ -12,37 +22,37 @@ export function getUserConfigDir() { return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); } -async function loadConfig(configFileName) { +async function loadConfig(configFileName: string): Promise { if (typeof configFileName !== "string" || configFileName.trim() === "") { throw new Error( 'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".', ); } - const config = { + const envConfig: Config = { tenantId: process.env.AZURE_TENANT_ID, clientId: process.env.AZURE_CLIENT_ID, }; const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName); return readFile(configPath, "utf8") - .then((configJson) => JSON.parse(configJson)) - .catch((err) => { - if (err?.code === "ENOENT") { - return {}; + .then((configJson) => JSON.parse(configJson) as ConfigCandidate) + .catch((err: unknown) => { + if ((err as { code?: string } | null)?.code === "ENOENT") { + return {} as ConfigCandidate; } throw err; }) .then((json) => ({ - tenantId: json.tenantId || config.tenantId, - clientId: json.clientId || config.clientId, + tenantId: typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId, + clientId: typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId, })); } -export function loadPublicConfig() { +export function loadPublicConfig(): Promise { return loadConfig("public-config.json"); } -export function loadConfidentialConfig() { +export function loadConfidentialConfig(): Promise { return loadConfig("confidential-config.json"); } diff --git a/src/markdown.js b/src/markdown.ts similarity index 60% rename from src/markdown.js rename to src/markdown.ts index 6e1e637..bcba7ab 100644 --- a/src/markdown.js +++ b/src/markdown.ts @@ -1,18 +1,28 @@ // SPDX-License-Identifier: MIT -function formatCell(value) { +type Scalar = string | number | boolean | null | undefined; +type ScalarRow = Record; + +type HeaderSpec = + | { mode: "default" } + | { mode: "auto" } + | { mode: "original" } + | { mode: "list"; labels: string[] } + | { mode: "map"; map: Record }; + +function formatCell(value: unknown): string { const text = value == null ? "" : String(value); return text.replaceAll("|", "\\|").replaceAll("\n", "
"); } -function isGuid(value) { +function isGuid(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); } -function toAutoHeaderLabel(key) { +function toAutoHeaderLabel(key: string): string { const withSpaces = String(key) .replace(/[_-]+/g, " ") .replace(/([a-z0-9])([A-Z])/g, "$1 $2") @@ -25,16 +35,20 @@ function toAutoHeaderLabel(key) { .join(" "); } -function getScalarRowsAndHeaders(value) { - let rows; +function isScalar(value: unknown): value is Scalar { + return value == null || typeof value !== "object"; +} + +function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } { + let rows: Array>; if (Array.isArray(value)) { rows = value.map((item) => item && typeof item === "object" && !Array.isArray(item) - ? item - : { value: item } + ? item as Record + : { value: item }, ); } else if (value && typeof value === "object") { - rows = [value]; + rows = [value as Record]; } else { rows = [{ value }]; } @@ -48,10 +62,7 @@ function getScalarRowsAndHeaders(value) { 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"; - }) + rows.every((row) => isScalar(row[key])), ); if (headers.length === 0) { @@ -61,25 +72,39 @@ function getScalarRowsAndHeaders(value) { }; } - return { headers, rows }; + const scalarRows: ScalarRow[] = rows.map((row) => { + const result: ScalarRow = {}; + for (const [key, rowValue] of Object.entries(row)) { + if (isScalar(rowValue)) { + result[key] = rowValue; + } + } + return result; + }); + + return { headers, rows: scalarRows }; } -export function toMarkdownTable(value, pretty = false, quoteGuids = false) { - const headerSpec = arguments[3] ?? { mode: "default" }; +export function toMarkdownTable( + value: unknown, + pretty = false, + quoteGuids = false, + headerSpec: HeaderSpec = { mode: "default" }, +): string { const { headers, rows } = getScalarRowsAndHeaders(value); const headerDefinitions = headers.map((key, idx) => { let label = key; - if (headerSpec?.mode === "auto") { + if (headerSpec.mode === "auto") { label = toAutoHeaderLabel(key); - } else if (headerSpec?.mode === "list" && Array.isArray(headerSpec.labels) && headerSpec.labels[idx]) { + } else if (headerSpec.mode === "list" && headerSpec.labels[idx]) { label = headerSpec.labels[idx]; - } else if (headerSpec?.mode === "map" && headerSpec.map && headerSpec.map[key]) { + } else if (headerSpec.mode === "map" && headerSpec.map[key]) { label = headerSpec.map[key]; } return { key, label }; }); - const renderCell = (raw) => { + const renderCell = (raw: Scalar): string => { const text = formatCell(raw); return quoteGuids && isGuid(raw) ? `\`${text}\`` : text; }; @@ -88,7 +113,7 @@ export function toMarkdownTable(value, pretty = false, quoteGuids = false) { const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`; const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`; const rowLines = rows.map((row) => - `| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |` + `| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |`, ); return [headerLine, separatorLine, ...rowLines].join("\n"); } @@ -100,13 +125,13 @@ export function toMarkdownTable(value, pretty = false, quoteGuids = false) { ) ); - const renderRow = (values) => + const renderRow = (values: string[]): string => `| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`; const headerLine = renderRow(headerDefinitions.map((h) => h.label)); const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`; const rowLines = rows.map((row) => - renderRow(headerDefinitions.map((header) => renderCell(row[header.key]))) + renderRow(headerDefinitions.map((header) => renderCell(row[header.key]))), ); return [headerLine, separatorLine, ...rowLines].join("\n"); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a260eb0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024"], + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "rewriteRelativeImportExtensions": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}