Compare commits

..

31 Commits

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -1,4 +1,36 @@
# A set of Azure related NodeJS modules
[![test](https://gitea.koszewscy.waw.pl/slawek/sk-az-tools/actions/workflows/build.yml/badge.svg)](https://gitea.koszewscy.waw.pl/slawek/sk-az-tools/actions?workflow=build.yml)
This repository contains a collection of NodeJS modules that facilitate interaction with Azure and Graph authentication and management of selected Entra ID objects and Azure resources.
## Installation
```bash
npm install @slawek/sk-az-tools
```
## Development
### Build from TypeScript
```bash
npm run build
```
### Watch mode
```bash
npm run build:watch
```
### CLI smoke check
```bash
node dist/cli.js --help
```
## Publishing
The package is published from compiled output in `dist/`. See `docs/PACKAGING.md` for the complete release workflow.

View File

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

121
docs/Commands.md Normal file
View File

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

View File

@@ -1,94 +0,0 @@
# Developing `hello-world` (ESM) Summary
## Minimal Layout
- `package.json`, `README.md`, `src/index.js` (ESM only).
- `package.json` uses `"type": "module"` and explicit `exports`.
- `files` allow-list to control shipped content.
Example `package.json`:
```json
{
"name": "hello-world",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.js"
},
"files": ["src", "README.md", "package.json"],
"engines": {
"node": ">=18"
}
}
```
Example `src/index.js`:
```js
export function helloWorld() {
console.log("Hello World!!!");
}
```
## Sub-modules (Subpath Exports)
- Expose sub-modules using explicit subpaths in `exports`.
- Keep public API small and intentional.
Example:
```json
{
"exports": {
".": "./src/index.js",
"./greetings": "./src/greetings.js",
"./callouts": "./src/callouts.js"
}
}
```
## `exports` vs `files`
- `exports` defines the public import surface (what consumers can import).
- `files` defines what gets packaged; supports globs and negation.
Example:
```json
{
"files": ["dist/**", "README.md", "!dist/**/*.map"]
}
```
## Dev Workflow (Separate Repo, Live Updates)
- Use `npm link` for live-edit development across repos.
Publisher repo:
```bash
npm link
```
Consumer repo:
```bash
npm link hello-world
```
Notes:
- If you build to `dist/`, run a watch build so the consumer sees updates.
- Unlink when done:
```bash
npm unlink hello-world
```
## Distribution as a `.tgz` Artifact
- Create a tarball with `npm pack` and distribute the `.tgz` file.
- Install directly from the tarball path.
- Use `npm pack --dry-run` to verify contents before sharing.
- The `.tgz` is written to the current working directory where `npm pack` is run.
- You can redirect output with `--pack-destination` (or `pack-destination` config).
- Ship `package.json` in the artifact, but exclude `package-lock.json` (keep it in the repo for development only).
Commands:
```bash
npm pack
npm install ./hello-world-1.0.0.tgz
```
Example with output directory:
```bash
npm pack --pack-destination ./artifacts
```

1633
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -uo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") [options] <app-name>
Options:
-c, --config <path> Write JSON config to file (optional)
-h, --help Show this help message and exit
EOF
}
CONFIG_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-c|--config)
if [[ $# -le 0 ]]; then
echo "Error: Missing value for --config" >&2
exit 1
fi
CONFIG_PATH="$2"
shift 2
;;
-*)
echo "Error: Unknown option: $1" >&2
exit 1
;;
*)
break
;;
esac
done
APP_NAME="${1:-}"
if [[ -z "$APP_NAME" ]]; then
echo "Error: Application name is required." >&2
usage >&2
exit 1
fi
APP_ID="$(az ad app list --display-name "$APP_NAME" | jq -r '[.[].appId] | join(",")')"
if [[ "$APP_ID" =~ "," ]]; then
echo "Error: The application name '$APP_NAME' is not unique." >&2
exit 1
fi
if [[ -z "$APP_ID" ]]; then
APP_ID="$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)"
if [[ -z "$APP_ID" ]]; then
echo "Error: Failed to create application '$APP_NAME'." >&2
exit 1
fi
echo "Created application '$APP_NAME' with appId '$APP_ID'."
else
printf "Application '%s' already exists. Update it? [y/N]: " "$APP_NAME" >&2
read -r ANSWER
if [[ ! "$ANSWER" =~ ^[Yy]([Ee][Ss])*$ ]]; then
echo "Canceled." >&2
exit 0
fi
fi
RESOURCE_ACCESS='[
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{ "id": "0e263e50-5827-48a4-b97c-d940288653c7", "type": "Scope" }
]
},
{
"resourceAppId": "499b84ac-1321-427f-aa17-267ca6975798",
"resourceAccess": [
{ "id": "ee69721e-6c3a-468f-a9ec-302d16a4c599", "type": "Scope" }
]
},
{
"resourceAppId": "797f4846-ba00-4fd7-ba43-dac1f8f63013",
"resourceAccess": [
{ "id": "41094075-9dad-400e-a0bd-54e686782033", "type": "Scope" }
]
}
]'
if ! az ad app update \
--id "$APP_ID" \
--sign-in-audience AzureADMyOrg \
--is-fallback-public-client true \
--required-resource-accesses "$RESOURCE_ACCESS" \
--public-client-redirect-uris http://localhost "msal${APP_ID}://auth" \
--enable-access-token-issuance true \
--enable-id-token-issuance true \
>/dev/null 2>&1; then
echo "Error: Failed to configure application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
SP_ID="$(az ad sp show --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
SP_ID="$(az ad sp create --id "$APP_ID" --query id -o tsv)"
if [[ -z "$SP_ID" ]]; then
echo "Error: Failed to create service principal for application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
else
echo "Service principal for application '$APP_NAME' already exists with id '$SP_ID'."
fi
az ad app permission admin-consent --id "$APP_ID"
if [[ $? -ne 0 ]]; then
echo "Error: Failed to grant admin consent for application '$APP_NAME' ($APP_ID)." >&2
exit 1
fi
TENANT_ID="$(az account show --query tenantId -o tsv)"
if [[ -z "$TENANT_ID" ]]; then
echo "Error: Failed to resolve tenantId from current Azure CLI context." >&2
exit 1
fi
CONFIG="{
\"tenantId\": \"$TENANT_ID\",
\"clientId\": \"$APP_ID\"
}
"
if [[ -n "$CONFIG_PATH" ]]; then
mkdir -p "$(dirname "$CONFIG_PATH")"
else
CONFIG_PATH="/dev/null"
fi
echo "$CONFIG" | tee "$CONFIG_PATH"

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

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

