Compare commits

...

28 Commits

Author SHA1 Message Date
67dd2045e3 refactor: moved bump-patch.mjs to the generic sk-tools package. 2026-03-07 11:06:10 +01:00
94a573f1e1 refactor: simplify bump function and improve version handling 2026-03-07 11:04:23 +01:00
ed18cb535a Cleaned up docs. 2026-03-07 10:17:56 +01:00
9c2aea491c feat: bump version to 0.4.3 and add bump-patch script for automated versioning
All checks were successful
build / build (push) Successful in 15s
2026-03-07 10:13:47 +01:00
d39fdb3e33 feat: add make-deps script for function dependency graph generation
Some checks failed
build / build (push) Failing after 15s
2026-03-07 10:01:04 +01:00
2a0b49effe refactor: remove omitPermissionGuidColumns utility and implement omitRecords function 2026-03-07 08:02:58 +01:00
fff80047c2 fix: restored accidentialy removed sk-tools dependency.
All checks were successful
build / build (push) Successful in 17s
2026-03-06 23:11:10 +01:00
a629a3a32d Fix incorrect handling of table output.
Some checks failed
build / build (push) Failing after 12s
2026-03-06 23:03:19 +01:00
2fa9462657 Refactor CLI commands: remove table command and related utilities; update dependencies and version
All checks were successful
build / build (push) Successful in 17s
2026-03-06 19:05:34 +01:00
03fb55d97f Add Azure CLI Impersonation documentation 2026-03-06 14:51:29 +01:00
a53d2896b1 Add create and delete scripts creating Public Client Application (Remove unused JavaScript version). 2026-03-06 14:51:23 +01:00
3b37b26571 Add package publishing step to build workflow 2026-03-06 12:24:32 +01:00
350577420b update command usage formatting for consistency
All checks were successful
build / build (push) Successful in 12s
2026-03-06 12:11:06 +01:00
8cbd1d6399 Add support for CSV and TSV input formats in table command
All checks were successful
build / build (push) Successful in 12s
2026-03-06 11:43:47 +01:00
21b6a51330 Add comprehensive command documentation for SK Azure Tools
All checks were successful
build / build (push) Successful in 50s
2026-03-06 06:20:42 +01:00
9b9aefc9a5 Fix usageTable command help text for header option 2026-03-06 06:20:36 +01:00
849a8505a2 Bump version to 0.3.1 in package.json and package-lock.json
All checks were successful
build / build (push) Successful in 12s
2026-03-05 23:29:45 +01:00
ba7bacbe12 Update: Refactored commands into their own source files. 2026-03-05 23:28:08 +01:00
9581ee1a31 Add REST command support with method and URL options
All checks were successful
build / build (push) Successful in 12s
2026-03-05 23:18:47 +01:00
9f023d44cc Fix badge link in README to use correct YAML file extension 2026-03-05 22:57:09 +01:00
d74d133a60 Add build status badge to README 2026-03-05 22:56:15 +01:00
05d517709b Refactor code structure for improved readability and maintainability
All checks were successful
build / build (push) Successful in 12s
2026-03-05 22:51:36 +01:00
e70e668432 Add workflow_dispatch trigger to build workflow 2026-03-05 22:49:31 +01:00
e246657740 Add build workflow for CI/CD pipeline 2026-03-05 22:48:27 +01:00
71ec95b52b Bump version to 0.3.0 and add get-token command for Azure token retrieval 2026-03-05 22:39:42 +01:00
21b8179d40 Update version to 0.2.1 and add create-pca script for Azure app management 2026-03-05 22:03:10 +01:00
b88b35cb90 Bump version to 0.2.0 and update build script to make CLI executable 2026-03-05 21:36:52 +01:00
cb41e7dec1 Migrated to TypeScript. 2026-03-05 21:29:58 +01:00
48 changed files with 3269 additions and 985 deletions

View File

@@ -0,0 +1,30 @@
name: build
on:
push:
paths:
- 'src/**'
- package.json
- package-lock.json
- tsconfig.json
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Publish package
run: |
npm config set @slawek:registry=https://gitea.koszewscy.waw.pl/api/packages/slawek/npm/
npm config set -- '//gitea.koszewscy.waw.pl/api/packages/slawek/npm/:_authToken' "${{ secrets.CI_TOKEN }}"
npm publish --access public

1
.gitignore vendored
View File

@@ -2,4 +2,3 @@
node_modules/ node_modules/
dist/ dist/
artifacts/ artifacts/
package-lock.json

View File

@@ -1,3 +1,11 @@
docs src/
*.ts
tsconfig.json
scripts/
Dockerfile
.git
.gitignore
artifacts/
node_modules node_modules
package-lock.json package-lock.json
docs

View File

@@ -1,4 +1,36 @@
# A set of Azure related NodeJS modules # A set of Azure related NodeJS modules
[![test](https://gitea.koszewscy.waw.pl/slawek/sk-az-tools/actions/workflows/build.yml/badge.svg)](https://gitea.koszewscy.waw.pl/slawek/sk-az-tools/actions?workflow=build.yml)
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. 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.

View File

@@ -0,0 +1,34 @@
# Azure CLI Impersonation
To use `sk-az-tools` module or commands, you need to register Public Client Application and assign it appropriate permissions for full functionality.
Some commands may work with limited functionality without dedicated Public Client Application. You can use Azure CLI public client application for that purpose.
The Client ID of Azure CLI public client application is `04b07795-8ddb-461a-bbee-02f9e1bf7b46`.
Create a configuration file `$HOME/.config/sk-az-tools/public-config.json` with the following content:
```json
{
"tenantId": "<tenant-id>",
"clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
}
```
You can obtain tenant ID using `az account show --query tenantId -o tsv` command.
Confirm Client ID of the Azure CLI by locating Azure CLI installation and looking into the following file that lives in the Azure CLI embedded Python distribution:
`<installation_path>/lib/python3.<minor_version>/site-packages/azure/cli/core/auth/constants.py`:
```python
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46'
ACCESS_TOKEN = 'access_token'
EXPIRES_IN = "expires_in"
```

121
docs/Commands.md Normal file
View File

@@ -0,0 +1,121 @@
# SK Azure Tools Commands
The `sk-az-tools` package may act as a CLI tool that provides various commands for working with various services. Currently implemented commands support services related to:
- Microsoft Entra ID
- Azure Resource Manager
- Azure DevOps Services
## Login
**Command name:** `login`
**Usage:** `sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]`
**Options:**
- `--resources` <csv> - Comma-separated resources to authenticate. Allowed values: `graph`, `devops`, `arm`. Default is all three.
- `--use-device-code` - Use device code flow instead of browser-based interactive flow.
- `--no-browser` - Do not launch browser automatically. Print the sign-in URL to stderr.
- `--browser` <name> - Browser keyword used for interactive sign-in. Allowed values: `brave`, `browser`, `browserPrivate`, `chrome`, `edge`, `firefox`.
- `--browser-profile` <name> - Chromium profile name (for example: `Default`, `Profile 1`).
**Description:** The `login` command authenticates user sign-in for selected resource audiences and caches tokens for subsequent commands.
## Logout
**Command name:** `logout`
**Usage:** `sk-az-tools logout [--all] [global options]`
**Options:**
- `--all` - Clear login state and remove all cached accounts.
**Description:** The `logout` command signs out from cached user sessions. By default it signs out the active account; with `--all` it clears all cached accounts.
## Get Token
**Command name:** `get-token`
**Usage:** `sk-az-tools get-token --type|-t <azurerm|devops> [global options]`
**Options:**
- `--type`, `-t` <azurerm|devops> - Token audience to retrieve.
**Description:** The `get-token` command returns an access token for either Azure Resource Manager (`azurerm`) or Azure DevOps (`devops`) using the current login context.
## REST
**Command name:** `rest`
**Usage:** `sk-az-tools rest [--method <httpMethod>] --url <url> [--header <name: value>] [global options]`
**Options:**
- `--method` <httpMethod> - HTTP method to use. Default: `GET`.
- `--url` <url> - Full URL to call.
- `--header` <name: value> - Extra request header to include (for example: `Content-Type: application/json`).
**Description:** The `rest` command performs an HTTP request and returns response metadata and body. If `Authorization` is not provided explicitly, a `Bearer` token is added automatically for supported hosts:
- `management.azure.com` - uses Azure Resource Manager token
- `dev.azure.com` - uses Azure DevOps token
## List Apps
**Command name:** `list-apps`
**Usage:** `sk-az-tools list-apps [--display-name|-n <name>] [--app-id|-i <appId>] [--filter|-f <glob>] [global options]`
**Options:**
- `--display-name`, `-n` <name> - Find applications by display name.
- `--app-id`, `-i` <appId> - Find application by application (client) ID.
- `--filter`, `-f` <glob> - Filter results by display name using glob pattern.
**Description:** The `list-apps` command queries Microsoft Entra applications and returns matching app registrations.
## List App Permissions
**Command name:** `list-app-permissions`
**Usage:** `sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]`
**Options:**
- `--app-id`, `-i` <appId> - Application (client) ID. Required.
- `--resolve`, `-r` - Resolve permission GUIDs to human-readable names.
- `--short`, `-s` - Output compact result (omits permission GUID columns in output formatting step).
- `--filter`, `-f` <glob> - Filter permissions by name using glob pattern.
**Description:** The `list-app-permissions` command returns required API permissions declared by the target app registration.
## List App Grants
**Command name:** `list-app-grants`
**Usage:** `sk-az-tools list-app-grants --app-id|-i <appId> [global options]`
**Options:**
- `--app-id`, `-i` <appId> - Application (client) ID. Required.
**Description:** The `list-app-grants` command lists OAuth2 delegated permission grants associated with the specified app.
## List Resource Permissions
**Command name:** `list-resource-permissions`
**Usage:** `sk-az-tools list-resource-permissions [--app-id|-i <appId> | --display-name|-n <name>] [--filter|-f <glob>] [global options]`
**Options:**
- `--app-id`, `-i` <appId> - Resource application ID.
- `--display-name`, `-n` <name> - Resource application display name.
- `--filter`, `-f` <glob> - Filter permissions by name using glob pattern.
**Description:** The `list-resource-permissions` command returns available delegated and application permissions exposed by a resource app.

View File

@@ -1,94 +0,0 @@
# Developing `hello-world` (ESM) Summary
## 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.
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"
}
}
```
Example `src/index.js`:
```js
export function helloWorld() {
console.log("Hello World!!!");
}
```
## Sub-modules (Subpath Exports)
- Expose sub-modules using explicit subpaths in `exports`.
- Keep public API small and intentional.
Example:
```json
{
"exports": {
".": "./src/index.js",
"./greetings": "./src/greetings.js",
"./callouts": "./src/callouts.js"
}
}
```
## `exports` vs `files`
- `exports` defines the public import surface (what consumers can import).
- `files` defines what gets packaged; supports globs and negation.
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
```
Consumer repo:
```bash
npm link hello-world
```
Notes:
- If you build to `dist/`, run a watch build so the consumer sees updates.
- Unlink when done:
```bash
npm unlink hello-world
```
## 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).
Commands:
```bash
npm pack
npm install ./hello-world-1.0.0.tgz
```
Example with output directory:
```bash
npm pack --pack-destination ./artifacts
```

