Compare commits
28 Commits
main-js
...
67dd2045e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 67dd2045e3 | |||
| 94a573f1e1 | |||
| ed18cb535a | |||
| 9c2aea491c | |||
| d39fdb3e33 | |||
| 2a0b49effe | |||
| fff80047c2 | |||
| a629a3a32d | |||
| 2fa9462657 | |||
| 03fb55d97f | |||
| a53d2896b1 | |||
| 3b37b26571 | |||
| 350577420b | |||
| 8cbd1d6399 | |||
| 21b6a51330 | |||
| 9b9aefc9a5 | |||
| 849a8505a2 | |||
| ba7bacbe12 | |||
| 9581ee1a31 | |||
| 9f023d44cc | |||
| d74d133a60 | |||
| 05d517709b | |||
| e70e668432 | |||
| e246657740 | |||
| 71ec95b52b | |||
| 21b8179d40 | |||
| b88b35cb90 | |||
| cb41e7dec1 |
30
.gitea/workflows/build.yml
Normal file
30
.gitea/workflows/build.yml
Normal 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
1
.gitignore
vendored
@@ -2,4 +2,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
artifacts/
|
||||
package-lock.json
|
||||
10
.npmignore
10
.npmignore
@@ -1,3 +1,11 @@
|
||||
docs
|
||||
src/
|
||||
*.ts
|
||||
tsconfig.json
|
||||
scripts/
|
||||
Dockerfile
|
||||
.git
|
||||
.gitignore
|
||||
artifacts/
|
||||
node_modules
|
||||
package-lock.json
|
||||
docs
|
||||
32
README.md
32
README.md
@@ -1,4 +1,36 @@
|
||||
# A set of Azure related NodeJS modules
|
||||
|
||||
[](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.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
34
docs/AzureCLIImpersonation.md
Normal file
34
docs/AzureCLIImpersonation.md
Normal 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
121
docs/Commands.md
Normal 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.
|
||||
|
||||
|
||||
@@ -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
1609
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -1,7 +1,19 @@
|
||||
{
|
||||
"name": "@slawek/sk-az-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.3",
|
||||
"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": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
@@ -11,22 +23,29 @@
|
||||
"@azure/msal-node": "^5.0.3",
|
||||
"@azure/msal-node-extensions": "^1.2.0",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@slawek/sk-tools": ">=0.1.0",
|
||||
"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": {
|
||||
"name": "Sławomir Koszewski",
|
||||
"email": "slawek@koszewscy.waw.pl"
|
||||
},
|
||||
"bin": {
|
||||
"sk-az-tools": "./src/cli.js"
|
||||
"sk-az-tools": "./dist/cli.js"
|
||||
},
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./azure": "./src/azure/index.js",
|
||||
"./graph": "./src/graph/index.js",
|
||||
"./devops": "./src/devops/index.js"
|
||||
".": "./dist/index.js",
|
||||
"./azure": "./dist/azure/index.js",
|
||||
"./graph": "./dist/graph/index.js",
|
||||
"./devops": "./dist/devops/index.js"
|
||||
}
|
||||
}
|
||||
|
||||
139
scripts/create-pca.sh
Executable file
139
scripts/create-pca.sh
Executable 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
44
scripts/delete-pca.sh
Executable 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'."
|
||||
210
scripts/make-mermaid-func-deps.mjs
Normal file
210
scripts/make-mermaid-func-deps.mjs
Normal 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);
|
||||
}
|
||||
@@ -2,7 +2,18 @@
|
||||
|
||||
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
|
||||
|
||||
export async function getCredential(credentialType, options) {
|
||||
type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode";
|
||||
|
||||
type CredentialOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
};
|
||||
|
||||
export async function getCredential(
|
||||
credentialType: CredentialType,
|
||||
options: CredentialOptions,
|
||||
): Promise<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> {
|
||||
switch (credentialType) {
|
||||
case "d":
|
||||
case "default":
|
||||
1
src/azure/index.d.ts
vendored
1
src/azure/index.d.ts
vendored
@@ -1 +0,0 @@
|
||||
//
|
||||
@@ -4,10 +4,9 @@
|
||||
* @module azure
|
||||
*
|
||||
* This module provides authentication functionalities for Azure services.
|
||||
*
|
||||
*/
|
||||
|
||||
export { getCredential } from "./client-auth.js";
|
||||
export { getCredential } from "./client-auth.ts";
|
||||
export {
|
||||
loginInteractive,
|
||||
loginDeviceCode,
|
||||
@@ -15,4 +14,4 @@ export {
|
||||
logout,
|
||||
parseResources,
|
||||
acquireResourceTokenFromLogin,
|
||||
} from "./pca-auth.js";
|
||||
} from "./pca-auth.ts";
|
||||
@@ -6,20 +6,76 @@ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { PublicClientApplication } from "@azure/msal-node";
|
||||
import type {
|
||||
AccountInfo,
|
||||
AuthenticationResult,
|
||||
ICachePlugin,
|
||||
TokenCacheContext,
|
||||
} from "@azure/msal-node";
|
||||
import os from "node:os";
|
||||
|
||||
const RESOURCE_SCOPE_BY_NAME = {
|
||||
graph: "https://graph.microsoft.com/.default",
|
||||
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
|
||||
arm: "https://management.azure.com/.default",
|
||||
};
|
||||
} as const;
|
||||
|
||||
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
|
||||
type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
|
||||
|
||||
const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"];
|
||||
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
|
||||
const BROWSER_KEYWORDS = Object.keys(apps).sort();
|
||||
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
|
||||
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
||||
|
||||
function getCacheRoot() {
|
||||
type SessionState = {
|
||||
activeAccountUpn: string | null;
|
||||
};
|
||||
|
||||
type BrowserOptions = {
|
||||
browser?: string;
|
||||
browserProfile?: string;
|
||||
};
|
||||
|
||||
type LoginInteractiveOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
scopes: string[];
|
||||
showAuthUrlOnly?: boolean;
|
||||
browser?: string;
|
||||
browserProfile?: string;
|
||||
};
|
||||
|
||||
type LoginDeviceCodeOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
type LoginOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
resourcesCsv?: string;
|
||||
useDeviceCode?: boolean;
|
||||
noBrowser?: boolean;
|
||||
browser?: string;
|
||||
browserProfile?: string;
|
||||
};
|
||||
|
||||
type AcquireResourceTokenOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
resource?: string;
|
||||
};
|
||||
|
||||
type LogoutOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
clearAll?: boolean;
|
||||
userPrincipalName?: string;
|
||||
};
|
||||
|
||||
function getCacheRoot(): string {
|
||||
const isWindows = process.platform === "win32";
|
||||
const userRoot = isWindows
|
||||
? process.env.LOCALAPPDATA || os.homedir()
|
||||
@@ -30,14 +86,14 @@ function getCacheRoot() {
|
||||
: path.join(userRoot, ".config", "sk-az-tools");
|
||||
}
|
||||
|
||||
function getSessionFilePath() {
|
||||
function getSessionFilePath(): string {
|
||||
return path.join(getCacheRoot(), "login-session.json");
|
||||
}
|
||||
|
||||
async function readSessionState() {
|
||||
async function readSessionState(): Promise<SessionState> {
|
||||
try {
|
||||
const sessionJson = await readFile(getSessionFilePath(), "utf8");
|
||||
const parsed = JSON.parse(sessionJson);
|
||||
const parsed = JSON.parse(sessionJson) as { activeAccountUpn?: unknown };
|
||||
return {
|
||||
activeAccountUpn:
|
||||
typeof parsed?.activeAccountUpn === "string"
|
||||
@@ -45,40 +101,40 @@ async function readSessionState() {
|
||||
: null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err?.code === "ENOENT") {
|
||||
if ((err as { code?: string } | null)?.code === "ENOENT") {
|
||||
return { activeAccountUpn: null };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSessionState(state) {
|
||||
async function writeSessionState(state: SessionState): Promise<void> {
|
||||
const sessionPath = getSessionFilePath();
|
||||
await mkdir(path.dirname(sessionPath), { recursive: true });
|
||||
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function clearSessionState() {
|
||||
async function clearSessionState(): Promise<void> {
|
||||
try {
|
||||
await unlink(getSessionFilePath());
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") {
|
||||
if ((err as { code?: string } | null)?.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUpn(upn) {
|
||||
function normalizeUpn(upn: unknown): string {
|
||||
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function writeStderr(message) {
|
||||
function writeStderr(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function getBrowserAppName(browser) {
|
||||
function getBrowserAppName(browser?: string): string | readonly string[] | undefined {
|
||||
if (!browser || browser.trim() === "") {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyword = BROWSER_KEYWORDS.find(
|
||||
@@ -90,10 +146,10 @@ function getBrowserAppName(browser) {
|
||||
);
|
||||
}
|
||||
|
||||
return apps[keyword];
|
||||
return OPEN_APPS[keyword];
|
||||
}
|
||||
|
||||
function getBrowserKeyword(browser) {
|
||||
function getBrowserKeyword(browser?: string): string {
|
||||
if (!browser || browser.trim() === "") {
|
||||
return "";
|
||||
}
|
||||
@@ -109,14 +165,13 @@ function getBrowserKeyword(browser) {
|
||||
return keyword.toLowerCase();
|
||||
}
|
||||
|
||||
function getBrowserOpenOptions({ browser, browserProfile }) {
|
||||
function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters<typeof open>[1] {
|
||||
const browserName = getBrowserAppName(browser);
|
||||
const options = browserName
|
||||
? { wait: false, app: { name: browserName } }
|
||||
: { wait: false };
|
||||
|
||||
if (!browserProfile || browserProfile.trim() === "") {
|
||||
return options;
|
||||
return browserName
|
||||
? { wait: false, app: { name: browserName } }
|
||||
: { wait: false };
|
||||
}
|
||||
|
||||
const browserKeyword = getBrowserKeyword(browser);
|
||||
@@ -126,11 +181,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) {
|
||||
);
|
||||
}
|
||||
|
||||
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`];
|
||||
return options;
|
||||
if (!browserName) {
|
||||
throw new Error("--browser-profile requires --browser");
|
||||
}
|
||||
|
||||
function validateBrowserOptions({ browser, browserProfile }) {
|
||||
return {
|
||||
wait: false,
|
||||
app: {
|
||||
name: browserName,
|
||||
arguments: [`--profile-directory=${browserProfile.trim()}`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): void {
|
||||
if (browser && browser.trim() !== "") {
|
||||
getBrowserAppName(browser);
|
||||
}
|
||||
@@ -145,7 +209,7 @@ function validateBrowserOptions({ browser, browserProfile }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseResources(resourcesCsv) {
|
||||
export function parseResources(resourcesCsv?: string): ResourceName[] {
|
||||
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
||||
return [...DEFAULT_RESOURCES];
|
||||
}
|
||||
@@ -156,24 +220,24 @@ export function parseResources(resourcesCsv) {
|
||||
.filter(Boolean);
|
||||
|
||||
const unique = [...new Set(resources)];
|
||||
const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]);
|
||||
const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return unique;
|
||||
return unique as ResourceName[];
|
||||
}
|
||||
|
||||
function fileCachePlugin(cachePath) {
|
||||
function fileCachePlugin(cachePath: string): ICachePlugin {
|
||||
return {
|
||||
beforeCacheAccess: async (ctx) => {
|
||||
beforeCacheAccess: async (ctx: TokenCacheContext) => {
|
||||
if (fs.existsSync(cachePath)) {
|
||||
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
|
||||
}
|
||||
},
|
||||
afterCacheAccess: async (ctx) => {
|
||||
afterCacheAccess: async (ctx: TokenCacheContext) => {
|
||||
if (!ctx.cacheHasChanged) return;
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
||||
@@ -182,10 +246,10 @@ function fileCachePlugin(cachePath) {
|
||||
};
|
||||
}
|
||||
|
||||
async function createPca({ tenantId, clientId }) {
|
||||
async function createPca({ tenantId, clientId }: { tenantId: string; clientId: string }): Promise<PublicClientApplication> {
|
||||
const cacheRoot = getCacheRoot();
|
||||
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
|
||||
let cachePlugin;
|
||||
let cachePlugin: ICachePlugin;
|
||||
try {
|
||||
const {
|
||||
DataProtectionScope,
|
||||
@@ -201,7 +265,7 @@ async function createPca({ tenantId, clientId }) {
|
||||
usePlaintextFileOnLinux: true,
|
||||
});
|
||||
cachePlugin = new PersistenceCachePlugin(persistence);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Fallback when msal-node-extensions/keytar/libsecret are unavailable.
|
||||
cachePlugin = fileCachePlugin(cachePath);
|
||||
}
|
||||
@@ -217,7 +281,15 @@ async function createPca({ tenantId, clientId }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function acquireTokenWithCache({ pca, scopes, account }) {
|
||||
async function acquireTokenWithCache({
|
||||
pca,
|
||||
scopes,
|
||||
account,
|
||||
}: {
|
||||
pca: PublicClientApplication;
|
||||
scopes: string[];
|
||||
account?: AccountInfo | null;
|
||||
}): Promise<AuthenticationResult | null> {
|
||||
if (account) {
|
||||
try {
|
||||
return await pca.acquireTokenSilent({
|
||||
@@ -244,7 +316,13 @@ async function acquireTokenWithCache({ pca, scopes, account }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findAccountByUpn({ pca, upn }) {
|
||||
async function findAccountByUpn({
|
||||
pca,
|
||||
upn,
|
||||
}: {
|
||||
pca: PublicClientApplication;
|
||||
upn: string | null;
|
||||
}): Promise<AccountInfo | null> {
|
||||
const normalized = normalizeUpn(upn);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@@ -264,11 +342,12 @@ export async function loginInteractive({
|
||||
showAuthUrlOnly = false,
|
||||
browser,
|
||||
browserProfile,
|
||||
}) {
|
||||
}: LoginInteractiveOptions): Promise<AuthenticationResult | null> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||
throw new Error("scopes[] is required");
|
||||
}
|
||||
validateBrowserOptions({ browser, browserProfile });
|
||||
|
||||
const pca = await createPca({ tenantId, clientId });
|
||||
@@ -276,33 +355,34 @@ export async function loginInteractive({
|
||||
const cached = await acquireTokenWithCache({ pca, scopes });
|
||||
if (cached) return cached;
|
||||
|
||||
return await pca.acquireTokenInteractive({
|
||||
return pca.acquireTokenInteractive({
|
||||
scopes,
|
||||
openBrowser: async (url) => {
|
||||
openBrowser: async (url: string) => {
|
||||
if (showAuthUrlOnly) {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
return;
|
||||
}
|
||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||
return open(url, options).catch(() => {
|
||||
await open(url, options).catch(() => {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginDeviceCode({ tenantId, clientId, scopes }) {
|
||||
export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise<AuthenticationResult | null> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||
throw new Error("scopes[] is required");
|
||||
}
|
||||
|
||||
const pca = await createPca({ tenantId, clientId });
|
||||
|
||||
const cached = await acquireTokenWithCache({ pca, scopes });
|
||||
if (cached) return cached;
|
||||
|
||||
return await pca.acquireTokenByDeviceCode({
|
||||
return pca.acquireTokenByDeviceCode({
|
||||
scopes,
|
||||
deviceCodeCallback: (response) => {
|
||||
writeStderr(response.message);
|
||||
@@ -318,7 +398,12 @@ export async function login({
|
||||
noBrowser = false,
|
||||
browser,
|
||||
browserProfile,
|
||||
}) {
|
||||
}: LoginOptions): Promise<{
|
||||
accountUpn: string | null;
|
||||
resources: Array<{ resource: string; expiresOn: string | null }>;
|
||||
flow: "device-code" | "interactive";
|
||||
browserLaunchAttempted: boolean;
|
||||
}> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
validateBrowserOptions({ browser, browserProfile });
|
||||
@@ -332,8 +417,8 @@ export async function login({
|
||||
upn: session.activeAccountUpn,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
let selectedAccount = preferredAccount;
|
||||
const results: Array<{ resource: string; expiresOn: string | null }> = [];
|
||||
let selectedAccount: AccountInfo | null = preferredAccount;
|
||||
for (let index = 0; index < resources.length; index += 1) {
|
||||
const resource = resources[index];
|
||||
const scope = [scopes[index]];
|
||||
@@ -354,13 +439,13 @@ export async function login({
|
||||
} else {
|
||||
token = await pca.acquireTokenInteractive({
|
||||
scopes: scope,
|
||||
openBrowser: async (url) => {
|
||||
openBrowser: async (url: string) => {
|
||||
if (noBrowser) {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
return;
|
||||
}
|
||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||
return open(url, options).catch(() => {
|
||||
await open(url, options).catch(() => {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
});
|
||||
},
|
||||
@@ -395,16 +480,17 @@ export async function acquireResourceTokenFromLogin({
|
||||
tenantId,
|
||||
clientId,
|
||||
resource,
|
||||
}) {
|
||||
}: AcquireResourceTokenOptions): Promise<AuthenticationResult | null> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
if (!resource) throw new Error("resource is required");
|
||||
|
||||
const scope = RESOURCE_SCOPE_BY_NAME[resource];
|
||||
if (!scope) {
|
||||
if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) {
|
||||
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
|
||||
}
|
||||
|
||||
const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName];
|
||||
|
||||
const session = await readSessionState();
|
||||
if (!session.activeAccountUpn) {
|
||||
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
||||
@@ -434,7 +520,7 @@ export async function logout({
|
||||
clientId,
|
||||
clearAll = false,
|
||||
userPrincipalName,
|
||||
}) {
|
||||
}: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
|
||||
@@ -450,7 +536,7 @@ export async function logout({
|
||||
await clearSessionState();
|
||||
return {
|
||||
clearedAll: true,
|
||||
signedOut: accounts.map((account) => account.username).filter(Boolean),
|
||||
signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,6 +554,6 @@ export async function logout({
|
||||
await clearSessionState();
|
||||
return {
|
||||
clearedAll: false,
|
||||
signedOut: [accountToSignOut.username].filter(Boolean),
|
||||
signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)),
|
||||
};
|
||||
}
|
||||
173
src/cli.js
173
src/cli.js
@@ -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
165
src/cli.ts
Normal 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);
|
||||
});
|
||||
@@ -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
35
src/cli/commands.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
57
src/cli/commands/get-token.ts
Normal file
57
src/cli/commands/get-token.ts
Normal 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`);
|
||||
}
|
||||
22
src/cli/commands/list-app-grants.ts
Normal file
22
src/cli/commands/list-app-grants.ts
Normal 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"]);
|
||||
}
|
||||
31
src/cli/commands/list-app-permissions.ts
Normal file
31
src/cli/commands/list-app-permissions.ts
Normal 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;
|
||||
}
|
||||
30
src/cli/commands/list-apps.ts
Normal file
30
src/cli/commands/list-apps.ts
Normal 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;
|
||||
}
|
||||
34
src/cli/commands/list-resource-permissions.ts
Normal file
34
src/cli/commands/list-resource-permissions.ts
Normal 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
30
src/cli/commands/login.ts
Normal 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"],
|
||||
});
|
||||
}
|
||||
22
src/cli/commands/logout.ts
Normal file
22
src/cli/commands/logout.ts
Normal 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
129
src/cli/commands/rest.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
src/cli/commands/shared.ts
Normal file
36
src/cli/commands/shared.ts
Normal 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
19
src/cli/commands/types.ts
Normal 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;
|
||||
};
|
||||
176
src/cli/utils.js
176
src/cli/utils.js
@@ -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
33
scripts/create-pca.js → src/create-pca.ts
Executable file → Normal file
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -9,7 +8,18 @@ import readline from "node:readline";
|
||||
import { spawnSync } from "node:child_process";
|
||||
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, {
|
||||
encoding: "utf8",
|
||||
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>
|
||||
Options:
|
||||
-c, --config <path> Write JSON config to file (optional)
|
||||
-h, --help Show this help message and exit`;
|
||||
let values;
|
||||
let positionals;
|
||||
|
||||
let values: Record<string, string | boolean | undefined>;
|
||||
let positionals: string[];
|
||||
try {
|
||||
({ values, positionals } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
@@ -52,7 +63,7 @@ Options:
|
||||
allowPositionals: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
console.error(usageText);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -71,7 +82,7 @@ Options:
|
||||
}
|
||||
|
||||
const appName = positionals[0] || "";
|
||||
const configPath = values.config || "";
|
||||
const configPath = typeof values.config === "string" ? values.config : "";
|
||||
|
||||
if (!appName) {
|
||||
console.error("Error: Application name is required.");
|
||||
@@ -96,12 +107,12 @@ Options:
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
});
|
||||
const answer = await new Promise((resolve) => {
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`Application '${appName}' already exists. Update it? [y/N]: `,
|
||||
(answer) => {
|
||||
(answerValue) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
resolve(answerValue.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -251,6 +262,6 @@ Options:
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`Error: ${err.message}`);
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
1
src/devops/index.d.ts
vendored
1
src/devops/index.d.ts
vendored
@@ -1 +0,0 @@
|
||||
//
|
||||
@@ -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
42
src/devops/index.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,51 +1,77 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/**
|
||||
* Get an Azure application by its display name.
|
||||
*
|
||||
* @param { Object } client
|
||||
* @param { string } displayName
|
||||
* @returns { Promise<Object|null> }
|
||||
*/
|
||||
export async function getApp(client, displayName) {
|
||||
type GraphObject = Record<string, unknown>;
|
||||
|
||||
type GraphResult<T = GraphObject> = {
|
||||
value?: T[];
|
||||
};
|
||||
|
||||
type AppQueryOptions = {
|
||||
displayName?: string;
|
||||
appId?: string;
|
||||
};
|
||||
|
||||
type RequiredResourceAccessItem = {
|
||||
type?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type RequiredResourceAccess = {
|
||||
resourceAppId?: string;
|
||||
resourceAccess?: RequiredResourceAccessItem[];
|
||||
};
|
||||
|
||||
type GraphPermission = {
|
||||
id?: string;
|
||||
value?: string;
|
||||
displayName?: string;
|
||||
adminConsentDisplayName?: string;
|
||||
userConsentDisplayName?: string;
|
||||
isEnabled?: boolean;
|
||||
};
|
||||
|
||||
type ServicePrincipal = {
|
||||
id?: string;
|
||||
appId?: string;
|
||||
displayName?: string;
|
||||
oauth2PermissionScopes?: GraphPermission[];
|
||||
appRoles?: GraphPermission[];
|
||||
};
|
||||
|
||||
type ResourcePermissionsOptions = {
|
||||
appId?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export async function getApp(client: any, displayName: string): Promise<GraphObject | null> {
|
||||
const result = await client
|
||||
.api("/applications")
|
||||
.filter(`displayName eq '${displayName}'`)
|
||||
.get();
|
||||
.get() as GraphResult;
|
||||
|
||||
// Return the first application found or null if none exists
|
||||
return result.value.length > 0 ? result.value[0] : null;
|
||||
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
|
||||
}
|
||||
|
||||
export async function createApp(client, displayName) {
|
||||
export async function createApp(client: any, displayName: string): Promise<GraphObject> {
|
||||
const app = await client.api("/applications").post({
|
||||
displayName,
|
||||
});
|
||||
}) as GraphObject;
|
||||
|
||||
if (!app || !app.appId) {
|
||||
if (!app || typeof app.appId !== "string") {
|
||||
throw new Error("Failed to create application");
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function deleteApp(client, appObjectId) {
|
||||
export async function deleteApp(client: any, appObjectId: string): Promise<void> {
|
||||
await client.api(`/applications/${appObjectId}`).delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* List Azure applications, optionally filtered by display name and/or app ID.
|
||||
*
|
||||
* @param { Object } client
|
||||
* @param { Object } [options]
|
||||
* @param { string } [options.displayName]
|
||||
* @param { string } [options.appId]
|
||||
* @returns { Promise<Array> }
|
||||
*/
|
||||
export async function listApps(client, options = {}) {
|
||||
export async function listApps(client: any, options: AppQueryOptions = {}): Promise<GraphObject[]> {
|
||||
const { displayName, appId } = options;
|
||||
let request = client.api("/applications");
|
||||
const filters = [];
|
||||
const filters: string[] = [];
|
||||
|
||||
if (displayName) {
|
||||
filters.push(`displayName eq '${displayName}'`);
|
||||
@@ -58,18 +84,11 @@ export async function listApps(client, options = {}) {
|
||||
request = request.filter(filters.join(" and "));
|
||||
}
|
||||
|
||||
const result = await request.get();
|
||||
const result = await request.get() as GraphResult;
|
||||
return Array.isArray(result?.value) ? result.value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List required resource access configuration for an application by appId.
|
||||
*
|
||||
* @param { Object } client
|
||||
* @param { string } appId
|
||||
* @returns { Promise<Array> }
|
||||
*/
|
||||
export async function listAppPermissions(client, appId) {
|
||||
export async function listAppPermissions(client: any, appId: string): Promise<RequiredResourceAccess[]> {
|
||||
if (!appId) {
|
||||
throw new Error("appId is required");
|
||||
}
|
||||
@@ -78,7 +97,7 @@ export async function listAppPermissions(client, appId) {
|
||||
.api("/applications")
|
||||
.filter(`appId eq '${appId}'`)
|
||||
.select("id,appId,displayName,requiredResourceAccess")
|
||||
.get();
|
||||
.get() as GraphResult<GraphObject>;
|
||||
|
||||
const app = Array.isArray(result?.value) && result.value.length > 0
|
||||
? result.value[0]
|
||||
@@ -88,19 +107,13 @@ export async function listAppPermissions(client, appId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(app.requiredResourceAccess)
|
||||
? app.requiredResourceAccess
|
||||
const requiredResourceAccess = app.requiredResourceAccess;
|
||||
return Array.isArray(requiredResourceAccess)
|
||||
? requiredResourceAccess as RequiredResourceAccess[]
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List required resource access in a resolved, human-readable form.
|
||||
*
|
||||
* @param { Object } client
|
||||
* @param { string } appId
|
||||
* @returns { Promise<Array> }
|
||||
*/
|
||||
export async function listAppPermissionsResolved(client, appId) {
|
||||
export async function listAppPermissionsResolved(client: any, appId: string): Promise<Array<Record<string, unknown>>> {
|
||||
const requiredResourceAccess = await listAppPermissions(client, appId);
|
||||
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
|
||||
return [];
|
||||
@@ -109,7 +122,7 @@ export async function listAppPermissionsResolved(client, appId) {
|
||||
const resourceAppIds = [...new Set(
|
||||
requiredResourceAccess
|
||||
.map((entry) => entry?.resourceAppId)
|
||||
.filter(Boolean),
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0),
|
||||
)];
|
||||
|
||||
const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
|
||||
@@ -117,17 +130,21 @@ export async function listAppPermissionsResolved(client, appId) {
|
||||
.api("/servicePrincipals")
|
||||
.filter(`appId eq '${resourceAppId}'`)
|
||||
.select("appId,displayName,oauth2PermissionScopes,appRoles")
|
||||
.get();
|
||||
.get() as GraphResult<ServicePrincipal>;
|
||||
|
||||
const sp = Array.isArray(result?.value) && result.value.length > 0
|
||||
? result.value[0]
|
||||
: null;
|
||||
|
||||
const scopesById = new Map(
|
||||
(sp?.oauth2PermissionScopes ?? []).map((scope) => [scope.id, scope]),
|
||||
(sp?.oauth2PermissionScopes ?? [])
|
||||
.filter((scope) => typeof scope.id === "string")
|
||||
.map((scope) => [scope.id as string, scope]),
|
||||
);
|
||||
const rolesById = new Map(
|
||||
(sp?.appRoles ?? []).map((role) => [role.id, role]),
|
||||
(sp?.appRoles ?? [])
|
||||
.filter((role) => typeof role.id === "string")
|
||||
.map((role) => [role.id as string, role]),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -142,9 +159,10 @@ export async function listAppPermissionsResolved(client, appId) {
|
||||
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
|
||||
);
|
||||
|
||||
const rows = [];
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
for (const resourceEntry of requiredResourceAccess) {
|
||||
const resourceMeta = byResourceAppId.get(resourceEntry.resourceAppId);
|
||||
const resourceAppId = resourceEntry.resourceAppId ?? "";
|
||||
const resourceMeta = byResourceAppId.get(resourceAppId);
|
||||
const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
|
||||
? resourceEntry.resourceAccess
|
||||
: [];
|
||||
@@ -153,8 +171,8 @@ export async function listAppPermissionsResolved(client, appId) {
|
||||
const permissionType = item?.type ?? null;
|
||||
const permissionId = item?.id ?? null;
|
||||
const resolved = permissionType === "Scope"
|
||||
? resourceMeta?.scopesById.get(permissionId)
|
||||
: resourceMeta?.rolesById.get(permissionId);
|
||||
? resourceMeta?.scopesById.get(permissionId ?? "")
|
||||
: resourceMeta?.rolesById.get(permissionId ?? "");
|
||||
|
||||
rows.push({
|
||||
resourceAppId: resourceEntry.resourceAppId ?? null,
|
||||
@@ -174,14 +192,7 @@ export async function listAppPermissionsResolved(client, appId) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* List delegated OAuth2 permission grants for an application by appId.
|
||||
*
|
||||
* @param { Object } client
|
||||
* @param { string } appId
|
||||
* @returns { Promise<Array> }
|
||||
*/
|
||||
export async function listAppGrants(client, appId) {
|
||||
export async function listAppGrants(client: any, appId: string): Promise<GraphObject[]> {
|
||||
if (!appId) {
|
||||
throw new Error("appId is required");
|
||||
}
|
||||
@@ -190,7 +201,7 @@ export async function listAppGrants(client, appId) {
|
||||
.api("/servicePrincipals")
|
||||
.filter(`appId eq '${appId}'`)
|
||||
.select("id,appId,displayName")
|
||||
.get();
|
||||
.get() as GraphResult<ServicePrincipal>;
|
||||
|
||||
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
|
||||
? spResult.value[0]
|
||||
@@ -203,21 +214,12 @@ export async function listAppGrants(client, appId) {
|
||||
const grantsResult = await client
|
||||
.api("/oauth2PermissionGrants")
|
||||
.filter(`clientId eq '${servicePrincipal.id}'`)
|
||||
.get();
|
||||
.get() as GraphResult;
|
||||
|
||||
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List available delegated scopes and app roles for a resource app.
|
||||
*
|
||||
* @param { Object } client
|
||||
* @param { Object } options
|
||||
* @param { string } [options.appId]
|
||||
* @param { string } [options.displayName]
|
||||
* @returns { Promise<Array> }
|
||||
*/
|
||||
export async function listResourcePermissions(client, options = {}) {
|
||||
export async function listResourcePermissions(client: any, options: ResourcePermissionsOptions = {}): Promise<Array<Record<string, unknown>>> {
|
||||
const { appId, displayName } = options;
|
||||
if (!appId && !displayName) {
|
||||
throw new Error("appId or displayName is required");
|
||||
@@ -233,9 +235,9 @@ export async function listResourcePermissions(client, options = {}) {
|
||||
request = request.filter(`displayName eq '${displayName}'`);
|
||||
}
|
||||
|
||||
const result = await request.get();
|
||||
const result = await request.get() as GraphResult<ServicePrincipal>;
|
||||
const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
|
||||
const rows = [];
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const sp of servicePrincipals) {
|
||||
for (const scope of sp?.oauth2PermissionScopes ?? []) {
|
||||
@@ -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
30
src/graph/auth.ts
Normal 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 };
|
||||
}
|
||||
1
src/graph/index.d.ts
vendored
1
src/graph/index.d.ts
vendored
@@ -1 +0,0 @@
|
||||
//
|
||||
@@ -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
5
src/graph/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
export * from "./auth.ts";
|
||||
export * from "./app.ts";
|
||||
export * from "./sp.ts";
|
||||
@@ -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
30
src/graph/sp.ts
Normal 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
1
src/index.d.ts
vendored
@@ -1 +0,0 @@
|
||||
//
|
||||
@@ -4,7 +4,17 @@ import { readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export function getUserConfigDir() {
|
||||
type Config = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
};
|
||||
|
||||
type ConfigCandidate = {
|
||||
tenantId?: unknown;
|
||||
clientId?: unknown;
|
||||
};
|
||||
|
||||
export function getUserConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
|
||||
}
|
||||
@@ -12,37 +22,37 @@ export function getUserConfigDir() {
|
||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
||||
}
|
||||
|
||||
async function loadConfig(configFileName) {
|
||||
async function loadConfig(configFileName: string): Promise<Config> {
|
||||
if (typeof configFileName !== "string" || configFileName.trim() === "") {
|
||||
throw new Error(
|
||||
'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".',
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
const envConfig: Config = {
|
||||
tenantId: process.env.AZURE_TENANT_ID,
|
||||
clientId: process.env.AZURE_CLIENT_ID,
|
||||
};
|
||||
|
||||
const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName);
|
||||
return readFile(configPath, "utf8")
|
||||
.then((configJson) => JSON.parse(configJson))
|
||||
.catch((err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
return {};
|
||||
.then((configJson) => JSON.parse(configJson) as ConfigCandidate)
|
||||
.catch((err: unknown) => {
|
||||
if ((err as { code?: string } | null)?.code === "ENOENT") {
|
||||
return {} as ConfigCandidate;
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.then((json) => ({
|
||||
tenantId: json.tenantId || config.tenantId,
|
||||
clientId: json.clientId || config.clientId,
|
||||
tenantId: typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId,
|
||||
clientId: typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function loadPublicConfig() {
|
||||
export function loadPublicConfig(): Promise<Config> {
|
||||
return loadConfig("public-config.json");
|
||||
}
|
||||
|
||||
export function loadConfidentialConfig() {
|
||||
export function loadConfidentialConfig(): Promise<Config> {
|
||||
return loadConfig("confidential-config.json");
|
||||
}
|
||||
113
src/markdown.js
113
src/markdown.js
@@ -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
20
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user