View File

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

View File

@@ -1,34 +1,43 @@
// SPDX-License-Identifier: MIT
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) {
case "d":
case "default":
return new DefaultAzureCredential();
case "cs":
case "clientSecret":
if (!options.tenantId || !options.clientId || !options.clientSecret) {
if (!tenantId || !clientId || !clientSecret) {
throw new Error(
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
);
}
return new ClientSecretCredential(
options.tenantId,
options.clientId,
options.clientSecret,
tenantId,
clientId,
clientSecret,
);
case "dc":
case "deviceCode":
if (!options.tenantId || !options.clientId) {
if (!tenantId || !clientId) {
throw new Error(
"tenantId and clientId are required for DeviceCodeCredential",
);
}
return new DeviceCodeCredential({
tenantId: options.tenantId,
clientId: options.clientId,
tenantId,
clientId,
userPromptCallback: (info) => {
console.log(info.message);
},
@@ -37,3 +46,11 @@ export async function getCredential(credentialType, options) {
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);
}

View File

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

View File

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

View File

@@ -2,83 +2,71 @@
import open, { apps } from "open";
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 { 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 = {
graph: "https://graph.microsoft.com/.default",
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
arm: "https://management.azure.com/.default",
};
} as const;
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"];
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
const BROWSER_KEYWORDS = Object.keys(apps).sort();
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
const CONFIG_FILE_NAME = "config";
function getCacheRoot() {
const isWindows = process.platform === "win32";
const userRoot = isWindows
? process.env.LOCALAPPDATA || os.homedir()
: os.homedir();
type SessionState = {
activeAccountUpn: string | null;
};
return isWindows
? path.join(userRoot, "sk-az-tools")
: path.join(userRoot, ".config", "sk-az-tools");
async function readSessionState(): Promise<SessionState> {
const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown };
return {
activeAccountUpn:
typeof parsed?.activeAccountUpn === "string"
? parsed.activeAccountUpn
: null,
};
}
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 {
activeAccountUpn:
typeof parsed?.activeAccountUpn === "string"
? parsed.activeAccountUpn
: null,
};
} catch (err) {
if (err?.code === "ENOENT") {
return { activeAccountUpn: null };
}
throw err;
}
}
async function writeSessionState(state) {
const sessionPath = getSessionFilePath();
async function writeSessionState(state: SessionState): Promise<void> {
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
await mkdir(path.dirname(sessionPath), { recursive: true });
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
}
async function clearSessionState() {
async function clearSessionState(): Promise<void> {
try {
await unlink(getSessionFilePath());
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`);
await unlink(sessionPath);
} catch (err) {
if (err?.code !== "ENOENT") {
if ((err as { code?: string } | null)?.code !== "ENOENT") {
throw err;
}
}
}
function normalizeUpn(upn) {
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
}
function writeStderr(message) {
function writeStderr(message: string): void {
process.stderr.write(`${message}\n`);
}
function getBrowserAppName(browser) {
function getBrowserAppName(browser?: string): string | readonly string[] | undefined {
if (!browser || browser.trim() === "") {
return null;
return undefined;
}
const keyword = BROWSER_KEYWORDS.find(
@@ -90,10 +78,10 @@ function getBrowserAppName(browser) {
);
}
return apps[keyword];
return OPEN_APPS[keyword];
}
function getBrowserKeyword(browser) {
function getBrowserKeyword(browser?: string): string {
if (!browser || browser.trim() === "") {
return "";
}
@@ -109,14 +97,13 @@ function getBrowserKeyword(browser) {
return keyword.toLowerCase();
}
function getBrowserOpenOptions({ browser, browserProfile }) {
function getBrowserOpenOptions(browser?: string, browserProfile?: string): Parameters<typeof open>[1] {
const browserName = getBrowserAppName(browser);
const options = browserName
? { wait: false, app: { name: browserName } }
: { wait: false };
if (!browserProfile || browserProfile.trim() === "") {
return options;
return browserName
? { wait: false, app: { name: browserName } }
: { wait: false };
}
const browserKeyword = getBrowserKeyword(browser);
@@ -126,11 +113,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) {
);
}
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`];
return options;
if (!browserName) {
throw new Error("--browser-profile requires --browser");
}
return {
wait: false,
app: {
name: browserName,
arguments: [`--profile-directory=${browserProfile.trim()}`],
},
};
}
function validateBrowserOptions({ browser, browserProfile }) {
function validateBrowserOptions(browser?: string, browserProfile?: string): void {
if (browser && browser.trim() !== "") {
getBrowserAppName(browser);
}
@@ -145,7 +141,7 @@ function validateBrowserOptions({ browser, browserProfile }) {
}
}
export function parseResources(resourcesCsv) {
export function parseResources(resourcesCsv?: string): ResourceName[] {
if (!resourcesCsv || resourcesCsv.trim() === "") {
return [...DEFAULT_RESOURCES];
}
@@ -156,24 +152,24 @@ export function parseResources(resourcesCsv) {
.filter(Boolean);
const unique = [...new Set(resources)];
const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]);
const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name));
if (invalid.length > 0) {
throw new Error(
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
);
}
return unique;
return unique as ResourceName[];
}
function fileCachePlugin(cachePath) {
function fileCachePlugin(cachePath: string): ICachePlugin {
return {
beforeCacheAccess: async (ctx) => {
beforeCacheAccess: async (ctx: TokenCacheContext) => {
if (fs.existsSync(cachePath)) {
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
}
},
afterCacheAccess: async (ctx) => {
afterCacheAccess: async (ctx: TokenCacheContext) => {
if (!ctx.cacheHasChanged) return;
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
@@ -182,10 +178,10 @@ function fileCachePlugin(cachePath) {
};
}
async function createPca({ tenantId, clientId }) {
const cacheRoot = getCacheRoot();
async function createPca(tenantId: string, clientId: string): Promise<PublicClientApplication> {
const cacheRoot = getConfigDir("sk-az-tools");
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
let cachePlugin;
let cachePlugin: ICachePlugin;
try {
const {
DataProtectionScope,
@@ -201,7 +197,7 @@ async function createPca({ tenantId, clientId }) {
usePlaintextFileOnLinux: true,
});
cachePlugin = new PersistenceCachePlugin(persistence);
} catch (err) {
} catch {
// Fallback when msal-node-extensions/keytar/libsecret are unavailable.
cachePlugin = fileCachePlugin(cachePath);
}
@@ -217,7 +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) {
try {
return await pca.acquireTokenSilent({
@@ -244,65 +244,74 @@ async function acquireTokenWithCache({ pca, scopes, account }) {
return null;
}
async function findAccountByUpn({ pca, upn }) {
const normalized = normalizeUpn(upn);
async function findAccountByUpn(
pca: PublicClientApplication,
upn: string,
): Promise<AccountInfo | null> {
const normalized = upn.trim().toLowerCase();
if (!normalized) {
return null;
}
const accounts = await pca.getTokenCache().getAllAccounts();
return (
accounts.find((account) => normalizeUpn(account?.username) === normalized) ??
accounts.find((account) => account.username.trim().toLowerCase() === normalized) ??
null
);
}
export async function loginInteractive({
tenantId,
clientId,
scopes,
export async function loginInteractive(
tenantId: string | undefined,
clientId: string | undefined,
scopes: string[],
showAuthUrlOnly = false,
browser,
browserProfile,
}) {
browser?: string,
browserProfile?: string,
): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required");
if (!Array.isArray(scopes) || scopes.length === 0)
if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("scopes[] is required");
validateBrowserOptions({ browser, browserProfile });
}
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;
return await pca.acquireTokenInteractive({
return pca.acquireTokenInteractive({
scopes,
openBrowser: async (url) => {
openBrowser: async (url: string) => {
if (showAuthUrlOnly) {
writeStderr(`Visit:\n${url}`);
return;
}
const options = getBrowserOpenOptions({ browser, browserProfile });
return open(url, options).catch(() => {
const options = getBrowserOpenOptions(browser, browserProfile);
await open(url, options).catch(() => {
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 (!clientId) throw new Error("clientId is required");
if (!Array.isArray(scopes) || scopes.length === 0)
if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("scopes[] is required");
}
const pca = await createPca({ tenantId, clientId });
const pca = await createPca(tenantId, clientId);
const cached = await acquireTokenWithCache({ pca, scopes });
const cached = await acquireTokenWithCache(pca, scopes);
if (cached) return cached;
return await pca.acquireTokenByDeviceCode({
return pca.acquireTokenByDeviceCode({
scopes,
deviceCodeCallback: (response) => {
writeStderr(response.message);
@@ -310,38 +319,38 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }) {
});
}
export async function login({
tenantId,
clientId,
resourcesCsv,
export async function login(
tenantId: string | undefined,
clientId: string | undefined,
resourcesCsv?: string,
useDeviceCode = false,
noBrowser = false,
browser,
browserProfile,
}) {
browser?: string,
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 (!clientId) throw new Error("clientId is required");
validateBrowserOptions({ browser, browserProfile });
validateBrowserOptions(browser, browserProfile);
const resources = parseResources(resourcesCsv);
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 preferredAccount = await findAccountByUpn({
pca,
upn: session.activeAccountUpn,
});
const preferredAccount = session.activeAccountUpn
? await findAccountByUpn(pca, session.activeAccountUpn)
: null;
const results = [];
let selectedAccount = preferredAccount;
const results: Array<{ resource: string; expiresOn: string | null }> = [];
let selectedAccount: AccountInfo | null = preferredAccount;
for (let index = 0; index < resources.length; index += 1) {
const resource = resources[index];
const scope = [scopes[index]];
let token = await acquireTokenWithCache({
pca,
scopes: scope,
account: selectedAccount,
});
let token = await acquireTokenWithCache(pca, scope, selectedAccount);
if (!token) {
if (useDeviceCode) {
@@ -354,13 +363,13 @@ export async function login({
} else {
token = await pca.acquireTokenInteractive({
scopes: scope,
openBrowser: async (url) => {
openBrowser: async (url: string) => {
if (noBrowser) {
writeStderr(`Visit:\n${url}`);
return;
}
const options = getBrowserOpenOptions({ browser, browserProfile });
return open(url, options).catch(() => {
const options = getBrowserOpenOptions(browser, browserProfile);
await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`);
});
},
@@ -391,30 +400,28 @@ export async function login({
};
}
export async function acquireResourceTokenFromLogin({
tenantId,
clientId,
resource,
}) {
export async function acquireResourceToken(
tenantId: string,
clientId: string,
resource: string,
): Promise<AuthenticationResult | null> {
if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required");
if (!resource) throw new Error("resource is required");
const scope = RESOURCE_SCOPE_BY_NAME[resource];
if (!scope) {
if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) {
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
}
const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName];
const session = await readSessionState();
if (!session.activeAccountUpn) {
throw new Error(LOGIN_REQUIRED_MESSAGE);
}
const pca = await createPca({ tenantId, clientId });
const account = await findAccountByUpn({
pca,
upn: session.activeAccountUpn,
});
const pca = await createPca(tenantId, clientId);
const account = await findAccountByUpn(pca, session.activeAccountUpn);
if (!account) {
throw new Error(LOGIN_REQUIRED_MESSAGE);
}
@@ -429,16 +436,16 @@ export async function acquireResourceTokenFromLogin({
}
}
export async function logout({
tenantId,
clientId,
export async function logout(
tenantId: string,
clientId: string,
clearAll = false,
userPrincipalName,
}) {
userPrincipalName?: string,
): Promise<{ clearedAll: boolean; signedOut: string[] }> {
if (!tenantId) throw new Error("tenantId 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 accounts = await tokenCache.getAllAccounts();
const session = await readSessionState();
@@ -450,13 +457,14 @@ export async function logout({
await clearSessionState();
return {
clearedAll: true,
signedOut: accounts.map((account) => account.username).filter(Boolean),
signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)),
};
}
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(
(account) => normalizeUpn(account.username) === targetUpn,
(account) => account.username.trim().toLowerCase() === targetUpn,
);
if (!accountToSignOut) {
@@ -468,6 +476,6 @@ export async function logout({
await clearSessionState();
return {
clearedAll: false,
signedOut: [accountToSignOut.username].filter(Boolean),
signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)),
};
}

View File

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

165
src/cli.ts Normal file
View File

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

View File

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

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

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

1
src/cli/commands/auth.ts Normal file
View File

@@ -0,0 +1 @@
// SPDX-License-Identifier: MIT

View 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`);
}

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View 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
View 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"],
);
}

View 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
View 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,
};
}

View 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
View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
export type CommandValues = {
[key: string]: string | boolean | undefined;
type?: string;
method?: string;
url?: string;
header?: string;
resources?: string;
"use-device-code"?: boolean;
"no-browser"?: boolean;
browser?: string;
"browser-profile"?: string;
all?: boolean;
"display-name"?: string;
"app-id"?: string;
filter?: string;
resolve?: boolean;
};

View File

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

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

@@ -1,7 +1,6 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -9,10 +8,16 @@ import readline from "node:readline";
import { spawnSync } from "node:child_process";
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, {
encoding: "utf8",
stdio: options.quiet
stdio: quiet
? ["ignore", "ignore", "ignore"]
: ["ignore", "pipe", "pipe"],
});
@@ -21,7 +26,7 @@ function runAz(args, options = {}) {
throw result.error;
}
if (result.status !== 0 && options.allowFailure !== true) {
if (result.status !== 0 && allowFailure !== true) {
throw new Error(
(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>
Options:
-c, --config <path> Write JSON config to file (optional)
-h, --help Show this help message and exit`;
let values;
let positionals;
let values: Record<string, string | boolean | undefined>;
let positionals: string[];
try {
({ values, positionals } = parseArgs({
args: process.argv.slice(2),
@@ -52,7 +58,7 @@ Options:
allowPositionals: true,
}));
} catch (err) {
console.error(`Error: ${err.message}`);
console.error(`Error: ${(err as Error).message}`);
console.error(usageText);
process.exit(1);
}
@@ -71,7 +77,7 @@ Options:
}
const appName = positionals[0] || "";
const configPath = values.config || "";
const configPath = typeof values.config === "string" ? values.config : "";
if (!appName) {
console.error("Error: Application name is required.");
@@ -96,12 +102,12 @@ Options:
input: process.stdin,
output: process.stderr,
});
const answer = await new Promise((resolve) => {
const answer = await new Promise<string>((resolve) => {
rl.question(
`Application '${appName}' already exists. Update it? [y/N]: `,
(answer) => {
(answerValue) => {
rl.close();
resolve(answer.trim());
resolve(answerValue.trim());
},
);
});
@@ -187,7 +193,7 @@ Options:
"--enable-id-token-issuance",
"true",
],
{ quiet: true },
true,
);
} catch {
console.error(
@@ -199,14 +205,12 @@ Options:
fs.rmSync(tempDir, { recursive: true, force: true });
}
runAz(["ad", "sp", "create", "--id", appId], {
quiet: true,
allowFailure: true,
});
runAz(["ad", "sp", "create", "--id", appId], true, true);
const adminConsentResult = runAz(
["ad", "app", "permission", "admin-consent", "--id", appId],
{ quiet: true, allowFailure: true },
true,
true,
);
if (adminConsentResult.status !== 0) {
console.warn(
@@ -251,6 +255,6 @@ Options:
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
});

View File

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

View File

@@ -1,55 +0,0 @@
// SPDX-License-Identifier: MIT
/**
* A DevOps helpers module.
*/
import { loginInteractive } from "../azure/index.js";
import * as azdev from "azure-devops-node-api";
const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"];
/**
* Get Azure DevOps API token.
*
* @param { string } tenantId - The Azure AD tenant ID
* @param { string } clientId - The Azure AD client ID
* @returns { Promise<string> } Azure DevOps API access token
*/
export async function getDevOpsApiToken(tenantId, clientId) {
const result = await loginInteractive({
tenantId,
clientId,
scopes: AZURE_DEVOPS_SCOPES,
});
const accessToken = result?.accessToken;
if(!accessToken) {
throw new Error("Failed to obtain Azure DevOps API token");
}
return accessToken;
}
/**
* Get Azure DevOps clients - Core and Git.
*
* @param { string } orgUrl - The Azure DevOps organization URL
* @param { string } tenantId - The Azure AD tenant ID
* @param { string } clientId - The Azure AD client ID
* @returns { Promise<{ coreClient: Object, gitClient: Object }> }
*/
export async function getDevOpsClients(orgUrl, tenantId, clientId) {
const accessToken = await getDevOpsApiToken(tenantId, clientId);
const authHandler = azdev.getBearerHandler(accessToken);
const connection = new azdev.WebApi(orgUrl, authHandler);
const coreClient = await connection.getCoreApi();
const gitClient = await connection.getGitApi();
return { coreClient, gitClient };
}

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

@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
/**
* A DevOps helpers module.
*/
import { loginInteractive } from "../azure/index.ts";
import * as azdev from "azure-devops-node-api";
const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"];
type LoginInteractiveResult = {
accessToken?: string;
};
export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise<string> {
const result = await loginInteractive(
tenantId,
clientId,
AZURE_DEVOPS_SCOPES,
) as LoginInteractiveResult;
const accessToken = result?.accessToken;
if (!accessToken) {
throw new Error("Failed to obtain Azure DevOps API token");
}
return accessToken;
}
export async function getDevOpsClients(orgUrl: string, tenantId: string, clientId: string): Promise<{ coreClient: unknown; gitClient: unknown }> {
const accessToken = await getDevOpsApiToken(tenantId, clientId);
const authHandler = azdev.getBearerHandler(accessToken);
const connection = new azdev.WebApi(orgUrl, authHandler);
const coreClient = await connection.getCoreApi();
const gitClient = await connection.getGitApi();
return { coreClient, gitClient };
}

View File

@@ -1,51 +1,70 @@
// SPDX-License-Identifier: MIT
/**
* Get an Azure application by its display name.
*
* @param { Object } client
* @param { string } displayName
* @returns { Promise<Object|null> }
*/
export async function getApp(client, displayName) {
type GraphObject = Record<string, unknown>;
type GraphResult<T = GraphObject> = {
value?: T[];
};
type RequiredResourceAccessItem = {
type?: string;
id?: string;
};
type RequiredResourceAccess = {
resourceAppId?: string;
resourceAccess?: RequiredResourceAccessItem[];
};
type GraphPermission = {
id?: string;
value?: string;
displayName?: string;
adminConsentDisplayName?: string;
userConsentDisplayName?: string;
isEnabled?: boolean;
};
type ServicePrincipal = {
id?: string;
appId?: string;
displayName?: string;
oauth2PermissionScopes?: GraphPermission[];
appRoles?: GraphPermission[];
};
export async function getApp(client: any, displayName: string): Promise<GraphObject | null> {
const result = await client
.api("/applications")
.filter(`displayName eq '${displayName}'`)
.get();
.get() as GraphResult;
// Return the first application found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
}
export async function createApp(client, displayName) {
export async function createApp(client: any, displayName: string): Promise<GraphObject> {
const app = await client.api("/applications").post({
displayName,
});
}) as GraphObject;
if (!app || !app.appId) {
if (!app || typeof app.appId !== "string") {
throw new Error("Failed to create application");
}
return app;
}
export async function deleteApp(client, appObjectId) {
export async function deleteApp(client: any, appObjectId: string): Promise<void> {
await client.api(`/applications/${appObjectId}`).delete();
}
/**
* List Azure applications, optionally filtered by display name and/or app ID.
*
* @param { Object } client
* @param { Object } [options]
* @param { string } [options.displayName]
* @param { string } [options.appId]
* @returns { Promise<Array> }
*/
export async function listApps(client, options = {}) {
const { displayName, appId } = options;
export async function listApps(
client: any,
displayName?: string,
appId?: string,
): Promise<GraphObject[]> {
let request = client.api("/applications");
const filters = [];
const filters: string[] = [];
if (displayName) {
filters.push(`displayName eq '${displayName}'`);
@@ -58,18 +77,11 @@ export async function listApps(client, options = {}) {
request = request.filter(filters.join(" and "));
}
const result = await request.get();
const result = await request.get() as GraphResult;
return Array.isArray(result?.value) ? result.value : [];
}
/**
* List required resource access configuration for an application by appId.
*
* @param { Object } client
* @param { string } appId
* @returns { Promise<Array> }
*/
export async function listAppPermissions(client, appId) {
export async function listAppPermissions(client: any, appId: string): Promise<RequiredResourceAccess[]> {
if (!appId) {
throw new Error("appId is required");
}
@@ -78,7 +90,7 @@ export async function listAppPermissions(client, appId) {
.api("/applications")
.filter(`appId eq '${appId}'`)
.select("id,appId,displayName,requiredResourceAccess")
.get();
.get() as GraphResult<GraphObject>;
const app = Array.isArray(result?.value) && result.value.length > 0
? result.value[0]
@@ -88,19 +100,13 @@ export async function listAppPermissions(client, appId) {
return [];
}
return Array.isArray(app.requiredResourceAccess)
? app.requiredResourceAccess
const requiredResourceAccess = app.requiredResourceAccess;
return Array.isArray(requiredResourceAccess)
? requiredResourceAccess as RequiredResourceAccess[]
: [];
}
/**
* List required resource access in a resolved, human-readable form.
*
* @param { Object } client
* @param { string } appId
* @returns { Promise<Array> }
*/
export async function listAppPermissionsResolved(client, appId) {
export async function listAppPermissionsResolved(client: any, appId: string): Promise<Array<Record<string, unknown>>> {
const requiredResourceAccess = await listAppPermissions(client, appId);
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
return [];
@@ -109,7 +115,7 @@ export async function listAppPermissionsResolved(client, appId) {
const resourceAppIds = [...new Set(
requiredResourceAccess
.map((entry) => entry?.resourceAppId)
.filter(Boolean),
.filter((value): value is string => typeof value === "string" && value.length > 0),
)];
const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
@@ -117,17 +123,21 @@ export async function listAppPermissionsResolved(client, appId) {
.api("/servicePrincipals")
.filter(`appId eq '${resourceAppId}'`)
.select("appId,displayName,oauth2PermissionScopes,appRoles")
.get();
.get() as GraphResult<ServicePrincipal>;
const sp = Array.isArray(result?.value) && result.value.length > 0
? result.value[0]
: null;
const scopesById = new Map(
(sp?.oauth2PermissionScopes ?? []).map((scope) => [scope.id, scope]),
(sp?.oauth2PermissionScopes ?? [])
.filter((scope) => typeof scope.id === "string")
.map((scope) => [scope.id as string, scope]),
);
const rolesById = new Map(
(sp?.appRoles ?? []).map((role) => [role.id, role]),
(sp?.appRoles ?? [])
.filter((role) => typeof role.id === "string")
.map((role) => [role.id as string, role]),
);
return {
@@ -142,9 +152,10 @@ export async function listAppPermissionsResolved(client, appId) {
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
);
const rows = [];
const rows: Array<Record<string, unknown>> = [];
for (const resourceEntry of requiredResourceAccess) {
const resourceMeta = byResourceAppId.get(resourceEntry.resourceAppId);
const resourceAppId = resourceEntry.resourceAppId ?? "";
const resourceMeta = byResourceAppId.get(resourceAppId);
const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
? resourceEntry.resourceAccess
: [];
@@ -153,8 +164,8 @@ export async function listAppPermissionsResolved(client, appId) {
const permissionType = item?.type ?? null;
const permissionId = item?.id ?? null;
const resolved = permissionType === "Scope"
? resourceMeta?.scopesById.get(permissionId)
: resourceMeta?.rolesById.get(permissionId);
? resourceMeta?.scopesById.get(permissionId ?? "")
: resourceMeta?.rolesById.get(permissionId ?? "");
rows.push({
resourceAppId: resourceEntry.resourceAppId ?? null,
@@ -174,14 +185,7 @@ export async function listAppPermissionsResolved(client, appId) {
return rows;
}
/**
* List delegated OAuth2 permission grants for an application by appId.
*
* @param { Object } client
* @param { string } appId
* @returns { Promise<Array> }
*/
export async function listAppGrants(client, appId) {
export async function listAppGrants(client: any, appId: string): Promise<GraphObject[]> {
if (!appId) {
throw new Error("appId is required");
}
@@ -190,7 +194,7 @@ export async function listAppGrants(client, appId) {
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.select("id,appId,displayName")
.get();
.get() as GraphResult<ServicePrincipal>;
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
? spResult.value[0]
@@ -203,22 +207,16 @@ export async function listAppGrants(client, appId) {
const grantsResult = await client
.api("/oauth2PermissionGrants")
.filter(`clientId eq '${servicePrincipal.id}'`)
.get();
.get() as GraphResult;
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
}
/**
* List available delegated scopes and app roles for a resource app.
*
* @param { Object } client
* @param { Object } options
* @param { string } [options.appId]
* @param { string } [options.displayName]
* @returns { Promise<Array> }
*/
export async function listResourcePermissions(client, options = {}) {
const { appId, displayName } = options;
export async function listResourcePermissions(
client: any,
appId?: string,
displayName?: string,
): Promise<Array<Record<string, unknown>>> {
if (!appId && !displayName) {
throw new Error("appId or displayName is required");
}
@@ -233,9 +231,9 @@ export async function listResourcePermissions(client, options = {}) {
request = request.filter(`displayName eq '${displayName}'`);
}
const result = await request.get();
const result = await request.get() as GraphResult<ServicePrincipal>;
const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
const rows = [];
const rows: Array<Record<string, unknown>> = [];
for (const sp of servicePrincipals) {
for (const scope of sp?.oauth2PermissionScopes ?? []) {

View File

@@ -1,29 +0,0 @@
// SPDX-License-Identifier: MIT
import { Client } from "@microsoft/microsoft-graph-client";
import { acquireResourceTokenFromLogin } from "../azure/index.js";
/**
* Initialize and return a Microsoft Graph client
* along with the authentication token.
*
* @param { Object } options - Options for authentication
* @param { string } options.tenantId - The Azure AD tenant ID
* @param { string } options.clientId - The Azure AD client ID
* @returns { Promise<{ graphApiToken: Object, client: Object }> } An object containing the Graph API token and client
*/
export async function getGraphClient({ tenantId, clientId }) {
const graphApiToken = await acquireResourceTokenFromLogin({
tenantId,
clientId,
resource: "graph",
});
const client = Client.init({
authProvider: (done) => {
done(null, graphApiToken.accessToken);
},
});
return { graphApiToken, client };
}

24
src/graph/auth.ts Normal file
View 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 };
}

View File

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

View File

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

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

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

View File

@@ -1,27 +0,0 @@
// SPDX-License-Identifier: MIT
export async function getServicePrincipal(client, appId) {
const result = await client
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.get();
// Return the first service principal found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
}
export async function createSp(client, appId) {
const sp = await client.api("/servicePrincipals").post({
appId,
});
if (!sp || !sp.id) {
throw new Error("Failed to create service principal");
}
return sp;
}
export async function deleteSp(client, spId) {
await client.api(`/servicePrincipals/${spId}`).delete();
}

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

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
type GraphResult<T = Record<string, unknown>> = {
value?: T[];
};
export async function getServicePrincipal(client: any, appId: string): Promise<Record<string, unknown> | null> {
const result = await client
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.get() as GraphResult;
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
}
export async function createSp(client: any, appId: string): Promise<Record<string, unknown>> {
const sp = await client.api("/servicePrincipals").post({
appId,
}) as Record<string, unknown>;
if (!sp || typeof sp.id !== "string") {
throw new Error("Failed to create service principal");
}
return sp;
}
export async function deleteSp(client: any, spId: string): Promise<void> {
await client.api(`/servicePrincipals/${spId}`).delete();
}

1
src/index.d.ts vendored
View File

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

View File

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

View File

@@ -1,113 +0,0 @@
// SPDX-License-Identifier: MIT
function formatCell(value) {
const text = value == null
? ""
: String(value);
return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
}
function isGuid(value) {
return typeof value === "string"
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}
function toAutoHeaderLabel(key) {
const withSpaces = String(key)
.replace(/[_-]+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
return withSpaces
.split(" ")
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
}
function getScalarRowsAndHeaders(value) {
let rows;
if (Array.isArray(value)) {
rows = value.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? item
: { value: item }
);
} else if (value && typeof value === "object") {
rows = [value];
} else {
rows = [{ value }];
}
if (rows.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
.filter((key) =>
rows.every((row) => {
const value = row[key];
return value == null || typeof value !== "object";
})
);
if (headers.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
return { headers, rows };
}
export function toMarkdownTable(value, pretty = false, quoteGuids = false) {
const headerSpec = arguments[3] ?? { mode: "default" };
const { headers, rows } = getScalarRowsAndHeaders(value);
const headerDefinitions = headers.map((key, idx) => {
let label = key;
if (headerSpec?.mode === "auto") {
label = toAutoHeaderLabel(key);
} else if (headerSpec?.mode === "list" && Array.isArray(headerSpec.labels) && headerSpec.labels[idx]) {
label = headerSpec.labels[idx];
} else if (headerSpec?.mode === "map" && headerSpec.map && headerSpec.map[key]) {
label = headerSpec.map[key];
}
return { key, label };
});
const renderCell = (raw) => {
const text = formatCell(raw);
return quoteGuids && isGuid(raw) ? `\`${text}\`` : text;
};
if (!pretty) {
const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;
const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`;
const rowLines = rows.map((row) =>
`| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |`
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}
const widths = headerDefinitions.map((header, idx) =>
Math.max(
header.label.length,
...rows.map((row) => renderCell(row[headerDefinitions[idx].key]).length),
)
);
const renderRow = (values) =>
`| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`;
const headerLine = renderRow(headerDefinitions.map((h) => h.label));
const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`;
const rowLines = rows.map((row) =>
renderRow(headerDefinitions.map((header) => renderCell(row[header.key])))
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2024"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"rewriteRelativeImportExtensions": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}