1609
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,19 @@
{ {
"name": "@slawek/sk-az-tools", "name": "@slawek/sk-az-tools",
"version": "0.1.0", "version": "0.4.3",
"type": "module", "type": "module",
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "rm -rf dist && tsc && chmod +x dist/cli.js",
"create-pca": "node dist/create-pca.js",
"bump-patch": "node scripts/bump-patch.mjs",
"make-deps": "node scripts/make-mermaid-func-deps.mjs",
"clean": "rm -rf dist"
},
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
@@ -11,22 +23,29 @@
"@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",
"@slawek/sk-tools": ">=0.1.0",
"azure-devops-node-api": "^15.1.2", "azure-devops-node-api": "^15.1.2",
"jmespath": "^0.16.0", "minimatch": "^10.1.2",
"minimatch": "^10.1.2" "open": "^10.1.0",
"semver": "^7.7.2"
},
"devDependencies": {
"@types/node": ">=24.0.0",
"ts-morph": ">=27.0.0",
"typescript": ">=5.8.2"
}, },
"author": { "author": {
"name": "Sławomir Koszewski", "name": "Sławomir Koszewski",
"email": "slawek@koszewscy.waw.pl" "email": "slawek@koszewscy.waw.pl"
}, },
"bin": { "bin": {
"sk-az-tools": "./src/cli.js" "sk-az-tools": "./dist/cli.js"
}, },
"license": "MIT", "license": "MIT",
"exports": { "exports": {
".": "./src/index.js", ".": "./dist/index.js",
"./azure": "./src/azure/index.js", "./azure": "./dist/azure/index.js",
"./graph": "./src/graph/index.js", "./graph": "./dist/graph/index.js",
"./devops": "./src/devops/index.js" "./devops": "./dist/devops/index.js"
} }
} }

139
scripts/create-pca.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -uo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") [options] <app-name>
Options:
-c, --config <path> Write JSON config to file (optional)
-h, --help Show this help message and exit
EOF
}
CONFIG_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-c|--config)
if [[ $# -le 0 ]]; then
echo "Error: Missing value for --config" >&2
exit 1
fi
CONFIG_PATH="$2"
shift 2
;;
-*)
echo "Error: Unknown option: $1" >&2
exit 1
;;
*)
break
;;
esac
done
APP_NAME="${1:-}"
if [[ -z "$APP_NAME" ]]; then
echo "Error: Application name is required." >&2
usage >&2
exit 1
fi
APP_ID="$(az ad app list --display-name "$APP_NAME" | jq -r '[.[].appId] | join(",")')"
if [[ "$APP_ID" =~ "," ]]; then
echo "Error: The application name '$APP_NAME' is not unique." >&2
exit 1
fi
if [[ -z "$APP_ID" ]]; then
APP_ID="$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)"
if [[ -z "$APP_ID" ]]; then
echo "Error: Failed to create application '$APP_NAME'." >&2
exit 1
fi
echo "Created application '$APP_NAME' with appId '$APP_ID'."
else
printf "Application '%s' already exists. Update it? [y/N]: " "$APP_NAME" >&2
read -r ANSWER
if [[ ! "$ANSWER" =~ ^[Yy]([Ee][Ss])*$ ]]; then
echo "Canceled." >&2
exit 0
fi
fi
RESOURCE_ACCESS='[
{
"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" }
]
}
]'
if ! az ad app update \
--id "$APP_ID" \
--sign-in-audience AzureADMyOrg \
--is-fallback-public-client true \
--required-resource-accesses "$RESOURCE_ACCESS" \
--public-client-redirect-uris http://localhost "msal${APP_ID}://auth" \
--enable-access-token-issuance true \
--enable-id-token-issuance true \
>/dev/null 2>&1; then
echo "Error: Failed to configure application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
SP_ID="$(az ad sp show --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
SP_ID="$(az ad sp create --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
echo "Error: Failed to create service principal for application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
else
echo "Service principal for application '$APP_NAME' already exists with id '$SP_ID'."
fi
az ad app permission admin-consent --id "$APP_ID"
if [[ $? -ne 0 ]]; then
echo "Error: Failed to grant admin consent for application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
TENANT_ID="$(az account show --query tenantId -o tsv)"
if [[ -z "$TENANT_ID" ]]; then
echo "Error: Failed to resolve tenantId from current Azure CLI context." >&2
exit 1
fi
CONFIG="{
\"tenantId\": \"$TENANT_ID\",
\"clientId\": \"$APP_ID\"
}
"
if [[ -n "$CONFIG_PATH" ]]; then
mkdir -p "$(dirname "$CONFIG_PATH")"
else
CONFIG_PATH="/dev/null"
fi
echo "$CONFIG" | tee "$CONFIG_PATH"

