Compare commits
31 Commits
main-js
...
059590fde4
| Author | SHA1 | Date | |
|---|---|---|---|
| 059590fde4 | |||
| 63029d1119 | |||
| aa6f9e24f8 | |||
| 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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
artifacts/
|
artifacts/
|
||||||
package-lock.json
|
|
||||||
10
.npmignore
10
.npmignore
@@ -1,3 +1,11 @@
|
|||||||
docs
|
src/
|
||||||
|
*.ts
|
||||||
|
tsconfig.json
|
||||||
|
scripts/
|
||||||
|
Dockerfile
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
artifacts/
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
docs
|
||||||
32
README.md
32
README.md
@@ -1,4 +1,36 @@
|
|||||||
# A set of Azure related NodeJS modules
|
# 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.
|
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
|
|
||||||
```
|
|
||||||
1633
package-lock.json
generated
Normal file
1633
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -1,7 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@slawek/sk-az-tools",
|
"name": "@slawek/sk-az-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.4.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "rm -rf dist && tsc && chmod +x dist/cli.js",
|
||||||
|
"create-pca": "node dist/create-pca.js",
|
||||||
|
"bump-patch": "node scripts/bump-patch.mjs",
|
||||||
|
"make-deps": "node scripts/make-mermaid-func-deps.mjs",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
@@ -11,22 +23,30 @@
|
|||||||
"@azure/msal-node": "^5.0.3",
|
"@azure/msal-node": "^5.0.3",
|
||||||
"@azure/msal-node-extensions": "^1.2.0",
|
"@azure/msal-node-extensions": "^1.2.0",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
|
"@slawek/sk-tools": ">=0.1.0",
|
||||||
"azure-devops-node-api": "^15.1.2",
|
"azure-devops-node-api": "^15.1.2",
|
||||||
"jmespath": "^0.16.0",
|
"minimatch": "^10.1.2",
|
||||||
"minimatch": "^10.1.2"
|
"open": "^10.1.0",
|
||||||
|
"semver": "^7.7.2",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": ">=24.0.0",
|
||||||
|
"ts-morph": ">=27.0.0",
|
||||||
|
"typescript": ">=5.8.2"
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Sławomir Koszewski",
|
"name": "Sławomir Koszewski",
|
||||||
"email": "slawek@koszewscy.waw.pl"
|
"email": "slawek@koszewscy.waw.pl"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"sk-az-tools": "./src/cli.js"
|
"sk-az-tools": "./dist/cli.js"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.js",
|
".": "./dist/index.js",
|
||||||
"./azure": "./src/azure/index.js",
|
"./azure": "./dist/azure/index.js",
|
||||||
"./graph": "./src/graph/index.js",
|
"./graph": "./dist/graph/index.js",
|
||||||
"./devops": "./src/devops/index.js"
|
"./devops": "./dist/devops/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
139
scripts/create-pca.sh
Executable file
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);
|
||||||
|
}
|
||||||
@@ -1,34 +1,43 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
|
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
|
||||||
|
import type { AuthenticationResult } from "@azure/msal-node";
|
||||||
|
import { acquireResourceToken as acquireResourceTokenPca } from "./pca-auth.ts";
|
||||||
|
|
||||||
export async function getCredential(credentialType, options) {
|
type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode";
|
||||||
|
|
||||||
|
export async function getCredential(
|
||||||
|
credentialType: CredentialType,
|
||||||
|
tenantId?: string,
|
||||||
|
clientId?: string,
|
||||||
|
clientSecret?: string,
|
||||||
|
): Promise<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> {
|
||||||
switch (credentialType) {
|
switch (credentialType) {
|
||||||
case "d":
|
case "d":
|
||||||
case "default":
|
case "default":
|
||||||
return new DefaultAzureCredential();
|
return new DefaultAzureCredential();
|
||||||
case "cs":
|
case "cs":
|
||||||
case "clientSecret":
|
case "clientSecret":
|
||||||
if (!options.tenantId || !options.clientId || !options.clientSecret) {
|
if (!tenantId || !clientId || !clientSecret) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
|
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new ClientSecretCredential(
|
return new ClientSecretCredential(
|
||||||
options.tenantId,
|
tenantId,
|
||||||
options.clientId,
|
clientId,
|
||||||
options.clientSecret,
|
clientSecret,
|
||||||
);
|
);
|
||||||
case "dc":
|
case "dc":
|
||||||
case "deviceCode":
|
case "deviceCode":
|
||||||
if (!options.tenantId || !options.clientId) {
|
if (!tenantId || !clientId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"tenantId and clientId are required for DeviceCodeCredential",
|
"tenantId and clientId are required for DeviceCodeCredential",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new DeviceCodeCredential({
|
return new DeviceCodeCredential({
|
||||||
tenantId: options.tenantId,
|
tenantId,
|
||||||
clientId: options.clientId,
|
clientId,
|
||||||
userPromptCallback: (info) => {
|
userPromptCallback: (info) => {
|
||||||
console.log(info.message);
|
console.log(info.message);
|
||||||
},
|
},
|
||||||
@@ -37,3 +46,11 @@ export async function getCredential(credentialType, options) {
|
|||||||
throw new Error(`Unsupported credential type: ${credentialType}`);
|
throw new Error(`Unsupported credential type: ${credentialType}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function acquireResourceToken(
|
||||||
|
tenantId: string,
|
||||||
|
clientId: string,
|
||||||
|
resource: string,
|
||||||
|
): Promise<AuthenticationResult | null> {
|
||||||
|
return acquireResourceTokenPca(tenantId, clientId, resource);
|
||||||
|
}
|
||||||
1
src/azure/index.d.ts
vendored
1
src/azure/index.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
//
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @module azure
|
|
||||||
*
|
|
||||||
* This module provides authentication functionalities for Azure services.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { getCredential } from "./client-auth.js";
|
|
||||||
export {
|
|
||||||
loginInteractive,
|
|
||||||
loginDeviceCode,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
parseResources,
|
|
||||||
acquireResourceTokenFromLogin,
|
|
||||||
} from "./pca-auth.js";
|
|
||||||
25
src/azure/index.ts
Normal file
25
src/azure/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @module azure
|
||||||
|
*
|
||||||
|
* This module provides authentication functionalities for Azure services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { getCredential } from "./client-auth.ts";
|
||||||
|
import { acquireResourceToken as acquireResourceTokenPca } from "./pca-auth.ts";
|
||||||
|
export {
|
||||||
|
loginInteractive,
|
||||||
|
loginDeviceCode,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
parseResources,
|
||||||
|
} from "./pca-auth.ts";
|
||||||
|
|
||||||
|
export async function acquireResourceToken(
|
||||||
|
tenantId: string,
|
||||||
|
clientId: string,
|
||||||
|
resource: string,
|
||||||
|
) {
|
||||||
|
return acquireResourceTokenPca(tenantId, clientId, resource);
|
||||||
|
}
|
||||||
@@ -2,83 +2,71 @@
|
|||||||
|
|
||||||
import open, { apps } from "open";
|
import open, { apps } from "open";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
import { writeFile, mkdir, unlink } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { PublicClientApplication } from "@azure/msal-node";
|
import { PublicClientApplication } from "@azure/msal-node";
|
||||||
import os from "node:os";
|
import { getConfig, getConfigDir } from "@slawek/sk-tools";
|
||||||
|
import type {
|
||||||
|
AccountInfo,
|
||||||
|
AuthenticationResult,
|
||||||
|
ICachePlugin,
|
||||||
|
TokenCacheContext,
|
||||||
|
} from "@azure/msal-node";
|
||||||
|
|
||||||
const RESOURCE_SCOPE_BY_NAME = {
|
const RESOURCE_SCOPE_BY_NAME = {
|
||||||
graph: "https://graph.microsoft.com/.default",
|
graph: "https://graph.microsoft.com/.default",
|
||||||
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
|
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
|
||||||
arm: "https://management.azure.com/.default",
|
arm: "https://management.azure.com/.default",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
|
type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
|
||||||
|
|
||||||
|
const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"];
|
||||||
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
|
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
|
||||||
const BROWSER_KEYWORDS = Object.keys(apps).sort();
|
const BROWSER_KEYWORDS = Object.keys(apps).sort();
|
||||||
|
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
|
||||||
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
||||||
|
const CONFIG_FILE_NAME = "config";
|
||||||
|
|
||||||
function getCacheRoot() {
|
type SessionState = {
|
||||||
const isWindows = process.platform === "win32";
|
activeAccountUpn: string | null;
|
||||||
const userRoot = isWindows
|
};
|
||||||
? process.env.LOCALAPPDATA || os.homedir()
|
|
||||||
: os.homedir();
|
|
||||||
|
|
||||||
return isWindows
|
async function readSessionState(): Promise<SessionState> {
|
||||||
? path.join(userRoot, "sk-az-tools")
|
const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown };
|
||||||
: path.join(userRoot, ".config", "sk-az-tools");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionFilePath() {
|
|
||||||
return path.join(getCacheRoot(), "login-session.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readSessionState() {
|
|
||||||
try {
|
|
||||||
const sessionJson = await readFile(getSessionFilePath(), "utf8");
|
|
||||||
const parsed = JSON.parse(sessionJson);
|
|
||||||
return {
|
return {
|
||||||
activeAccountUpn:
|
activeAccountUpn:
|
||||||
typeof parsed?.activeAccountUpn === "string"
|
typeof parsed?.activeAccountUpn === "string"
|
||||||
? parsed.activeAccountUpn
|
? parsed.activeAccountUpn
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
|
||||||
if (err?.code === "ENOENT") {
|
|
||||||
return { activeAccountUpn: null };
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeSessionState(state) {
|
async function writeSessionState(state: SessionState): Promise<void> {
|
||||||
const sessionPath = getSessionFilePath();
|
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
|
||||||
await mkdir(path.dirname(sessionPath), { recursive: true });
|
await mkdir(path.dirname(sessionPath), { recursive: true });
|
||||||
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
|
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearSessionState() {
|
async function clearSessionState(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await unlink(getSessionFilePath());
|
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
|
||||||
|
await unlink(sessionPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code !== "ENOENT") {
|
if ((err as { code?: string } | null)?.code !== "ENOENT") {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUpn(upn) {
|
function writeStderr(message: string): void {
|
||||||
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStderr(message) {
|
|
||||||
process.stderr.write(`${message}\n`);
|
process.stderr.write(`${message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrowserAppName(browser) {
|
function getBrowserAppName(browser?: string): string | readonly string[] | undefined {
|
||||||
if (!browser || browser.trim() === "") {
|
if (!browser || browser.trim() === "") {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyword = BROWSER_KEYWORDS.find(
|
const keyword = BROWSER_KEYWORDS.find(
|
||||||
@@ -90,10 +78,10 @@ function getBrowserAppName(browser) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apps[keyword];
|
return OPEN_APPS[keyword];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrowserKeyword(browser) {
|
function getBrowserKeyword(browser?: string): string {
|
||||||
if (!browser || browser.trim() === "") {
|
if (!browser || browser.trim() === "") {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -109,14 +97,13 @@ function getBrowserKeyword(browser) {
|
|||||||
return keyword.toLowerCase();
|
return keyword.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrowserOpenOptions({ browser, browserProfile }) {
|
function getBrowserOpenOptions(browser?: string, browserProfile?: string): Parameters<typeof open>[1] {
|
||||||
const browserName = getBrowserAppName(browser);
|
const browserName = getBrowserAppName(browser);
|
||||||
const options = browserName
|
|
||||||
? { wait: false, app: { name: browserName } }
|
|
||||||
: { wait: false };
|
|
||||||
|
|
||||||
if (!browserProfile || browserProfile.trim() === "") {
|
if (!browserProfile || browserProfile.trim() === "") {
|
||||||
return options;
|
return browserName
|
||||||
|
? { wait: false, app: { name: browserName } }
|
||||||
|
: { wait: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserKeyword = getBrowserKeyword(browser);
|
const browserKeyword = getBrowserKeyword(browser);
|
||||||
@@ -126,11 +113,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`];
|
if (!browserName) {
|
||||||
return options;
|
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?: string, browserProfile?: string): void {
|
||||||
if (browser && browser.trim() !== "") {
|
if (browser && browser.trim() !== "") {
|
||||||
getBrowserAppName(browser);
|
getBrowserAppName(browser);
|
||||||
}
|
}
|
||||||
@@ -145,7 +141,7 @@ function validateBrowserOptions({ browser, browserProfile }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseResources(resourcesCsv) {
|
export function parseResources(resourcesCsv?: string): ResourceName[] {
|
||||||
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
||||||
return [...DEFAULT_RESOURCES];
|
return [...DEFAULT_RESOURCES];
|
||||||
}
|
}
|
||||||
@@ -156,24 +152,24 @@ export function parseResources(resourcesCsv) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const unique = [...new Set(resources)];
|
const unique = [...new Set(resources)];
|
||||||
const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]);
|
const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name));
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
|
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return unique;
|
return unique as ResourceName[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileCachePlugin(cachePath) {
|
function fileCachePlugin(cachePath: string): ICachePlugin {
|
||||||
return {
|
return {
|
||||||
beforeCacheAccess: async (ctx) => {
|
beforeCacheAccess: async (ctx: TokenCacheContext) => {
|
||||||
if (fs.existsSync(cachePath)) {
|
if (fs.existsSync(cachePath)) {
|
||||||
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
|
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
afterCacheAccess: async (ctx) => {
|
afterCacheAccess: async (ctx: TokenCacheContext) => {
|
||||||
if (!ctx.cacheHasChanged) return;
|
if (!ctx.cacheHasChanged) return;
|
||||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||||
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
||||||
@@ -182,10 +178,10 @@ function fileCachePlugin(cachePath) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPca({ tenantId, clientId }) {
|
async function createPca(tenantId: string, clientId: string): Promise<PublicClientApplication> {
|
||||||
const cacheRoot = getCacheRoot();
|
const cacheRoot = getConfigDir("sk-az-tools");
|
||||||
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
|
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
|
||||||
let cachePlugin;
|
let cachePlugin: ICachePlugin;
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
DataProtectionScope,
|
DataProtectionScope,
|
||||||
@@ -201,7 +197,7 @@ async function createPca({ tenantId, clientId }) {
|
|||||||
usePlaintextFileOnLinux: true,
|
usePlaintextFileOnLinux: true,
|
||||||
});
|
});
|
||||||
cachePlugin = new PersistenceCachePlugin(persistence);
|
cachePlugin = new PersistenceCachePlugin(persistence);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Fallback when msal-node-extensions/keytar/libsecret are unavailable.
|
// Fallback when msal-node-extensions/keytar/libsecret are unavailable.
|
||||||
cachePlugin = fileCachePlugin(cachePath);
|
cachePlugin = fileCachePlugin(cachePath);
|
||||||
}
|
}
|
||||||
@@ -217,7 +213,11 @@ async function createPca({ tenantId, clientId }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acquireTokenWithCache({ pca, scopes, account }) {
|
async function acquireTokenWithCache(
|
||||||
|
pca: PublicClientApplication,
|
||||||
|
scopes: string[],
|
||||||
|
account?: AccountInfo | null,
|
||||||
|
): Promise<AuthenticationResult | null> {
|
||||||
if (account) {
|
if (account) {
|
||||||
try {
|
try {
|
||||||
return await pca.acquireTokenSilent({
|
return await pca.acquireTokenSilent({
|
||||||
@@ -244,65 +244,74 @@ async function acquireTokenWithCache({ pca, scopes, account }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findAccountByUpn({ pca, upn }) {
|
async function findAccountByUpn(
|
||||||
const normalized = normalizeUpn(upn);
|
pca: PublicClientApplication,
|
||||||
|
upn: string,
|
||||||
|
): Promise<AccountInfo | null> {
|
||||||
|
const normalized = upn.trim().toLowerCase();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await pca.getTokenCache().getAllAccounts();
|
const accounts = await pca.getTokenCache().getAllAccounts();
|
||||||
return (
|
return (
|
||||||
accounts.find((account) => normalizeUpn(account?.username) === normalized) ??
|
accounts.find((account) => account.username.trim().toLowerCase() === normalized) ??
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginInteractive({
|
export async function loginInteractive(
|
||||||
tenantId,
|
tenantId: string | undefined,
|
||||||
clientId,
|
clientId: string | undefined,
|
||||||
scopes,
|
scopes: string[],
|
||||||
showAuthUrlOnly = false,
|
showAuthUrlOnly = false,
|
||||||
browser,
|
browser?: string,
|
||||||
browserProfile,
|
browserProfile?: string,
|
||||||
}) {
|
): Promise<AuthenticationResult | null> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||||
throw new Error("scopes[] is required");
|
throw new Error("scopes[] is required");
|
||||||
validateBrowserOptions({ browser, browserProfile });
|
}
|
||||||
|
validateBrowserOptions(browser, browserProfile);
|
||||||
|
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca(tenantId, clientId);
|
||||||
|
|
||||||
const cached = await acquireTokenWithCache({ pca, scopes });
|
const cached = await acquireTokenWithCache(pca, scopes);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
return await pca.acquireTokenInteractive({
|
return pca.acquireTokenInteractive({
|
||||||
scopes,
|
scopes,
|
||||||
openBrowser: async (url) => {
|
openBrowser: async (url: string) => {
|
||||||
if (showAuthUrlOnly) {
|
if (showAuthUrlOnly) {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
const options = getBrowserOpenOptions(browser, browserProfile);
|
||||||
return open(url, options).catch(() => {
|
await open(url, options).catch(() => {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginDeviceCode({ tenantId, clientId, scopes }) {
|
export async function loginDeviceCode(
|
||||||
|
tenantId: string | undefined,
|
||||||
|
clientId: string | undefined,
|
||||||
|
scopes: string[],
|
||||||
|
): Promise<AuthenticationResult | null> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||||
throw new Error("scopes[] is required");
|
throw new Error("scopes[] is required");
|
||||||
|
}
|
||||||
|
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca(tenantId, clientId);
|
||||||
|
|
||||||
const cached = await acquireTokenWithCache({ pca, scopes });
|
const cached = await acquireTokenWithCache(pca, scopes);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
return await pca.acquireTokenByDeviceCode({
|
return pca.acquireTokenByDeviceCode({
|
||||||
scopes,
|
scopes,
|
||||||
deviceCodeCallback: (response) => {
|
deviceCodeCallback: (response) => {
|
||||||
writeStderr(response.message);
|
writeStderr(response.message);
|
||||||
@@ -310,38 +319,38 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login({
|
export async function login(
|
||||||
tenantId,
|
tenantId: string | undefined,
|
||||||
clientId,
|
clientId: string | undefined,
|
||||||
resourcesCsv,
|
resourcesCsv?: string,
|
||||||
useDeviceCode = false,
|
useDeviceCode = false,
|
||||||
noBrowser = false,
|
noBrowser = false,
|
||||||
browser,
|
browser?: string,
|
||||||
browserProfile,
|
browserProfile?: string,
|
||||||
}) {
|
): Promise<{
|
||||||
|
accountUpn: string | null;
|
||||||
|
resources: Array<{ resource: string; expiresOn: string | null }>;
|
||||||
|
flow: "device-code" | "interactive";
|
||||||
|
browserLaunchAttempted: boolean;
|
||||||
|
}> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
validateBrowserOptions({ browser, browserProfile });
|
validateBrowserOptions(browser, browserProfile);
|
||||||
|
|
||||||
const resources = parseResources(resourcesCsv);
|
const resources = parseResources(resourcesCsv);
|
||||||
const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]);
|
const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]);
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca(tenantId, clientId);
|
||||||
const session = await readSessionState();
|
const session = await readSessionState();
|
||||||
const preferredAccount = await findAccountByUpn({
|
const preferredAccount = session.activeAccountUpn
|
||||||
pca,
|
? await findAccountByUpn(pca, session.activeAccountUpn)
|
||||||
upn: session.activeAccountUpn,
|
: null;
|
||||||
});
|
|
||||||
|
|
||||||
const results = [];
|
const results: Array<{ resource: string; expiresOn: string | null }> = [];
|
||||||
let selectedAccount = preferredAccount;
|
let selectedAccount: AccountInfo | null = preferredAccount;
|
||||||
for (let index = 0; index < resources.length; index += 1) {
|
for (let index = 0; index < resources.length; index += 1) {
|
||||||
const resource = resources[index];
|
const resource = resources[index];
|
||||||
const scope = [scopes[index]];
|
const scope = [scopes[index]];
|
||||||
let token = await acquireTokenWithCache({
|
let token = await acquireTokenWithCache(pca, scope, selectedAccount);
|
||||||
pca,
|
|
||||||
scopes: scope,
|
|
||||||
account: selectedAccount,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (useDeviceCode) {
|
if (useDeviceCode) {
|
||||||
@@ -354,13 +363,13 @@ export async function login({
|
|||||||
} else {
|
} else {
|
||||||
token = await pca.acquireTokenInteractive({
|
token = await pca.acquireTokenInteractive({
|
||||||
scopes: scope,
|
scopes: scope,
|
||||||
openBrowser: async (url) => {
|
openBrowser: async (url: string) => {
|
||||||
if (noBrowser) {
|
if (noBrowser) {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
const options = getBrowserOpenOptions(browser, browserProfile);
|
||||||
return open(url, options).catch(() => {
|
await open(url, options).catch(() => {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -391,30 +400,28 @@ export async function login({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acquireResourceTokenFromLogin({
|
export async function acquireResourceToken(
|
||||||
tenantId,
|
tenantId: string,
|
||||||
clientId,
|
clientId: string,
|
||||||
resource,
|
resource: string,
|
||||||
}) {
|
): Promise<AuthenticationResult | null> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
if (!resource) throw new Error("resource is required");
|
if (!resource) throw new Error("resource is required");
|
||||||
|
|
||||||
const scope = RESOURCE_SCOPE_BY_NAME[resource];
|
if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) {
|
||||||
if (!scope) {
|
|
||||||
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
|
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName];
|
||||||
|
|
||||||
const session = await readSessionState();
|
const session = await readSessionState();
|
||||||
if (!session.activeAccountUpn) {
|
if (!session.activeAccountUpn) {
|
||||||
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca(tenantId, clientId);
|
||||||
const account = await findAccountByUpn({
|
const account = await findAccountByUpn(pca, session.activeAccountUpn);
|
||||||
pca,
|
|
||||||
upn: session.activeAccountUpn,
|
|
||||||
});
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
||||||
}
|
}
|
||||||
@@ -429,16 +436,16 @@ export async function acquireResourceTokenFromLogin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout({
|
export async function logout(
|
||||||
tenantId,
|
tenantId: string,
|
||||||
clientId,
|
clientId: string,
|
||||||
clearAll = false,
|
clearAll = false,
|
||||||
userPrincipalName,
|
userPrincipalName?: string,
|
||||||
}) {
|
): Promise<{ clearedAll: boolean; signedOut: string[] }> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
|
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca(tenantId, clientId);
|
||||||
const tokenCache = pca.getTokenCache();
|
const tokenCache = pca.getTokenCache();
|
||||||
const accounts = await tokenCache.getAllAccounts();
|
const accounts = await tokenCache.getAllAccounts();
|
||||||
const session = await readSessionState();
|
const session = await readSessionState();
|
||||||
@@ -450,13 +457,14 @@ export async function logout({
|
|||||||
await clearSessionState();
|
await clearSessionState();
|
||||||
return {
|
return {
|
||||||
clearedAll: true,
|
clearedAll: true,
|
||||||
signedOut: accounts.map((account) => account.username).filter(Boolean),
|
signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUpn = normalizeUpn(userPrincipalName) || normalizeUpn(session.activeAccountUpn);
|
const targetUpn = (typeof userPrincipalName === "string" ? userPrincipalName.trim().toLowerCase() : "")
|
||||||
|
|| (typeof session.activeAccountUpn === "string" ? session.activeAccountUpn.trim().toLowerCase() : "");
|
||||||
const accountToSignOut = accounts.find(
|
const accountToSignOut = accounts.find(
|
||||||
(account) => normalizeUpn(account.username) === targetUpn,
|
(account) => account.username.trim().toLowerCase() === targetUpn,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!accountToSignOut) {
|
if (!accountToSignOut) {
|
||||||
@@ -468,6 +476,6 @@ export async function logout({
|
|||||||
await clearSessionState();
|
await clearSessionState();
|
||||||
return {
|
return {
|
||||||
clearedAll: false,
|
clearedAll: false,
|
||||||
signedOut: [accountToSignOut.username].filter(Boolean),
|
signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/cli/commands/auth.ts
Normal file
1
src/cli/commands/auth.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
51
src/cli/commands/get-token.ts
Normal file
51
src/cli/commands/get-token.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { acquireResourceToken } from "../../azure/index.ts";
|
||||||
|
import { getDevOpsApiToken } from "../../devops/index.ts";
|
||||||
|
import { loadConfig } 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 loadConfig("public-config");
|
||||||
|
|
||||||
|
if (tokenType === "azurerm") {
|
||||||
|
const result = await acquireResourceToken(
|
||||||
|
config.tenantId,
|
||||||
|
config.clientId,
|
||||||
|
"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;
|
||||||
|
}
|
||||||
27
src/cli/commands/list-apps.ts
Normal file
27
src/cli/commands/list-apps.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// 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, values["display-name"], 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;
|
||||||
|
}
|
||||||
35
src/cli/commands/list-resource-permissions.ts
Normal file
35
src/cli/commands/list-resource-permissions.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// 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,
|
||||||
|
values["app-id"],
|
||||||
|
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 { loadConfig } 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 loadConfig("public-config");
|
||||||
|
return login(
|
||||||
|
config.tenantId,
|
||||||
|
config.clientId,
|
||||||
|
values.resources,
|
||||||
|
Boolean(values["use-device-code"]),
|
||||||
|
Boolean(values["no-browser"]),
|
||||||
|
values.browser,
|
||||||
|
values["browser-profile"],
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/cli/commands/logout.ts
Normal file
18
src/cli/commands/logout.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { logout } from "../../azure/index.ts";
|
||||||
|
import { loadConfig } 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 loadConfig("public-config");
|
||||||
|
return logout(config.tenantId, config.clientId, Boolean(values.all));
|
||||||
|
}
|
||||||
123
src/cli/commands/rest.ts
Normal file
123
src/cli/commands/rest.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { acquireResourceToken } from "../../azure/index.ts";
|
||||||
|
import { getDevOpsApiToken } from "../../devops/index.ts";
|
||||||
|
import { loadConfig } 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 loadConfig("public-config");
|
||||||
|
|
||||||
|
if (host === "management.azure.com") {
|
||||||
|
const result = await acquireResourceToken(
|
||||||
|
config.tenantId,
|
||||||
|
config.clientId,
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
33
src/cli/commands/shared.ts
Normal file
33
src/cli/commands/shared.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { minimatch } from "minimatch";
|
||||||
|
|
||||||
|
import { loadConfig } 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 loadConfig("public-config");
|
||||||
|
return getGraphClient(config.tenantId, 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
42
scripts/create-pca.js → src/create-pca.ts
Executable file → Normal file
42
scripts/create-pca.js → src/create-pca.ts
Executable file → Normal file
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -9,10 +8,16 @@ import readline from "node:readline";
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
function runAz(args, options = {}) {
|
type RunAzResult = {
|
||||||
|
status: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function runAz(args: string[], quiet = false, allowFailure = false): RunAzResult {
|
||||||
const result = spawnSync("az", args, {
|
const result = spawnSync("az", args, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
stdio: options.quiet
|
stdio: quiet
|
||||||
? ["ignore", "ignore", "ignore"]
|
? ["ignore", "ignore", "ignore"]
|
||||||
: ["ignore", "pipe", "pipe"],
|
: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
@@ -21,7 +26,7 @@ function runAz(args, options = {}) {
|
|||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status !== 0 && options.allowFailure !== true) {
|
if (result.status !== 0 && allowFailure !== true) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
(result.stderr || "").trim() || `az ${args.join(" ")} failed`,
|
(result.stderr || "").trim() || `az ${args.join(" ")} failed`,
|
||||||
);
|
);
|
||||||
@@ -34,13 +39,14 @@ function runAz(args, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main(): Promise<void> {
|
||||||
const usageText = `Usage: ${path.basename(process.argv[1])} [options] <app-name>
|
const usageText = `Usage: ${path.basename(process.argv[1])} [options] <app-name>
|
||||||
Options:
|
Options:
|
||||||
-c, --config <path> Write JSON config to file (optional)
|
-c, --config <path> Write JSON config to file (optional)
|
||||||
-h, --help Show this help message and exit`;
|
-h, --help Show this help message and exit`;
|
||||||
let values;
|
|
||||||
let positionals;
|
let values: Record<string, string | boolean | undefined>;
|
||||||
|
let positionals: string[];
|
||||||
try {
|
try {
|
||||||
({ values, positionals } = parseArgs({
|
({ values, positionals } = parseArgs({
|
||||||
args: process.argv.slice(2),
|
args: process.argv.slice(2),
|
||||||
@@ -52,7 +58,7 @@ Options:
|
|||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error: ${err.message}`);
|
console.error(`Error: ${(err as Error).message}`);
|
||||||
console.error(usageText);
|
console.error(usageText);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -71,7 +77,7 @@ Options:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appName = positionals[0] || "";
|
const appName = positionals[0] || "";
|
||||||
const configPath = values.config || "";
|
const configPath = typeof values.config === "string" ? values.config : "";
|
||||||
|
|
||||||
if (!appName) {
|
if (!appName) {
|
||||||
console.error("Error: Application name is required.");
|
console.error("Error: Application name is required.");
|
||||||
@@ -96,12 +102,12 @@ Options:
|
|||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
output: process.stderr,
|
output: process.stderr,
|
||||||
});
|
});
|
||||||
const answer = await new Promise((resolve) => {
|
const answer = await new Promise<string>((resolve) => {
|
||||||
rl.question(
|
rl.question(
|
||||||
`Application '${appName}' already exists. Update it? [y/N]: `,
|
`Application '${appName}' already exists. Update it? [y/N]: `,
|
||||||
(answer) => {
|
(answerValue) => {
|
||||||
rl.close();
|
rl.close();
|
||||||
resolve(answer.trim());
|
resolve(answerValue.trim());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -187,7 +193,7 @@ Options:
|
|||||||
"--enable-id-token-issuance",
|
"--enable-id-token-issuance",
|
||||||
"true",
|
"true",
|
||||||
],
|
],
|
||||||
{ quiet: true },
|
true,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -199,14 +205,12 @@ Options:
|
|||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
runAz(["ad", "sp", "create", "--id", appId], {
|
runAz(["ad", "sp", "create", "--id", appId], true, true);
|
||||||
quiet: true,
|
|
||||||
allowFailure: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminConsentResult = runAz(
|
const adminConsentResult = runAz(
|
||||||
["ad", "app", "permission", "admin-consent", "--id", appId],
|
["ad", "app", "permission", "admin-consent", "--id", appId],
|
||||||
{ quiet: true, allowFailure: true },
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
if (adminConsentResult.status !== 0) {
|
if (adminConsentResult.status !== 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -251,6 +255,6 @@ Options:
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(`Error: ${err.message}`);
|
console.error(`Error: ${(err as Error).message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
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,
|
||||||
|
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,70 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
/**
|
type GraphObject = Record<string, unknown>;
|
||||||
* Get an Azure application by its display name.
|
|
||||||
*
|
type GraphResult<T = GraphObject> = {
|
||||||
* @param { Object } client
|
value?: T[];
|
||||||
* @param { string } displayName
|
};
|
||||||
* @returns { Promise<Object|null> }
|
|
||||||
*/
|
type RequiredResourceAccessItem = {
|
||||||
export async function getApp(client, displayName) {
|
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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getApp(client: any, displayName: string): Promise<GraphObject | null> {
|
||||||
const result = await client
|
const result = await client
|
||||||
.api("/applications")
|
.api("/applications")
|
||||||
.filter(`displayName eq '${displayName}'`)
|
.filter(`displayName eq '${displayName}'`)
|
||||||
.get();
|
.get() as GraphResult;
|
||||||
|
|
||||||
// Return the first application found or null if none exists
|
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
|
||||||
return result.value.length > 0 ? result.value[0] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApp(client, displayName) {
|
export async function createApp(client: any, displayName: string): Promise<GraphObject> {
|
||||||
const app = await client.api("/applications").post({
|
const app = await client.api("/applications").post({
|
||||||
displayName,
|
displayName,
|
||||||
});
|
}) as GraphObject;
|
||||||
|
|
||||||
if (!app || !app.appId) {
|
if (!app || typeof app.appId !== "string") {
|
||||||
throw new Error("Failed to create application");
|
throw new Error("Failed to create application");
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApp(client, appObjectId) {
|
export async function deleteApp(client: any, appObjectId: string): Promise<void> {
|
||||||
await client.api(`/applications/${appObjectId}`).delete();
|
await client.api(`/applications/${appObjectId}`).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listApps(
|
||||||
* List Azure applications, optionally filtered by display name and/or app ID.
|
client: any,
|
||||||
*
|
displayName?: string,
|
||||||
* @param { Object } client
|
appId?: string,
|
||||||
* @param { Object } [options]
|
): Promise<GraphObject[]> {
|
||||||
* @param { string } [options.displayName]
|
|
||||||
* @param { string } [options.appId]
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listApps(client, options = {}) {
|
|
||||||
const { displayName, appId } = options;
|
|
||||||
let request = client.api("/applications");
|
let request = client.api("/applications");
|
||||||
const filters = [];
|
const filters: string[] = [];
|
||||||
|
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
filters.push(`displayName eq '${displayName}'`);
|
filters.push(`displayName eq '${displayName}'`);
|
||||||
@@ -58,18 +77,11 @@ export async function listApps(client, options = {}) {
|
|||||||
request = request.filter(filters.join(" and "));
|
request = request.filter(filters.join(" and "));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await request.get();
|
const result = await request.get() as GraphResult;
|
||||||
return Array.isArray(result?.value) ? result.value : [];
|
return Array.isArray(result?.value) ? result.value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listAppPermissions(client: any, appId: string): Promise<RequiredResourceAccess[]> {
|
||||||
* List required resource access configuration for an application by appId.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { string } appId
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listAppPermissions(client, appId) {
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
throw new Error("appId is required");
|
throw new Error("appId is required");
|
||||||
}
|
}
|
||||||
@@ -78,7 +90,7 @@ export async function listAppPermissions(client, appId) {
|
|||||||
.api("/applications")
|
.api("/applications")
|
||||||
.filter(`appId eq '${appId}'`)
|
.filter(`appId eq '${appId}'`)
|
||||||
.select("id,appId,displayName,requiredResourceAccess")
|
.select("id,appId,displayName,requiredResourceAccess")
|
||||||
.get();
|
.get() as GraphResult<GraphObject>;
|
||||||
|
|
||||||
const app = Array.isArray(result?.value) && result.value.length > 0
|
const app = Array.isArray(result?.value) && result.value.length > 0
|
||||||
? result.value[0]
|
? result.value[0]
|
||||||
@@ -88,19 +100,13 @@ export async function listAppPermissions(client, appId) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(app.requiredResourceAccess)
|
const requiredResourceAccess = app.requiredResourceAccess;
|
||||||
? app.requiredResourceAccess
|
return Array.isArray(requiredResourceAccess)
|
||||||
|
? requiredResourceAccess as RequiredResourceAccess[]
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listAppPermissionsResolved(client: any, appId: string): Promise<Array<Record<string, unknown>>> {
|
||||||
* List required resource access in a resolved, human-readable form.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { string } appId
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listAppPermissionsResolved(client, appId) {
|
|
||||||
const requiredResourceAccess = await listAppPermissions(client, appId);
|
const requiredResourceAccess = await listAppPermissions(client, appId);
|
||||||
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
|
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -109,7 +115,7 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
const resourceAppIds = [...new Set(
|
const resourceAppIds = [...new Set(
|
||||||
requiredResourceAccess
|
requiredResourceAccess
|
||||||
.map((entry) => entry?.resourceAppId)
|
.map((entry) => entry?.resourceAppId)
|
||||||
.filter(Boolean),
|
.filter((value): value is string => typeof value === "string" && value.length > 0),
|
||||||
)];
|
)];
|
||||||
|
|
||||||
const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
|
const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
|
||||||
@@ -117,17 +123,21 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
.api("/servicePrincipals")
|
.api("/servicePrincipals")
|
||||||
.filter(`appId eq '${resourceAppId}'`)
|
.filter(`appId eq '${resourceAppId}'`)
|
||||||
.select("appId,displayName,oauth2PermissionScopes,appRoles")
|
.select("appId,displayName,oauth2PermissionScopes,appRoles")
|
||||||
.get();
|
.get() as GraphResult<ServicePrincipal>;
|
||||||
|
|
||||||
const sp = Array.isArray(result?.value) && result.value.length > 0
|
const sp = Array.isArray(result?.value) && result.value.length > 0
|
||||||
? result.value[0]
|
? result.value[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const scopesById = new Map(
|
const scopesById = new Map(
|
||||||
(sp?.oauth2PermissionScopes ?? []).map((scope) => [scope.id, scope]),
|
(sp?.oauth2PermissionScopes ?? [])
|
||||||
|
.filter((scope) => typeof scope.id === "string")
|
||||||
|
.map((scope) => [scope.id as string, scope]),
|
||||||
);
|
);
|
||||||
const rolesById = new Map(
|
const rolesById = new Map(
|
||||||
(sp?.appRoles ?? []).map((role) => [role.id, role]),
|
(sp?.appRoles ?? [])
|
||||||
|
.filter((role) => typeof role.id === "string")
|
||||||
|
.map((role) => [role.id as string, role]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -142,9 +152,10 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
|
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows = [];
|
const rows: Array<Record<string, unknown>> = [];
|
||||||
for (const resourceEntry of requiredResourceAccess) {
|
for (const resourceEntry of requiredResourceAccess) {
|
||||||
const resourceMeta = byResourceAppId.get(resourceEntry.resourceAppId);
|
const resourceAppId = resourceEntry.resourceAppId ?? "";
|
||||||
|
const resourceMeta = byResourceAppId.get(resourceAppId);
|
||||||
const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
|
const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
|
||||||
? resourceEntry.resourceAccess
|
? resourceEntry.resourceAccess
|
||||||
: [];
|
: [];
|
||||||
@@ -153,8 +164,8 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
const permissionType = item?.type ?? null;
|
const permissionType = item?.type ?? null;
|
||||||
const permissionId = item?.id ?? null;
|
const permissionId = item?.id ?? null;
|
||||||
const resolved = permissionType === "Scope"
|
const resolved = permissionType === "Scope"
|
||||||
? resourceMeta?.scopesById.get(permissionId)
|
? resourceMeta?.scopesById.get(permissionId ?? "")
|
||||||
: resourceMeta?.rolesById.get(permissionId);
|
: resourceMeta?.rolesById.get(permissionId ?? "");
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
resourceAppId: resourceEntry.resourceAppId ?? null,
|
resourceAppId: resourceEntry.resourceAppId ?? null,
|
||||||
@@ -174,14 +185,7 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listAppGrants(client: any, appId: string): Promise<GraphObject[]> {
|
||||||
* List delegated OAuth2 permission grants for an application by appId.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { string } appId
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listAppGrants(client, appId) {
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
throw new Error("appId is required");
|
throw new Error("appId is required");
|
||||||
}
|
}
|
||||||
@@ -190,7 +194,7 @@ export async function listAppGrants(client, appId) {
|
|||||||
.api("/servicePrincipals")
|
.api("/servicePrincipals")
|
||||||
.filter(`appId eq '${appId}'`)
|
.filter(`appId eq '${appId}'`)
|
||||||
.select("id,appId,displayName")
|
.select("id,appId,displayName")
|
||||||
.get();
|
.get() as GraphResult<ServicePrincipal>;
|
||||||
|
|
||||||
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
|
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
|
||||||
? spResult.value[0]
|
? spResult.value[0]
|
||||||
@@ -203,22 +207,16 @@ export async function listAppGrants(client, appId) {
|
|||||||
const grantsResult = await client
|
const grantsResult = await client
|
||||||
.api("/oauth2PermissionGrants")
|
.api("/oauth2PermissionGrants")
|
||||||
.filter(`clientId eq '${servicePrincipal.id}'`)
|
.filter(`clientId eq '${servicePrincipal.id}'`)
|
||||||
.get();
|
.get() as GraphResult;
|
||||||
|
|
||||||
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
|
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listResourcePermissions(
|
||||||
* List available delegated scopes and app roles for a resource app.
|
client: any,
|
||||||
*
|
appId?: string,
|
||||||
* @param { Object } client
|
displayName?: string,
|
||||||
* @param { Object } options
|
): Promise<Array<Record<string, unknown>>> {
|
||||||
* @param { string } [options.appId]
|
|
||||||
* @param { string } [options.displayName]
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listResourcePermissions(client, options = {}) {
|
|
||||||
const { appId, displayName } = options;
|
|
||||||
if (!appId && !displayName) {
|
if (!appId && !displayName) {
|
||||||
throw new Error("appId or displayName is required");
|
throw new Error("appId or displayName is required");
|
||||||
}
|
}
|
||||||
@@ -233,9 +231,9 @@ export async function listResourcePermissions(client, options = {}) {
|
|||||||
request = request.filter(`displayName eq '${displayName}'`);
|
request = request.filter(`displayName eq '${displayName}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await request.get();
|
const result = await request.get() as GraphResult<ServicePrincipal>;
|
||||||
const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
|
const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
|
||||||
const rows = [];
|
const rows: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (const sp of servicePrincipals) {
|
for (const sp of servicePrincipals) {
|
||||||
for (const scope of sp?.oauth2PermissionScopes ?? []) {
|
for (const scope of sp?.oauth2PermissionScopes ?? []) {
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
24
src/graph/auth.ts
Normal file
24
src/graph/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { Client } from "@microsoft/microsoft-graph-client";
|
||||||
|
import { acquireResourceToken } from "../azure/index.ts";
|
||||||
|
|
||||||
|
type GraphApiToken = {
|
||||||
|
accessToken: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getGraphClient(
|
||||||
|
tenantId: string,
|
||||||
|
clientId: string,
|
||||||
|
): Promise<{ graphApiToken: GraphApiToken; client: any }> {
|
||||||
|
const graphApiToken = await acquireResourceToken(tenantId, clientId, "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 @@
|
|||||||
//
|
|
||||||
48
src/index.js
48
src/index.js
@@ -1,48 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export function getUserConfigDir() {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
|
|
||||||
}
|
|
||||||
|
|
||||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConfig(configFileName) {
|
|
||||||
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 = {
|
|
||||||
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 {};
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
})
|
|
||||||
.then((json) => ({
|
|
||||||
tenantId: json.tenantId || config.tenantId,
|
|
||||||
clientId: json.clientId || config.clientId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadPublicConfig() {
|
|
||||||
return loadConfig("public-config.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadConfidentialConfig() {
|
|
||||||
return loadConfig("confidential-config.json");
|
|
||||||
}
|
|
||||||
36
src/index.ts
Normal file
36
src/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { validate as validateUuid } from "uuid";
|
||||||
|
import { getConfig } from "@slawek/sk-tools";
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
tenantId: string;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadConfig(configName: string): Promise<Config> {
|
||||||
|
if (typeof configName !== "string" || configName.trim() === "") {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid config name. Expected a non-empty string like "public-config" or "confidential-config".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envConfig = {
|
||||||
|
tenantId: process.env.AZURE_TENANT_ID,
|
||||||
|
clientId: process.env.AZURE_CLIENT_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = (await getConfig("sk-az-tools", configName)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const tenantId = (typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId) ?? "";
|
||||||
|
const clientId = (typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId) ?? "";
|
||||||
|
|
||||||
|
if (!validateUuid(tenantId ?? "") || !validateUuid(clientId ?? "")) {
|
||||||
|
throw new Error("tenantId and clientId must be valid GUIDs.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
clientId,
|
||||||
|
};
|
||||||
|
}
|
||||||
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