44
scripts/delete-pca.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -uo pipefail
APP_NAME="${1:-}"
if [[ -z "$APP_NAME" ]]; then
echo "Error: Application name is required." >&2
echo "Usage: $(basename "$0") <app-name>" >&2
exit 1
fi
APP_ID="$(az ad app list --display-name "$APP_NAME" | jq -r '[.[].appId] | join(",")')"
if [[ "$APP_ID" =~ "," ]]; then
echo "Error: The application name '$APP_NAME' is not unique." >&2
exit 1
fi
if [[ -z "$APP_ID" ]]; then
echo "Error: No application found with name '$APP_NAME'." >&2
exit 1
fi
SP_ID="$(az ad sp show --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
echo "No service principal found for application '$APP_NAME' ($APP_ID)."
fi
# Get confirmation from user before deleting
read -p "Are you sure you want to delete application '$APP_NAME' with appId '$APP_ID' and its service principal? (y/N) " -n 1 -r
echo
if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
echo "Aborting deletion."
exit 0
fi
if [[ -n "$SP_ID" ]]; then
az ad sp delete --id "$SP_ID"
echo "Deleted service principal with id '$SP_ID' for application '$APP_NAME' ($APP_ID)."
fi
az ad app delete --id "$APP_ID"
echo "Deleted application '$APP_NAME' with appId '$APP_ID'."

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import fs from "node:fs";
import path from "node:path";
import { parseArgs } from "node:util";
import { Node, Project, SyntaxKind } from "ts-morph";
const projectRoot = process.cwd();
const MAX_EDGES = Number(process.env.MAX_GRAPH_EDGES ?? "450");
const {
values: { source: sourceArgsRaw, output: outputArg },
} = parseArgs({
options: {
source: {
type: "string",
multiple: true,
},
output: {
type: "string",
},
},
});
function collectFunctionEntries(sourceFile) {
const entries = [];
const namesSeen = new Set();
for (const fn of sourceFile.getFunctions()) {
const name = fn.getName();
if (!name || namesSeen.has(name)) {
continue;
}
namesSeen.add(name);
entries.push({ name, node: fn });
}
for (const declaration of sourceFile.getVariableDeclarations()) {
const name = declaration.getName();
if (namesSeen.has(name)) {
continue;
}
const initializer = declaration.getInitializer();
if (!initializer) {
continue;
}
if (!Node.isArrowFunction(initializer) && !Node.isFunctionExpression(initializer)) {
continue;
}
namesSeen.add(name);
entries.push({ name, node: initializer });
}
return entries;
}
function nodeId(filePath, functionName) {
return `${filePath}__${functionName}`.replace(/[^A-Za-z0-9_]/g, "_");
}
function normalizeRelativePath(filePath) {
return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
}
function normalizeSourceArg(sourceArg) {
const projectRelative = path.isAbsolute(sourceArg)
? path.relative(projectRoot, sourceArg)
: sourceArg;
return normalizeRelativePath(projectRelative).replace(/\/+$/, "");
}
const functionsByFile = new Map();
const allFunctionNames = new Set();
const functionEntriesByFile = new Map();
const project = new Project({
tsConfigFilePath: path.join(projectRoot, "tsconfig.json"),
});
const sourceFiles = project
.getSourceFiles()
.filter((sourceFile) => !sourceFile.isDeclarationFile())
.sort((a, b) => a.getFilePath().localeCompare(b.getFilePath()));
const sourceContext = sourceFiles.map((sourceFile) => ({
sourceFile,
relativePath: normalizeRelativePath(path.relative(projectRoot, sourceFile.getFilePath())),
}));
const filteredSourceFiles = (() => {
if (!sourceArgsRaw || sourceArgsRaw.length === 0) {
return sourceContext.map((item) => item.sourceFile);
}
const sourceArgs = sourceArgsRaw.map((sourceArg) => normalizeSourceArg(sourceArg));
const matchedSourceFiles = new Set();
for (const sourceArg of sourceArgs) {
const hasMatch = sourceContext.some((item) => (
item.relativePath === sourceArg
|| item.relativePath.startsWith(`${sourceArg}/`)
));
if (!hasMatch) {
throw new Error(`No source file matched --source=${sourceArg}`);
}
for (const item of sourceContext) {
if (item.relativePath === sourceArg || item.relativePath.startsWith(`${sourceArg}/`)) {
matchedSourceFiles.add(item.sourceFile);
}
}
}
return [...matchedSourceFiles];
})();
for (const sourceFile of filteredSourceFiles) {
const absolutePath = sourceFile.getFilePath();
const relativePath = path.relative(projectRoot, absolutePath).replaceAll("\\", "/");
const functionEntries = collectFunctionEntries(sourceFile);
const functionNames = functionEntries.map((entry) => entry.name);
functionsByFile.set(relativePath, functionNames);
functionEntriesByFile.set(relativePath, functionEntries);
for (const functionName of functionNames) {
allFunctionNames.add(functionName);
}
}
const firstNodeForFunction = new Map();
for (const [relativePath, functionNames] of functionsByFile.entries()) {
for (const functionName of functionNames) {
if (!firstNodeForFunction.has(functionName)) {
firstNodeForFunction.set(functionName, nodeId(relativePath, functionName));
}
}
}
const edgeSet = new Set();
for (const functionEntries of functionEntriesByFile.values()) {
for (const { name: sourceName, node } of functionEntries) {
const body = node.getBody();
if (!body) {
continue;
}
const sourceNode = firstNodeForFunction.get(sourceName);
if (!sourceNode) {
continue;
}
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const expression = call.getExpression();
let targetName;
if (Node.isIdentifier(expression)) {
targetName = expression.getText();
} else if (Node.isPropertyAccessExpression(expression)) {
targetName = expression.getName();
}
if (!targetName || targetName === sourceName || !allFunctionNames.has(targetName)) {
continue;
}
const targetNode = firstNodeForFunction.get(targetName);
if (targetNode) {
edgeSet.add(`${sourceNode} --> ${targetNode}`);
}
}
}
}
let diagram = "%% Function Dependency Graph (`src/**/*.ts`)\n";
diagram += "%% Generated from current package source files.\n";
diagram += "flowchart LR\n";
for (const [relativePath, functionNames] of functionsByFile.entries()) {
if (functionNames.length === 0) {
continue;
}
const subgraphId = relativePath.replace(/[^A-Za-z0-9_]/g, "_");
diagram += ` subgraph ${subgraphId}[\"${relativePath}\"]\n`;
for (const functionName of functionNames) {
diagram += ` ${nodeId(relativePath, functionName)}[\"${functionName}\"]\n`;
}
diagram += " end\n";
}
const sortedEdges = [...edgeSet].sort();
const emittedEdges = sortedEdges.slice(0, MAX_EDGES);
for (const edge of emittedEdges) {
diagram += ` ${edge}\n`;
}
if (sortedEdges.length > MAX_EDGES) {
diagram += `%% Note: showing ${MAX_EDGES} of ${sortedEdges.length} detected edges to stay within Mermaid default edge limits.\n`;
}
if (outputArg) {
const outputPath = path.resolve(projectRoot, outputArg);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, diagram, "utf8");
} else {
process.stdout.write(diagram);
}

View File

@@ -2,7 +2,18 @@
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity"; 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<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> {
switch (credentialType) { switch (credentialType) {
case "d": case "d":
case "default": case "default":

View File

@@ -1 +0,0 @@
//

View File

@@ -2,12 +2,11 @@
/** /**
* @module azure * @module azure
* *
* This module provides authentication functionalities for Azure services. * This module provides authentication functionalities for Azure services.
*
*/ */
export { getCredential } from "./client-auth.js"; export { getCredential } from "./client-auth.ts";
export { export {
loginInteractive, loginInteractive,
loginDeviceCode, loginDeviceCode,
@@ -15,4 +14,4 @@ export {
logout, logout,
parseResources, parseResources,
acquireResourceTokenFromLogin, acquireResourceTokenFromLogin,
} from "./pca-auth.js"; } from "./pca-auth.ts";

View File

@@ -6,20 +6,76 @@ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { PublicClientApplication } from "@azure/msal-node"; import { PublicClientApplication } from "@azure/msal-node";
import type {
AccountInfo,
AuthenticationResult,
ICachePlugin,
TokenCacheContext,
} from "@azure/msal-node";
import os from "node:os"; import os from "node:os";
const RESOURCE_SCOPE_BY_NAME = { const RESOURCE_SCOPE_BY_NAME = {
graph: "https://graph.microsoft.com/.default", graph: "https://graph.microsoft.com/.default",
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default", devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
arm: "https://management.azure.com/.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 LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
const BROWSER_KEYWORDS = Object.keys(apps).sort(); const BROWSER_KEYWORDS = Object.keys(apps).sort();
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]); 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 isWindows = process.platform === "win32";
const userRoot = isWindows const userRoot = isWindows
? process.env.LOCALAPPDATA || os.homedir() ? process.env.LOCALAPPDATA || os.homedir()
@@ -30,14 +86,14 @@ function getCacheRoot() {
: path.join(userRoot, ".config", "sk-az-tools"); : path.join(userRoot, ".config", "sk-az-tools");
} }
function getSessionFilePath() { function getSessionFilePath(): string {
return path.join(getCacheRoot(), "login-session.json"); return path.join(getCacheRoot(), "login-session.json");
} }
async function readSessionState() { async function readSessionState(): Promise<SessionState> {
try { try {
const sessionJson = await readFile(getSessionFilePath(), "utf8"); const sessionJson = await readFile(getSessionFilePath(), "utf8");
const parsed = JSON.parse(sessionJson); const parsed = JSON.parse(sessionJson) as { activeAccountUpn?: unknown };
return { return {
activeAccountUpn: activeAccountUpn:
typeof parsed?.activeAccountUpn === "string" typeof parsed?.activeAccountUpn === "string"
@@ -45,40 +101,40 @@ async function readSessionState() {
: null, : null,
}; };
} catch (err) { } catch (err) {
if (err?.code === "ENOENT") { if ((err as { code?: string } | null)?.code === "ENOENT") {
return { activeAccountUpn: null }; return { activeAccountUpn: null };
} }
throw err; throw err;
} }
} }
async function writeSessionState(state) { async function writeSessionState(state: SessionState): Promise<void> {
const sessionPath = getSessionFilePath(); const sessionPath = getSessionFilePath();
await mkdir(path.dirname(sessionPath), { recursive: true }); await mkdir(path.dirname(sessionPath), { recursive: true });
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8"); await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
} }
async function clearSessionState() { async function clearSessionState(): Promise<void> {
try { try {
await unlink(getSessionFilePath()); await unlink(getSessionFilePath());
} catch (err) { } catch (err) {
if (err?.code !== "ENOENT") { if ((err as { code?: string } | null)?.code !== "ENOENT") {
throw err; throw err;
} }
} }
} }
function normalizeUpn(upn) { function normalizeUpn(upn: unknown): string {
return typeof upn === "string" ? upn.trim().toLowerCase() : ""; return typeof upn === "string" ? upn.trim().toLowerCase() : "";
} }
function writeStderr(message) { function writeStderr(message: string): void {
process.stderr.write(`${message}\n`); process.stderr.write(`${message}\n`);
} }
function getBrowserAppName(browser) { function getBrowserAppName(browser?: string): string | readonly string[] | undefined {
if (!browser || browser.trim() === "") { if (!browser || browser.trim() === "") {
return null; return undefined;
} }
const keyword = BROWSER_KEYWORDS.find( 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() === "") { if (!browser || browser.trim() === "") {
return ""; return "";
} }
@@ -109,14 +165,13 @@ function getBrowserKeyword(browser) {
return keyword.toLowerCase(); return keyword.toLowerCase();
} }
function getBrowserOpenOptions({ browser, browserProfile }) { function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters<typeof open>[1] {
const browserName = getBrowserAppName(browser); const browserName = getBrowserAppName(browser);
const options = browserName
? { wait: false, app: { name: browserName } }
: { wait: false };
if (!browserProfile || browserProfile.trim() === "") { if (!browserProfile || browserProfile.trim() === "") {
return options; return browserName
? { wait: false, app: { name: browserName } }
: { wait: false };
} }
const browserKeyword = getBrowserKeyword(browser); const browserKeyword = getBrowserKeyword(browser);
@@ -126,11 +181,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) {
); );
} }
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`]; if (!browserName) {
return options; 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() !== "") { if (browser && browser.trim() !== "") {
getBrowserAppName(browser); getBrowserAppName(browser);
} }
@@ -145,7 +209,7 @@ function validateBrowserOptions({ browser, browserProfile }) {
} }
} }
export function parseResources(resourcesCsv) { export function parseResources(resourcesCsv?: string): ResourceName[] {
if (!resourcesCsv || resourcesCsv.trim() === "") { if (!resourcesCsv || resourcesCsv.trim() === "") {
return [...DEFAULT_RESOURCES]; return [...DEFAULT_RESOURCES];
} }
@@ -156,24 +220,24 @@ export function parseResources(resourcesCsv) {
.filter(Boolean); .filter(Boolean);
const unique = [...new Set(resources)]; 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) { if (invalid.length > 0) {
throw new Error( throw new Error(
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`, `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 { return {
beforeCacheAccess: async (ctx) => { beforeCacheAccess: async (ctx: TokenCacheContext) => {
if (fs.existsSync(cachePath)) { if (fs.existsSync(cachePath)) {
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8")); ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
} }
}, },
afterCacheAccess: async (ctx) => { afterCacheAccess: async (ctx: TokenCacheContext) => {
if (!ctx.cacheHasChanged) return; if (!ctx.cacheHasChanged) return;
fs.mkdirSync(path.dirname(cachePath), { recursive: true }); fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, ctx.tokenCache.serialize()); 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<PublicClientApplication> {
const cacheRoot = getCacheRoot(); const cacheRoot = getCacheRoot();
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
let cachePlugin; let cachePlugin: ICachePlugin;
try { try {
const { const {
DataProtectionScope, DataProtectionScope,
@@ -201,7 +265,7 @@ async function createPca({ tenantId, clientId }) {
usePlaintextFileOnLinux: true, usePlaintextFileOnLinux: true,
}); });
cachePlugin = new PersistenceCachePlugin(persistence); cachePlugin = new PersistenceCachePlugin(persistence);
} catch (err) { } catch {
// Fallback when msal-node-extensions/keytar/libsecret are unavailable. // Fallback when msal-node-extensions/keytar/libsecret are unavailable.
cachePlugin = fileCachePlugin(cachePath); 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<AuthenticationResult | null> {
if (account) { if (account) {
try { try {
return await pca.acquireTokenSilent({ return await pca.acquireTokenSilent({
@@ -244,7 +316,13 @@ async function acquireTokenWithCache({ pca, scopes, account }) {
return null; return null;
} }
async function findAccountByUpn({ pca, upn }) { async function findAccountByUpn({
pca,
upn,
}: {
pca: PublicClientApplication;
upn: string | null;
}): Promise<AccountInfo | null> {
const normalized = normalizeUpn(upn); const normalized = normalizeUpn(upn);
if (!normalized) { if (!normalized) {
return null; return null;
@@ -264,11 +342,12 @@ export async function loginInteractive({
showAuthUrlOnly = false, showAuthUrlOnly = false,
browser, browser,
browserProfile, browserProfile,
}) { }: LoginInteractiveOptions): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId 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"); throw new Error("scopes[] is required");
}
validateBrowserOptions({ browser, browserProfile }); validateBrowserOptions({ browser, browserProfile });
const pca = await createPca({ tenantId, clientId }); const pca = await createPca({ tenantId, clientId });
@@ -276,33 +355,34 @@ export async function loginInteractive({
const cached = await acquireTokenWithCache({ pca, scopes }); const cached = await acquireTokenWithCache({ pca, scopes });
if (cached) return cached; if (cached) return cached;
return await pca.acquireTokenInteractive({ return pca.acquireTokenInteractive({
scopes, scopes,
openBrowser: async (url) => { openBrowser: async (url: string) => {
if (showAuthUrlOnly) { if (showAuthUrlOnly) {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
return; return;
} }
const options = getBrowserOpenOptions({ browser, browserProfile }); const options = getBrowserOpenOptions({ browser, browserProfile });
return open(url, options).catch(() => { await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
}); });
}, },
}); });
} }
export async function loginDeviceCode({ tenantId, clientId, scopes }) { export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId 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"); throw new Error("scopes[] is required");
}
const pca = await createPca({ tenantId, clientId }); const pca = await createPca({ tenantId, clientId });
const cached = await acquireTokenWithCache({ pca, scopes }); const cached = await acquireTokenWithCache({ pca, scopes });
if (cached) return cached; if (cached) return cached;
return await pca.acquireTokenByDeviceCode({ return pca.acquireTokenByDeviceCode({
scopes, scopes,
deviceCodeCallback: (response) => { deviceCodeCallback: (response) => {
writeStderr(response.message); writeStderr(response.message);
@@ -318,7 +398,12 @@ export async function login({
noBrowser = false, noBrowser = false,
browser, browser,
browserProfile, 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 (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
validateBrowserOptions({ browser, browserProfile }); validateBrowserOptions({ browser, browserProfile });
@@ -332,8 +417,8 @@ export async function login({
upn: session.activeAccountUpn, upn: session.activeAccountUpn,
}); });
const results = []; const results: Array<{ resource: string; expiresOn: string | null }> = [];
let selectedAccount = preferredAccount; let selectedAccount: AccountInfo | null = preferredAccount;
for (let index = 0; index < resources.length; index += 1) { for (let index = 0; index < resources.length; index += 1) {
const resource = resources[index]; const resource = resources[index];
const scope = [scopes[index]]; const scope = [scopes[index]];
@@ -354,13 +439,13 @@ export async function login({
} else { } else {
token = await pca.acquireTokenInteractive({ token = await pca.acquireTokenInteractive({
scopes: scope, scopes: scope,
openBrowser: async (url) => { openBrowser: async (url: string) => {
if (noBrowser) { if (noBrowser) {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
return; return;
} }
const options = getBrowserOpenOptions({ browser, browserProfile }); const options = getBrowserOpenOptions({ browser, browserProfile });
return open(url, options).catch(() => { await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`); writeStderr(`Visit:\n${url}`);
}); });
}, },
@@ -395,16 +480,17 @@ export async function acquireResourceTokenFromLogin({
tenantId, tenantId,
clientId, clientId,
resource, resource,
}) { }: AcquireResourceTokenOptions): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
if (!resource) throw new Error("resource is required"); if (!resource) throw new Error("resource is required");
const scope = RESOURCE_SCOPE_BY_NAME[resource]; if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) {
if (!scope) {
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`); throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
} }
const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName];
const session = await readSessionState(); const session = await readSessionState();
if (!session.activeAccountUpn) { if (!session.activeAccountUpn) {
throw new Error(LOGIN_REQUIRED_MESSAGE); throw new Error(LOGIN_REQUIRED_MESSAGE);
@@ -434,7 +520,7 @@ export async function logout({
clientId, clientId,
clearAll = false, clearAll = false,
userPrincipalName, userPrincipalName,
}) { }: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
@@ -450,7 +536,7 @@ export async function logout({
await clearSessionState(); await clearSessionState();
return { return {
clearedAll: true, 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(); await clearSessionState();
return { return {
clearedAll: false, clearedAll: false,
signedOut: [accountToSignOut.username].filter(Boolean), signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)),
}; };
} }

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import { parseArgs } from "node:util";
import { runCommand } from "./cli/commands.js";
import {
normalizeOutputFormat,
omitPermissionGuidColumns,
outputFiltered,
parseHeaderSpec,
renderOutput,
} from "./cli/utils.js";
function usage() {
return `Usage: sk-az-tools <command> [options]
Commands:
login Authenticate selected resources
logout Sign out and clear login state
list-apps List Entra applications
list-app-permissions List required permissions for an app
list-app-grants List OAuth2 grants for an app
list-resource-permissions List available permissions for a resource app
table Render stdin JSON as Markdown table
Global options (all commands):
-q, --query <jmespath>
-o, --output <format> table|t|alignedtable|at|prettytable|pt|tsv
-h, --help
Use: sk-az-tools --help <command>
or: sk-az-tools <command> --help`;
}
function usageListApps() {
return `Usage: sk-az-tools list-apps [--display-name|-n <name>] [--app-id|-i <appId>] [--filter|-f <glob>] [global options]
Options:
-n, --display-name <name> Get app by name
-i, --app-id <appId> Get app by id
-f, --filter <glob> Filter by app display name glob`;
}
function usageLogin() {
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]
Options:
--resources <csv> Comma-separated resources: graph,devops,arm (default: all)
--use-device-code Use device code flow instead of interactive flow
--no-browser Do not launch browser; print interactive URL to stderr
--browser <name> Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox
--browser-profile <name> Chromium profile name (e.g. Default, "Profile 1")`;
}
function usageLogout() {
return `Usage: sk-az-tools logout [--all] [global options]
Options:
--all Clear login state and remove all cached accounts`;
}
function usageListAppPermissions() {
return `Usage: sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]
Options:
-i, --app-id <appId> Application (client) ID (required)
-r, --resolve Resolve permission GUIDs to human-readable values
-s, --short Makes output more compact
-f, --filter <glob> Filter by permission name glob`;
}
function usageListAppGrants() {
return `Usage: sk-az-tools list-app-grants --app-id|-i <appId> [global options]
Options:
-i, --app-id <appId> Application (client) ID (required)`;
}
function usageListResourcePermissions() {
return `Usage: sk-az-tools list-resource-permissions [--app-id|-i <appId> | --display-name|-n <name>] [--filter|-f <glob>] [global options]
Options:
-i, --app-id <appId> Resource app ID
-n, --display-name <name> Resource app display name
-f, --filter <glob> Filter by permission name glob`;
}
function usageTable() {
return `Usage: sk-az-tools table [--header|-H <spec|auto|a|original|o>] [global options]
Options:
-H, --header <value> Header mode/spec: auto|a (default), original|o, OR "col1, col2" OR "key1: Label 1, key2: Label 2"`;
}
function usageCommand(command) {
switch (command) {
case "login":
return usageLogin();
case "list-apps":
return usageListApps();
case "logout":
return usageLogout();
case "list-app-permissions":
return usageListAppPermissions();
case "list-app-grants":
return usageListAppGrants();
case "list-resource-permissions":
return usageListResourcePermissions();
case "table":
return usageTable();
default:
return `Unknown command: ${command}\n\n${usage()}`;
}
}
async function main() {
const argv = process.argv.slice(2);
const command = argv[0];
if (!command) {
console.log(usage());
process.exit(0);
}
if (command === "-h" || command === "--help") {
const helpCommand = argv[1];
console.log(helpCommand ? usageCommand(helpCommand) : usage());
process.exit(0);
}
const { values } = parseArgs({
args: argv.slice(1),
options: {
help: { type: "boolean", short: "h" },
"display-name": { type: "string", short: "n" },
"app-id": { type: "string", short: "i" },
resources: { type: "string" },
"use-device-code": { type: "boolean" },
"no-browser": { type: "boolean" },
browser: { type: "string" },
"browser-profile": { type: "string" },
all: { type: "boolean" },
resolve: { type: "boolean", short: "r" },
short: { type: "boolean", short: "s" },
filter: { type: "string", short: "f" },
query: { type: "string", short: "q" },
header: { type: "string", short: "H" },
output: { type: "string", short: "o" },
},
strict: true,
allowPositionals: false,
});
if (values.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
? omitPermissionGuidColumns(filtered)
: filtered;
const headerSpec = parseHeaderSpec(values.header);
renderOutput(command, output, outputFormat, headerSpec);
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
console.error(usage());
process.exit(1);
});

165
src/cli.ts Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import { parseArgs } from "node:util";
import { runCommand } from "./cli/commands.ts";
import { usageGetToken } from "./cli/commands/get-token.ts";
import { usageListAppGrants } from "./cli/commands/list-app-grants.ts";
import { usageListAppPermissions } from "./cli/commands/list-app-permissions.ts";
import { usageListApps } from "./cli/commands/list-apps.ts";
import { usageListResourcePermissions } from "./cli/commands/list-resource-permissions.ts";
import { usageLogin } from "./cli/commands/login.ts";
import { usageLogout } from "./cli/commands/logout.ts";
import { usageRest } from "./cli/commands/rest.ts";
import {
normalizeOutputFormat,
outputFiltered,
parseHeaderSpec,
renderOutput,
} from "@slawek/sk-tools";
type CliValues = {
help?: boolean;
type?: string;
method?: string;
url?: string;
"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 <command> [options]
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
list-resource-permissions List available permissions for a resource app
Global options (all commands):
-q, --query <jmespath>
-o, --output <format> table|t|alignedtable|at|prettytable|pt|tsv
-h, --help
Use: sk-az-tools --help <command>
or: sk-az-tools <command> --help`;
}
function usageCommand(command: string): string {
switch (command) {
case "login":
return usageLogin();
case "list-apps":
return usageListApps();
case "logout":
return usageLogout();
case "get-token":
return usageGetToken();
case "rest":
return usageRest();
case "list-app-permissions":
return usageListAppPermissions();
case "list-app-grants":
return usageListAppGrants();
case "list-resource-permissions":
return usageListResourcePermissions();
default:
return `Unknown command: ${command}\n\n${usage()}`;
}
}
function omitRecords(record: Record<string, unknown>, names: Set<string>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(record).filter(([key]) => !names.has(key)),
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const command = argv[0];
if (!command) {
console.log(usage());
process.exit(0);
}
if (command === "-h" || command === "--help") {
const helpCommand = argv[1];
console.log(helpCommand ? usageCommand(helpCommand) : usage());
process.exit(0);
}
const { values } = parseArgs({
args: argv.slice(1),
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" },
"use-device-code": { type: "boolean" },
"no-browser": { type: "boolean" },
browser: { type: "string" },
"browser-profile": { type: "string" },
all: { type: "boolean" },
resolve: { type: "boolean", short: "r" },
short: { type: "boolean", short: "s" },
filter: { type: "string", short: "f" },
query: { type: "string", short: "q" },
header: { type: "string", short: "H" },
output: { type: "string", short: "o" },
},
strict: true,
allowPositionals: false,
});
const typedValues = values as CliValues;
if (typedValues.help) {
console.log(usageCommand(command));
process.exit(0);
}
const outputFormat = normalizeOutputFormat(typedValues.output);
const result = await runCommand(command, typedValues);
const filtered = outputFiltered(result, typedValues.query);
let output: unknown = filtered;
if (command === "list-app-permissions" && typedValues.short && Array.isArray(filtered) && filtered.every(isRecord)) {
const names = new Set(["resourceAppId", "permissionId"]);
output = filtered.map((item) => omitRecords(item, names));
}
const headerSpec = command === "rest"
? parseHeaderSpec(undefined)
: parseHeaderSpec(typedValues.header);
renderOutput(outputFormat, headerSpec, output);
}
main().catch((err: unknown) => {
const error = err as Error;
console.error(`Error: ${error.message}`);
console.error(usage());
process.exit(1);
});

View File

@@ -1,141 +0,0 @@
// SPDX-License-Identifier: MIT
import { minimatch } from "minimatch";
import { loadPublicConfig } from "../index.js";
import { getGraphClient } from "../graph/auth.js";
import { login, logout } from "../azure/index.js";
import {
listApps,
listAppPermissions,
listAppPermissionsResolved,
listAppGrants,
listResourcePermissions,
} from "../graph/app.js";
import { readJsonFromStdin } from "./utils.js";
function filterByPermissionName(rows, pattern) {
return rows.filter((item) =>
minimatch(item.permissionValue ?? "", pattern, { nocase: true })
|| minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true })
);
}
function filterByDisplayName(rows, pattern) {
return rows.filter((item) =>
minimatch(item.displayName ?? "", pattern, { nocase: true })
);
}
async function getGraphClientFromPublicConfig() {
const config = await loadPublicConfig();
return getGraphClient({
tenantId: config.tenantId,
clientId: config.clientId,
});
}
async function runTableCommand() {
return readJsonFromStdin();
}
async function runLoginCommand(values) {
const config = await loadPublicConfig();
return login({
tenantId: config.tenantId,
clientId: config.clientId,
resourcesCsv: values.resources,
useDeviceCode: Boolean(values["use-device-code"]),
noBrowser: Boolean(values["no-browser"]),
browser: values.browser,
browserProfile: values["browser-profile"],
});
}
async function runLogoutCommand(values) {
const config = await loadPublicConfig();
return logout({
tenantId: config.tenantId,
clientId: config.clientId,
clearAll: Boolean(values.all),
});
}
async function runListAppsCommand(values) {
const { client } = await getGraphClientFromPublicConfig();
let result = await listApps(client, {
displayName: values["display-name"],
appId: values["app-id"],
});
if (values["app-id"] && result.length > 1) {
throw new Error(`Expected a single app for --app-id ${values["app-id"]}, but got ${result.length}`);
}
if (values.filter) {
result = filterByDisplayName(result, values.filter);
}
return result;
}
async function runListAppPermissionsCommand(values) {
if (!values["app-id"]) {
throw new Error("--app-id is required for list-app-permissions");
}
const { client } = await getGraphClientFromPublicConfig();
let result = values.resolve || values.filter
? await listAppPermissionsResolved(client, values["app-id"])
: await listAppPermissions(client, values["app-id"]);
if (values.filter) {
result = filterByPermissionName(result, values.filter);
}
return result;
}
async function runListAppGrantsCommand(values) {
if (!values["app-id"]) {
throw new Error("--app-id is required for list-app-grants");
}
const { client } = await getGraphClientFromPublicConfig();
return listAppGrants(client, values["app-id"]);
}
async function runListResourcePermissionsCommand(values) {
if (!values["app-id"] && !values["display-name"]) {
throw new Error("--app-id or --display-name is required for list-resource-permissions");
}
if (values["app-id"] && values["display-name"]) {
throw new Error("Use either --app-id or --display-name for list-resource-permissions, not both");
}
const { client } = await getGraphClientFromPublicConfig();
let result = await listResourcePermissions(client, {
appId: values["app-id"],
displayName: values["display-name"],
});
if (values.filter) {
result = filterByPermissionName(result, values.filter);
}
return result;
}
export async function runCommand(command, values) {
switch (command) {
case "login":
return runLoginCommand(values);
case "logout":
return runLogoutCommand(values);
case "table":
return runTableCommand();
case "list-apps":
return runListAppsCommand(values);
case "list-app-permissions":
return runListAppPermissionsCommand(values);
case "list-app-grants":
return runListAppGrantsCommand(values);
case "list-resource-permissions":
return runListResourcePermissionsCommand(values);
default:
throw new Error(`Unknown command: ${command}`);
}
}

35
src/cli/commands.ts Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
import { runGetTokenCommand } from "./commands/get-token.ts";
import { runListAppGrantsCommand } from "./commands/list-app-grants.ts";
import { runListAppPermissionsCommand } from "./commands/list-app-permissions.ts";
import { runListAppsCommand } from "./commands/list-apps.ts";
import { runListResourcePermissionsCommand } from "./commands/list-resource-permissions.ts";
import { runLoginCommand } from "./commands/login.ts";
import { runLogoutCommand } from "./commands/logout.ts";
import { runRestCommand } from "./commands/rest.ts";
import type { CommandValues } from "./commands/types.ts";
export async function runCommand(command: string, values: CommandValues): Promise<unknown> {
switch (command) {
case "login":
return runLoginCommand(values);
case "logout":
return runLogoutCommand(values);
case "list-apps":
return runListAppsCommand(values);
case "list-app-permissions":
return runListAppPermissionsCommand(values);
case "list-app-grants":
return runListAppGrantsCommand(values);
case "list-resource-permissions":
return runListResourcePermissionsCommand(values);
case "get-token":
return runGetTokenCommand(values);
case "rest":
return runRestCommand(values);
default:
throw new Error(`Unknown command: ${command}`);
}
}

View File

@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
import { acquireResourceTokenFromLogin } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadPublicConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts";
export function usageGetToken(): string {
return `Usage: sk-az-tools get-token --type|-t <azurerm|devops> [global options]
Options:
--type, -t <value> Token type: azurerm|devops`;
}
export async function runGetTokenCommand(values: CommandValues): Promise<unknown> {
const tokenType = (values.type ?? "").toString().trim().toLowerCase();
if (!tokenType) {
throw new Error("--type is required for get-token (allowed: azurerm, devops)");
}
const config = await loadPublicConfig();
if (!config.tenantId) {
throw new Error("tenantId is required");
}
if (!config.clientId) {
throw new Error("clientId is required");
}
if (tokenType === "azurerm") {
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 {
tokenType,
accessToken,
};
}
if (tokenType === "devops") {
const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId);
return {
tokenType,
accessToken,
};
}
throw new Error(`Invalid --type '${values.type}'. Allowed: azurerm, devops`);
}

View File

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
import { listAppGrants } from "../../graph/app.ts";
import { getGraphClientFromPublicConfig } from "./shared.ts";
import type { CommandValues } from "./types.ts";
export function usageListAppGrants(): string {
return `Usage: sk-az-tools list-app-grants --app-id|-i <appId> [global options]
Options:
--app-id, -i <appId> Application (client) ID (required)`;
}
export async function runListAppGrantsCommand(values: CommandValues): Promise<unknown> {
if (!values["app-id"]) {
throw new Error("--app-id is required for list-app-grants");
}
const { client } = await getGraphClientFromPublicConfig();
return listAppGrants(client, values["app-id"]);
}

View File

@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
import { listAppPermissions, listAppPermissionsResolved } from "../../graph/app.ts";
import { filterByPermissionName, getGraphClientFromPublicConfig } from "./shared.ts";
import type { CommandValues } from "./types.ts";
export function usageListAppPermissions(): string {
return `Usage: sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]
Options:
--app-id, -i <appId> Application (client) ID (required)
--resolve, -r Resolve permission GUIDs to human-readable values
--short, -s Makes output more compact
--filter, -f <glob> Filter by permission name glob`;
}
export async function runListAppPermissionsCommand(values: CommandValues): Promise<unknown> {
if (!values["app-id"]) {
throw new Error("--app-id is required for list-app-permissions");
}
const { client } = await getGraphClientFromPublicConfig();
let result = values.resolve || values.filter
? await listAppPermissionsResolved(client, values["app-id"])
: await listAppPermissions(client, values["app-id"]);
if (values.filter) {
result = filterByPermissionName(result, values.filter);
}
return result;
}

View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
import { listApps } from "../../graph/app.ts";
import { filterByDisplayName, getGraphClientFromPublicConfig } from "./shared.ts";
import type { CommandValues } from "./types.ts";
export function usageListApps(): string {
return `Usage: sk-az-tools list-apps [--display-name|-n <name>] [--app-id|-i <appId>] [--filter|-f <glob>] [global options]
Options:
--display-name, -n <name> Get app by name
--app-id, -i <appId> Get app by id
--filter, -f <glob> Filter by app display name glob`;
}
export async function runListAppsCommand(values: CommandValues): Promise<unknown> {
const { client } = await getGraphClientFromPublicConfig();
let result = await listApps(client, {
displayName: values["display-name"],
appId: values["app-id"],
});
if (values["app-id"] && result.length > 1) {
throw new Error(`Expected a single app for --app-id ${values["app-id"]}, but got ${result.length}`);
}
if (values.filter) {
result = filterByDisplayName(result, values.filter);
}
return result;
}

View File

@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
import { listResourcePermissions } from "../../graph/app.ts";
import { filterByPermissionName, getGraphClientFromPublicConfig } from "./shared.ts";
import type { CommandValues } from "./types.ts";
export function usageListResourcePermissions(): string {
return `Usage: sk-az-tools list-resource-permissions [--app-id|-i <appId> | --display-name|-n <name>] [--filter|-f <glob>] [global options]
Options:
--app-id, -i <appId> Resource app ID
--display-name, -n <name> Resource app display name
--filter, -f <glob> Filter by permission name glob`;
}
export async function runListResourcePermissionsCommand(values: CommandValues): Promise<unknown> {
if (!values["app-id"] && !values["display-name"]) {
throw new Error("--app-id or --display-name is required for list-resource-permissions");
}
if (values["app-id"] && values["display-name"]) {
throw new Error("Use either --app-id or --display-name for list-resource-permissions, not both");
}
const { client } = await getGraphClientFromPublicConfig();
let result = await listResourcePermissions(client, {
appId: values["app-id"],
displayName: values["display-name"],
});
if (values.filter) {
result = filterByPermissionName(result, values.filter);
}
return result;
}

30
src/cli/commands/login.ts Normal file
View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
import { login } from "../../azure/index.ts";
import { loadPublicConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts";
export function usageLogin(): string {
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]
Options:
--resources <csv> Comma-separated resources: graph,devops,arm (default: all)
--use-device-code Use device code flow instead of interactive flow
--no-browser Do not launch browser; print interactive URL to stderr
--browser <name> Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox
--browser-profile <name> Chromium profile name (e.g. Default, "Profile 1")`;
}
export async function runLoginCommand(values: CommandValues): Promise<unknown> {
const config = await loadPublicConfig();
return login({
tenantId: config.tenantId,
clientId: config.clientId,
resourcesCsv: values.resources,
useDeviceCode: Boolean(values["use-device-code"]),
noBrowser: Boolean(values["no-browser"]),
browser: values.browser,
browserProfile: values["browser-profile"],
});
}

View File

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
import { logout } from "../../azure/index.ts";
import { loadPublicConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts";
export function usageLogout(): string {
return `Usage: sk-az-tools logout [--all] [global options]
Options:
--all Clear login state and remove all cached accounts`;
}
export async function runLogoutCommand(values: CommandValues): Promise<unknown> {
const config = await loadPublicConfig();
return logout({
tenantId: config.tenantId,
clientId: config.clientId,
clearAll: Boolean(values.all),
});
}

129
src/cli/commands/rest.ts Normal file
View File

@@ -0,0 +1,129 @@
// SPDX-License-Identifier: MIT
import { acquireResourceTokenFromLogin } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadPublicConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts";
export 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 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}`;
}
export 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,
};
}

View File

@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
import { minimatch } from "minimatch";
import { loadPublicConfig } from "../../index.ts";
import { getGraphClient } from "../../graph/auth.ts";
type PermissionRow = {
permissionValue?: string | null;
permissionDisplayName?: string | null;
};
type DisplayNameRow = {
displayName?: string | null;
};
export function filterByPermissionName<T extends PermissionRow>(rows: T[], pattern: string): T[] {
return rows.filter((item) =>
minimatch(item.permissionValue ?? "", pattern, { nocase: true })
|| minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true }),
);
}
export function filterByDisplayName<T extends DisplayNameRow>(rows: T[], pattern: string): T[] {
return rows.filter((item) =>
minimatch(item.displayName ?? "", pattern, { nocase: true }),
);
}
export async function getGraphClientFromPublicConfig(): Promise<{ client: any }> {
const config = await loadPublicConfig();
return getGraphClient({
tenantId: config.tenantId,
clientId: config.clientId,
});
}

19
src/cli/commands/types.ts Normal file
View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
export type CommandValues = {
[key: string]: string | boolean | undefined;
type?: string;
method?: string;
url?: string;
header?: string;
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;
};

View File

@@ -1,176 +0,0 @@
// SPDX-License-Identifier: MIT
import jmespath from "jmespath";
import { toMarkdownTable } from "../markdown.js";
export function outputFiltered(object, query) {
return query
? jmespath.search(object, query)
: object;
}
export function parseHeaderSpec(headerValue) {
if (!headerValue) {
return { mode: "auto" };
}
const raw = headerValue.trim();
if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") {
return { mode: "auto" };
}
if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") {
return { mode: "original" };
}
const parts = raw.split(",").map((p) => p.trim()).filter(Boolean);
const isMap = parts.some((p) => p.includes(":"));
if (!isMap) {
return { mode: "list", labels: parts };
}
const map = {};
for (const part of parts) {
const idx = part.indexOf(":");
if (idx < 0) {
throw new Error(`Invalid --header mapping segment: '${part}'`);
}
const key = part.slice(0, idx).trim();
const label = part.slice(idx + 1).trim();
if (!key || !label) {
throw new Error(`Invalid --header mapping segment: '${part}'`);
}
map[key] = label;
}
return { mode: "map", map };
}
export function normalizeOutputFormat(outputValue) {
if (outputValue == null) {
return "json";
}
const raw = outputValue.toLowerCase();
if (raw === "json") {
throw new Error("JSON is the default output. Omit --output to use it.");
}
if (raw === "j") {
throw new Error("JSON is the default output. Omit --output to use it.");
}
if (raw === "table" || raw === "t") return "table";
if (raw === "alignedtable" || raw === "at") return "alignedtable";
if (raw === "prettytable" || raw === "pt") return "prettytable";
if (raw === "tsv") return "tsv";
throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv");
}
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 v = row[key];
return v == null || typeof v !== "object";
}),
);
if (headers.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
return { headers, rows };
}
function toTsv(value) {
const { headers, rows } = getScalarRowsAndHeaders(value);
const lines = rows.map((row) =>
headers
.map((header) => (row[header] == null ? "" : String(row[header]).replaceAll("\t", " ").replaceAll("\n", " ")))
.join("\t"),
);
return lines.join("\n");
}
export function omitPermissionGuidColumns(value) {
if (Array.isArray(value)) {
return value.map((item) => omitPermissionGuidColumns(item));
}
if (!value || typeof value !== "object") {
return value;
}
const { resourceAppId, permissionId, ...rest } = value;
return rest;
}
export async function readJsonFromStdin() {
const input = await new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => {
resolve(data);
});
process.stdin.on("error", (err) => {
reject(err);
});
});
if (!input.trim()) {
throw new Error("No JSON input provided on stdin");
}
try {
return JSON.parse(input);
} catch (err) {
throw new Error(`Invalid JSON input on stdin: ${err.message}`);
}
}
export function renderOutput(command, output, outputFormat, headerSpec) {
if (outputFormat === "tsv") {
console.log(toTsv(output));
return;
}
if (command === "table") {
console.log(toMarkdownTable(
output,
outputFormat === "alignedtable" || outputFormat === "prettytable",
outputFormat === "prettytable",
headerSpec,
));
} else if (outputFormat === "alignedtable") {
console.log(toMarkdownTable(output, true, false, headerSpec));
} else if (outputFormat === "prettytable") {
console.log(toMarkdownTable(output, true, true, headerSpec));
} else if (outputFormat === "table") {
console.log(toMarkdownTable(output, false, false, headerSpec));
} else {
console.log(JSON.stringify(output, null, 2));
}
}

33
scripts/create-pca.js → src/create-pca.ts Executable file → Normal file
View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -9,7 +8,18 @@ import readline from "node:readline";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
function runAz(args, options = {}) { 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, { const result = spawnSync("az", args, {
encoding: "utf8", encoding: "utf8",
stdio: options.quiet stdio: options.quiet
@@ -34,13 +44,14 @@ function runAz(args, options = {}) {
}; };
} }
async function main() { async function main(): Promise<void> {
const usageText = `Usage: ${path.basename(process.argv[1])} [options] <app-name> const usageText = `Usage: ${path.basename(process.argv[1])} [options] <app-name>
Options: Options:
-c, --config <path> Write JSON config to file (optional) -c, --config <path> Write JSON config to file (optional)
-h, --help Show this help message and exit`; -h, --help Show this help message and exit`;
let values;
let positionals; let values: Record<string, string | boolean | undefined>;
let positionals: string[];
try { try {
({ values, positionals } = parseArgs({ ({ values, positionals } = parseArgs({
args: process.argv.slice(2), args: process.argv.slice(2),
@@ -52,7 +63,7 @@ Options:
allowPositionals: true, allowPositionals: true,
})); }));
} catch (err) { } catch (err) {
console.error(`Error: ${err.message}`); console.error(`Error: ${(err as Error).message}`);
console.error(usageText); console.error(usageText);
process.exit(1); process.exit(1);
} }
@@ -71,7 +82,7 @@ Options:
} }
const appName = positionals[0] || ""; const appName = positionals[0] || "";
const configPath = values.config || ""; const configPath = typeof values.config === "string" ? values.config : "";
if (!appName) { if (!appName) {
console.error("Error: Application name is required."); console.error("Error: Application name is required.");
@@ -96,12 +107,12 @@ Options:
input: process.stdin, input: process.stdin,
output: process.stderr, output: process.stderr,
}); });
const answer = await new Promise((resolve) => { const answer = await new Promise<string>((resolve) => {
rl.question( rl.question(
`Application '${appName}' already exists. Update it? [y/N]: `, `Application '${appName}' already exists. Update it? [y/N]: `,
(answer) => { (answerValue) => {
rl.close(); rl.close();
resolve(answer.trim()); resolve(answerValue.trim());
}, },
); );
}); });
@@ -251,6 +262,6 @@ Options:
} }
main().catch((err) => { main().catch((err) => {
console.error(`Error: ${err.message}`); console.error(`Error: ${(err as Error).message}`);
process.exit(1); process.exit(1);
}); });

View File

@@ -1 +0,0 @@
//

View File

@@ -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<string> } 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 };
}

42
src/devops/index.ts Normal file
View File

@@ -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<string> {
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 };
}

View File

@@ -1,51 +1,77 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
/** type GraphObject = Record<string, unknown>;
* Get an Azure application by its display name.
* type GraphResult<T = GraphObject> = {
* @param { Object } client value?: T[];
* @param { string } displayName };
* @returns { Promise<Object|null> }
*/ type AppQueryOptions = {
export async function getApp(client, displayName) { 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<GraphObject | null> {
const result = await client const result = await client
.api("/applications") .api("/applications")
.filter(`displayName eq '${displayName}'`) .filter(`displayName eq '${displayName}'`)
.get(); .get() as GraphResult;
// Return the first application found or null if none exists return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
return result.value.length > 0 ? result.value[0] : null;
} }
export async function createApp(client, displayName) { export async function createApp(client: any, displayName: string): Promise<GraphObject> {
const app = await client.api("/applications").post({ const app = await client.api("/applications").post({
displayName, displayName,
}); }) as GraphObject;
if (!app || !app.appId) { if (!app || typeof app.appId !== "string") {
throw new Error("Failed to create application"); throw new Error("Failed to create application");
} }
return app; return app;
} }
export async function deleteApp(client, appObjectId) { export async function deleteApp(client: any, appObjectId: string): Promise<void> {
await client.api(`/applications/${appObjectId}`).delete(); await client.api(`/applications/${appObjectId}`).delete();
} }
/** export async function listApps(client: any, options: AppQueryOptions = {}): Promise<GraphObject[]> {
* 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<Array> }
*/
export async function listApps(client, options = {}) {
const { displayName, appId } = options; const { displayName, appId } = options;
let request = client.api("/applications"); let request = client.api("/applications");
const filters = []; const filters: string[] = [];
if (displayName) { if (displayName) {
filters.push(`displayName eq '${displayName}'`); filters.push(`displayName eq '${displayName}'`);
@@ -58,18 +84,11 @@ export async function listApps(client, options = {}) {
request = request.filter(filters.join(" and ")); 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 : []; return Array.isArray(result?.value) ? result.value : [];
} }
/** export async function listAppPermissions(client: any, appId: string): Promise<RequiredResourceAccess[]> {
* 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) { if (!appId) {
throw new Error("appId is required"); throw new Error("appId is required");
} }
@@ -78,7 +97,7 @@ export async function listAppPermissions(client, appId) {
.api("/applications") .api("/applications")
.filter(`appId eq '${appId}'`) .filter(`appId eq '${appId}'`)
.select("id,appId,displayName,requiredResourceAccess") .select("id,appId,displayName,requiredResourceAccess")
.get(); .get() as GraphResult<GraphObject>;
const app = Array.isArray(result?.value) && result.value.length > 0 const app = Array.isArray(result?.value) && result.value.length > 0
? result.value[0] ? result.value[0]
@@ -88,19 +107,13 @@ export async function listAppPermissions(client, appId) {
return []; return [];
} }
return Array.isArray(app.requiredResourceAccess) const requiredResourceAccess = app.requiredResourceAccess;
? app.requiredResourceAccess return Array.isArray(requiredResourceAccess)
? requiredResourceAccess as RequiredResourceAccess[]
: []; : [];
} }
/** export async function listAppPermissionsResolved(client: any, appId: string): Promise<Array<Record<string, unknown>>> {
* List required resource access in a resolved, human-readable form.
*
* @param { Object } client
* @param { string } appId
* @returns { Promise<Array> }
*/
export async function listAppPermissionsResolved(client, appId) {
const requiredResourceAccess = await listAppPermissions(client, appId); const requiredResourceAccess = await listAppPermissions(client, appId);
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) { if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
return []; return [];
@@ -109,7 +122,7 @@ export async function listAppPermissionsResolved(client, appId) {
const resourceAppIds = [...new Set( const resourceAppIds = [...new Set(
requiredResourceAccess requiredResourceAccess
.map((entry) => entry?.resourceAppId) .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) => { const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
@@ -117,17 +130,21 @@ export async function listAppPermissionsResolved(client, appId) {
.api("/servicePrincipals") .api("/servicePrincipals")
.filter(`appId eq '${resourceAppId}'`) .filter(`appId eq '${resourceAppId}'`)
.select("appId,displayName,oauth2PermissionScopes,appRoles") .select("appId,displayName,oauth2PermissionScopes,appRoles")
.get(); .get() as GraphResult<ServicePrincipal>;
const sp = Array.isArray(result?.value) && result.value.length > 0 const sp = Array.isArray(result?.value) && result.value.length > 0
? result.value[0] ? result.value[0]
: null; : null;
const scopesById = new Map( 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( 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 { return {
@@ -142,9 +159,10 @@ export async function listAppPermissionsResolved(client, appId) {
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]), resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
); );
const rows = []; const rows: Array<Record<string, unknown>> = [];
for (const resourceEntry of requiredResourceAccess) { 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) const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
? resourceEntry.resourceAccess ? resourceEntry.resourceAccess
: []; : [];
@@ -153,8 +171,8 @@ export async function listAppPermissionsResolved(client, appId) {
const permissionType = item?.type ?? null; const permissionType = item?.type ?? null;
const permissionId = item?.id ?? null; const permissionId = item?.id ?? null;
const resolved = permissionType === "Scope" const resolved = permissionType === "Scope"
? resourceMeta?.scopesById.get(permissionId) ? resourceMeta?.scopesById.get(permissionId ?? "")
: resourceMeta?.rolesById.get(permissionId); : resourceMeta?.rolesById.get(permissionId ?? "");
rows.push({ rows.push({
resourceAppId: resourceEntry.resourceAppId ?? null, resourceAppId: resourceEntry.resourceAppId ?? null,
@@ -174,14 +192,7 @@ export async function listAppPermissionsResolved(client, appId) {
return rows; return rows;
} }
/** export async function listAppGrants(client: any, appId: string): Promise<GraphObject[]> {
* 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) { if (!appId) {
throw new Error("appId is required"); throw new Error("appId is required");
} }
@@ -190,7 +201,7 @@ export async function listAppGrants(client, appId) {
.api("/servicePrincipals") .api("/servicePrincipals")
.filter(`appId eq '${appId}'`) .filter(`appId eq '${appId}'`)
.select("id,appId,displayName") .select("id,appId,displayName")
.get(); .get() as GraphResult<ServicePrincipal>;
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0 const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
? spResult.value[0] ? spResult.value[0]
@@ -203,21 +214,12 @@ export async function listAppGrants(client, appId) {
const grantsResult = await client const grantsResult = await client
.api("/oauth2PermissionGrants") .api("/oauth2PermissionGrants")
.filter(`clientId eq '${servicePrincipal.id}'`) .filter(`clientId eq '${servicePrincipal.id}'`)
.get(); .get() as GraphResult;
return Array.isArray(grantsResult?.value) ? grantsResult.value : []; return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
} }
/** export async function listResourcePermissions(client: any, options: ResourcePermissionsOptions = {}): Promise<Array<Record<string, unknown>>> {
* 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<Array> }
*/
export async function listResourcePermissions(client, options = {}) {
const { appId, displayName } = options; const { appId, displayName } = options;
if (!appId && !displayName) { if (!appId && !displayName) {
throw new Error("appId or displayName is required"); throw new Error("appId or displayName is required");
@@ -233,9 +235,9 @@ export async function listResourcePermissions(client, options = {}) {
request = request.filter(`displayName eq '${displayName}'`); request = request.filter(`displayName eq '${displayName}'`);
} }
const result = await request.get(); const result = await request.get() as GraphResult<ServicePrincipal>;
const servicePrincipals = Array.isArray(result?.value) ? result.value : []; const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
const rows = []; const rows: Array<Record<string, unknown>> = [];
for (const sp of servicePrincipals) { for (const sp of servicePrincipals) {
for (const scope of sp?.oauth2PermissionScopes ?? []) { for (const scope of sp?.oauth2PermissionScopes ?? []) {

View File

@@ -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 };
}

30
src/graph/auth.ts Normal file
View File

@@ -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 };
}

View File

@@ -1 +0,0 @@
//

View File

@@ -1,5 +0,0 @@
// SPDX-License-Identifier: MIT
export * from "./auth.js";
export * from "./app.js";
export * from "./sp.js";

5
src/graph/index.ts Normal file
View File

@@ -0,0 +1,5 @@
// SPDX-License-Identifier: MIT
export * from "./auth.ts";
export * from "./app.ts";
export * from "./sp.ts";

View File

@@ -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();
}

30
src/graph/sp.ts Normal file
View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
type GraphResult<T = Record<string, unknown>> = {
value?: T[];
};
export async function getServicePrincipal(client: any, appId: string): Promise<Record<string, unknown> | 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<Record<string, unknown>> {
const sp = await client.api("/servicePrincipals").post({
appId,
}) as Record<string, unknown>;
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<void> {
await client.api(`/servicePrincipals/${spId}`).delete();
}

1
src/index.d.ts vendored
View File

@@ -1 +0,0 @@
//

View File

@@ -4,7 +4,17 @@ import { readFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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") { if (process.platform === "win32") {
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"); 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"); return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
} }
async function loadConfig(configFileName) { async function loadConfig(configFileName: string): Promise<Config> {
if (typeof configFileName !== "string" || configFileName.trim() === "") { if (typeof configFileName !== "string" || configFileName.trim() === "") {
throw new Error( throw new Error(
'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".', '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, tenantId: process.env.AZURE_TENANT_ID,
clientId: process.env.AZURE_CLIENT_ID, clientId: process.env.AZURE_CLIENT_ID,
}; };
const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName); const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName);
return readFile(configPath, "utf8") return readFile(configPath, "utf8")
.then((configJson) => JSON.parse(configJson)) .then((configJson) => JSON.parse(configJson) as ConfigCandidate)
.catch((err) => { .catch((err: unknown) => {
if (err?.code === "ENOENT") { if ((err as { code?: string } | null)?.code === "ENOENT") {
return {}; return {} as ConfigCandidate;
} }
throw err; throw err;
}) })
.then((json) => ({ .then((json) => ({
tenantId: json.tenantId || config.tenantId, tenantId: typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId,
clientId: json.clientId || config.clientId, clientId: typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId,
})); }));
} }
export function loadPublicConfig() { export function loadPublicConfig(): Promise<Config> {
return loadConfig("public-config.json"); return loadConfig("public-config.json");
} }
export function loadConfidentialConfig() { export function loadConfidentialConfig(): Promise<Config> {
return loadConfig("confidential-config.json"); return loadConfig("confidential-config.json");
} }

View File

@@ -1,113 +0,0 @@
// SPDX-License-Identifier: MIT
function formatCell(value) {
const text = value == null
? ""
: String(value);
return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
}
function isGuid(value) {
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) {
const withSpaces = String(key)
.replace(/[_-]+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
return withSpaces
.split(" ")
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
}
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, quoteGuids = false) {
const headerSpec = arguments[3] ?? { mode: "default" };
const { headers, rows } = getScalarRowsAndHeaders(value);
const headerDefinitions = headers.map((key, idx) => {
let label = key;
if (headerSpec?.mode === "auto") {
label = toAutoHeaderLabel(key);
} else if (headerSpec?.mode === "list" && Array.isArray(headerSpec.labels) && headerSpec.labels[idx]) {
label = headerSpec.labels[idx];
} else if (headerSpec?.mode === "map" && headerSpec.map && headerSpec.map[key]) {
label = headerSpec.map[key];
}
return { key, label };
});
const renderCell = (raw) => {
const text = formatCell(raw);
return quoteGuids && isGuid(raw) ? `\`${text}\`` : text;
};
if (!pretty) {
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(" | ")} |`
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}
const widths = headerDefinitions.map((header, idx) =>
Math.max(
header.label.length,
...rows.map((row) => renderCell(row[headerDefinitions[idx].key]).length),
)
);
const renderRow = (values) =>
`| ${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])))
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}

20
tsconfig.json Normal file
View File

@@ -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"]
}