Compare commits

...

31 Commits

Author SHA1 Message Date
6fc99f62c3 chore: update package version to 0.8.1.
All checks were successful
build / build (push) Successful in 15s
2026-03-11 13:00:09 +01:00
d6adb5a3ba Update: devops submodule convertion to new simpler auth model. 2026-03-11 12:59:00 +01:00
4dd3056b2f fix: missed incorrect AI autocomplete. 2026-03-11 12:12:36 +01:00
3e2b54ba3c fix: validation for bump type in check-package-version script 2026-03-11 12:11:30 +01:00
0269313516 chore: update package version to 0.8.0 in package.json and package-lock.json
All checks were successful
build / build (push) Successful in 15s
2026-03-11 11:59:24 +01:00
ade8f065e0 feat: enhance package version management in check-package-version script 2026-03-11 11:59:20 +01:00
1bae7d8f85 chore: update @slawek/sk-tools version to ^0.4.1 in package.json and package-lock.json 2026-03-11 11:48:09 +01:00
0714fc5c1b feat: add script to check and update package versions for sk-tools 2026-03-11 11:48:00 +01:00
d8d72be7e9 chore: update file permissions for make-mermaid-func-deps.mjs 2026-03-11 11:47:53 +01:00
b678dd5ace Authentication refactoring. 2026-03-11 10:41:42 +01:00
d69402a33d Updated package versions.
All checks were successful
build / build (push) Successful in 22s
2026-03-10 20:36:01 +01:00
2fa8fcfc3c Fix: Do not require PCA to be configured for Azure Identity default authentication. 2026-03-10 20:31:09 +01:00
059fc3c1da fix: update getAzureIdentityAuthProvider and make tenant id and client id optional. 2026-03-10 20:28:41 +01:00
97f7011f97 Fix: dependencies.
All checks were successful
build / build (push) Successful in 14s
2026-03-10 19:52:24 +01:00
dda13b7e2a chore: bump version to 0.7.1 in package.json
All checks were successful
build / build (push) Successful in 14s
2026-03-10 19:49:26 +01:00
5265e5300c refactor: remove unused usage functions and migrate argument parsing to commander.js 2026-03-10 19:48:02 +01:00
9fd770999b Migrated from parseArgs from node:util to commander.js.
All checks were successful
build / build (push) Successful in 14s
2026-03-10 07:15:00 +01:00
a98c77cd2e Refactor of authentication code. Added configuration file selectable authentication method. Selectable from built-in Azure Identity, and custom PCA using MSAL.
Some checks failed
build / build (push) Failing after 14s
2026-03-08 19:07:10 +01:00
0829b35113 refactor: remove unused imports and function for cleaner code
Some checks failed
build / build (push) Failing after 13s
2026-03-08 07:56:45 +01:00
63eb9c3cad Updated sk-tools to version 0.3.0.
All checks were successful
build / build (push) Successful in 13s
2026-03-07 21:51:18 +01:00
cd119c90c2 Fix: resolved package-lock issues.
All checks were successful
build / build (push) Successful in 13s
2026-03-07 19:14:19 +01:00
88ac901222 update: adopted new output options. Various optimizations.
Some checks failed
build / build (push) Failing after 15s
2026-03-07 18:49:15 +01:00
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
33 changed files with 1169 additions and 919 deletions

View File

@@ -6,18 +6,34 @@ The `sk-az-tools` package may act as a CLI tool that provides various commands f
- Azure Resource Manager
- Azure DevOps Services
## Global Options
These options apply to all commands unless stated otherwise:
- `--query`, `-q` <jmespath> - Apply JMESPath filter before output rendering.
- `--output`, `-o` <format> - Output format: `table|t|alignedtable|at|prettytable|pt|tsv`.
- `--columns`, `-C` <definition> - Column selection for table outputs:
- `col1` - Select column (case-insensitive match), keep raw header label.
- `col1:` - Select column (case-insensitive match), use auto-generated header label.
- `col1: Label 1` - Select column (case-insensitive match), use custom header label.
- Prefix token with `=` for exact column-name match: `=col1`, `=col1:`, `=col1:Label`.
- Tokens are comma-separated and rendered in the specified order.
- `--help`, `-h` - Show command help.
Note: `rest --header` is a command-specific HTTP header option and is unrelated to `--columns`.
## Login
**Command name:** `login`
**Usage:** `sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]`
**Usage:** `sk-az-tools login [resource...] [--use-device-code] [--no-browser] [--browser-name <name>] [--browser-profile <profile>] [global options]`
**Options:**
- `--resources` <csv> - Comma-separated resources to authenticate. Allowed values: `graph`, `devops`, `arm`. Default is all three.
- `[resource...]` - One or more resources to authenticate. Allowed values: `graph`, `devops`, `azurerm`. Default is `azurerm`.
- `--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-name` <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.

View File

@@ -1,54 +0,0 @@
# Packaging sk-az-tools
## Build model
- Source lives in `src/` as TypeScript (`.ts`).
- Runtime package is compiled to `dist/` using `npm run build`.
- Public package entrypoints (`exports` and `bin`) point to `dist/**`.
## Package surface
- `exports` defines what consumers can import.
- `files` controls what is shipped to npm.
- Current shipping content is `dist`, `README.md`, and `LICENSE`.
## Development workflow
Build once:
```bash
npm run build
```
Build in watch mode:
```bash
npm run build:watch
```
Smoke check CLI output:
```bash
node dist/cli.js --help
```
## Publish checklist
1. Run `npm run build` and ensure TypeScript compiles without errors.
2. Verify package content with `npm pack --dry-run`.
3. Create artifact: `npm pack --pack-destination ./artifacts`.
4. Optionally install the artifact locally and validate CLI/imports.
## Tarball usage
Create package tarball:
```bash
npm pack --pack-destination ./artifacts
```
Install from tarball:
```bash
npm install ./artifacts/@slawek/sk-az-tools-<version>.tgz
```

175
package-lock.json generated
View File

@@ -1,29 +1,33 @@
{
"name": "@slawek/sk-az-tools",
"version": "0.4.2",
"version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@slawek/sk-az-tools",
"version": "0.4.2",
"version": "0.8.1",
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.13.0",
"@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",
"@slawek/sk-tools": "^0.4.1",
"azure-devops-node-api": "^15.1.2",
"commander": "^14.0.3",
"minimatch": "^10.1.2",
"open": "^10.1.0"
"open": "^10.1.0",
"semver": "^7.7.2",
"uuid": "^11.1.0"
},
"bin": {
"sk-az-tools": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.8.2"
"@types/node": ">=24.0.0",
"ts-morph": ">=27.0.0",
"typescript": ">=5.8.2"
},
"engines": {
"node": ">=24.0.0"
@@ -153,6 +157,15 @@
"node": ">=16"
}
},
"node_modules/@azure/identity/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@azure/logger": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
@@ -232,6 +245,15 @@
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
@@ -269,26 +291,41 @@
}
},
"node_modules/@slawek/sk-tools": {
"version": "0.1.1",
"resolved": "https://gitea.koszewscy.waw.pl/api/packages/slawek/npm/%40slawek%2Fsk-tools/-/0.1.1/sk-tools-0.1.1.tgz",
"integrity": "sha512-DDG4d35VRGWD7kVpKre/jyXiWG+hgcJ8nrF38FInlWYEEciKb8gHSjqFvol2sQufKBTtvDtQTGbLIW+yqsehdA==",
"version": "0.4.1",
"resolved": "https://gitea.koszewscy.waw.pl/api/packages/slawek/npm/%40slawek%2Fsk-tools/-/0.4.1/sk-tools-0.4.1.tgz",
"integrity": "sha512-rTw/m6ZK72HGELcCC+ze1sNcqt4LM5dBUdJ3c5UsOT95qTZAGauVBKsslpVd4Kotf24vNlGFQ1fpVDcT5sluwQ==",
"license": "MIT",
"dependencies": {
"commander": "^14.0.3",
"d3-dsv": "^3.0.1",
"jmespath": "^0.16.0"
"jmespath": "^0.16.0",
"semver": "^7.7.4",
"uuid": "^11.1.0"
},
"bin": {
"sk-tools": "dist/cli.js"
}
},
"node_modules/@types/node": {
"version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
"node_modules/@ts-morph/common": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz",
"integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"minimatch": "^10.0.1",
"path-browserify": "^1.0.1",
"tinyglobby": "^0.2.14"
}
},
"node_modules/@types/node": {
"version": "25.3.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@typespec/ts-http-runtime": {
@@ -459,13 +496,20 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">= 10"
"node": ">=20"
}
},
"node_modules/d3-dsv": {
@@ -493,6 +537,15 @@
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -664,6 +717,24 @@
"node": ">=6"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -1113,6 +1184,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1413,6 +1504,34 @@
"node": ">=6"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ts-morph": {
"version": "27.0.2",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz",
"integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ts-morph/common": "~0.28.1",
"code-block-writer": "^13.0.3"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1477,9 +1596,9 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
@@ -1490,12 +1609,16 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/wrappy": {

View File

@@ -1,6 +1,6 @@
{
"name": "@slawek/sk-az-tools",
"version": "0.4.2",
"version": "0.8.1",
"type": "module",
"files": [
"dist",
@@ -8,11 +8,12 @@
"LICENSE"
],
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && tsc && chmod +x dist/cli.js",
"build": "rm -rf dist && tsc && chmod +x dist/cli.js",
"build:watch": "tsc --watch",
"prepublishOnly": "npm run build",
"create-pca": "node dist/create-pca.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"
@@ -23,14 +24,18 @@
"@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",
"@slawek/sk-tools": "^0.4.1",
"azure-devops-node-api": "^15.1.2",
"commander": "^14.0.3",
"minimatch": "^10.1.2",
"open": "^10.1.0"
"open": "^10.1.0",
"semver": "^7.7.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.8.2"
"@types/node": ">=24.0.0",
"ts-morph": ">=27.0.0",
"typescript": ">=5.8.2"
},
"author": {
"name": "Sławomir Koszewski",

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import { inc } from "semver";
const skAzToolsPackagePath = resolve("package.json");
const skAzToolsPackageLockPath = resolve("package-lock.json");
const skToolsPackagePath = resolve("../sk-tools", "package.json");
const skAzToolsPackage = JSON.parse(readFileSync(skAzToolsPackagePath, "utf-8"));
const skAzToolsPackageLock = JSON.parse(readFileSync(skAzToolsPackageLockPath, "utf-8"));
const skToolsPackage = JSON.parse(readFileSync(skToolsPackagePath, "utf-8"));
const { values } = parseArgs({
options: {
update: { type: "boolean", short: "u", description: "Update @slawek/sk-tools to the latest version." },
bump: { type: "string", short: "b", description: "Bump the version of @slawek/sk-az-tools in package.json. Allowed values: major, minor, patch." }
}
});
if (values.bump !== undefined && !["major", "minor", "patch"].includes(values.bump)) {
console.error(`Invalid bump type: ${values.bump}. Allowed values are: major, minor, patch.`);
process.exit(1);
}
// Package versions
console.log(`SK Tools version: ${skToolsPackage.version}`);
console.log(`SK Azure Tools version: ${skAzToolsPackage.version}\n`);
if (values.bump) {
const newVersion = inc(skAzToolsPackage.version, values.bump);
if (!newVersion) {
console.error(`Failed to bump version: ${skAzToolsPackage.version}`);
process.exit(1);
}
skAzToolsPackage.version = newVersion;
writeFileSync(skAzToolsPackagePath, JSON.stringify(skAzToolsPackage, null, 4));
console.log(`Bumped SK Azure Tools version to: ${newVersion}`);
}
console.log(`SK Azure Tools Locked version: ${skAzToolsPackageLock.version}`);
// Update package.json if --update flag is set
// or if the version of @slawek/sk-az-tools in package.json
// is different than the version in package-lock.json.
if (values.update || skAzToolsPackage.version !== skAzToolsPackageLock.version) {
console.log(`Updating package.json...`);
skAzToolsPackage.dependencies["@slawek/sk-tools"] = `>=${skToolsPackage.version}`;
writeFileSync(skAzToolsPackagePath, JSON.stringify(skAzToolsPackage, null, 4));
// Install and link the updated package
spawnSync("npm", ["install", "@slawek/sk-tools"], { stdio: "inherit" });
spawnSync("npm", ["link", "@slawek/sk-tools"], { stdio: "inherit" });
// Show the updated dependency tree
spawnSync("npm", ["ls"], { stdio: "inherit" });
} else {
console.log(`\nSK Tools version requested: ${skAzToolsPackage.dependencies["@slawek/sk-tools"] ?? "not found"}`);
}

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

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Hardcode variables.
SUBSCRIPTION_ID="c885a276-c882-483f-b216-42f73715161d"
ACCESS_TOKEN=$(sk-az-tools get-token graph)
# List Azure resource groups via Azure Resource Manager API
echo "Azure Resource Groups in subscription '$SUBSCRIPTION_ID':"
curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourcegroups?api-version=2021-04-01" |
jq '.value[] | {id, name, location}'
echo "---"
# Get current user ('me') via Microsoft Graph
echo "Current User (me):"
curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://graph.microsoft.com/v1.0/me" |
jq '{id, displayName, userPrincipalName}'
echo "---"
# List Azure DevOps projects in the given org
echo "Azure DevOps Projects in org 'skoszewski':"
curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://dev.azure.com/skoszewski/_apis/projects?api-version=7.1" |
jq '.value[] | {id, name, state}'

View File

@@ -1,50 +1,85 @@
// SPDX-License-Identifier: MIT
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
import {
DefaultAzureCredential,
ClientSecretCredential,
DeviceCodeCredential,
getBearerTokenProvider,
} from "@azure/identity";
import type { TokenCredential } from "@azure/core-auth";
import { SkAzureCredential } from "./sk-credential.ts";
type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode";
import { translateResourceNamesToScopes } from "./index.ts";
type CredentialOptions = {
tenantId?: string;
clientId?: string;
clientSecret?: string;
};
type CredentialType =
| "d"
| "default"
| "cs"
| "clientSecret"
| "dc"
| "deviceCode"
| "sk"
| "skCredential";
export async function getCredential(
export function getCredential(
credentialType: CredentialType,
options: CredentialOptions,
): Promise<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> {
tenantId?: string,
clientId?: string,
clientSecret?: string,
): TokenCredential {
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,
);
return new ClientSecretCredential(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);
},
});
case "sk":
case "skCredential":
if (!tenantId || !clientId) {
throw new Error(
"tenantId and clientId are required for SkAzureCredential",
);
}
return new SkAzureCredential(tenantId, clientId);
default:
throw new Error(`Unsupported credential type: ${credentialType}`);
}
}
export async function getTokenUsingAzureIdentity(
tenantId: string,
clientId: string,
resources: string[],
): Promise<string> {
const scopes = translateResourceNamesToScopes(resources);
const credential = getCredential("default", tenantId, clientId);
const getBearerToken = getBearerTokenProvider(credential, scopes);
const accessToken = await getBearerToken();
if (!accessToken) {
throw new Error("Failed to acquire access token with Azure Identity.");
}
return accessToken;
}

View File

@@ -6,12 +6,76 @@
* This module provides authentication functionalities for Azure services.
*/
export { getCredential } from "./client-auth.ts";
import { getTokenUsingMsal } from "./pca-auth.ts";
import { getTokenUsingAzureIdentity } from "./client-auth.ts";
import { loadAuthConfig, loadConfig } from "../index.ts";
import { SkAzureCredential } from "./sk-credential.ts";
import { DefaultAzureCredential } from "@azure/identity";
import type { TokenCredential } from "@azure/core-auth";
// Reexporting functions and types from submodules
export {
loginInteractive,
loginDeviceCode,
login,
logout,
parseResources,
acquireResourceTokenFromLogin,
} from "./pca-auth.ts";
export { getCredential } from "./client-auth.ts";
export const RESOURCE_SCOPE_BY_NAME = {
graph: "https://graph.microsoft.com/.default",
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
azurerm: "https://management.azure.com/.default",
openai: "https://cognitiveservices.azure.com/.default",
} as const;
export type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
export const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "azurerm"];
// A helper function to translate short resource names to their corresponding scopes
export function translateResourceNamesToScopes(resourceNames: string[]): string[] {
return resourceNames.map((name) => RESOURCE_SCOPE_BY_NAME[name as ResourceName]);
}
export function supportedResourceNames(): ResourceName[] {
return Object.keys(RESOURCE_SCOPE_BY_NAME) as ResourceName[];
}
// Generic utility functions
export type AuthMode = "azure-identity" | "msal";
export async function getTokenCredential(
tenantId?: string,
clientId?: string,
): Promise<TokenCredential> {
const config = await loadConfig();
if (config.authMode === "azure-identity") {
return new DefaultAzureCredential();
}
const authConfig = await loadAuthConfig("public-config");
return new SkAzureCredential(
tenantId || authConfig.tenantId,
clientId || authConfig.clientId,
);
}
export async function getAccessToken(
tenantId: string,
clientId: string,
resources: string[]
): Promise<string> {
const config = await loadConfig();
if (config.authMode === "msal") {
const result = await getTokenUsingMsal(tenantId, clientId, resources);
if (!result?.accessToken) {
throw new Error("Failed to acquire access token with MSAL.");
}
return result.accessToken;
} else {
return getTokenUsingAzureIdentity(tenantId, clientId, resources);
}
}

View File

@@ -2,121 +2,52 @@
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 { getConfig, getConfigDir } from "@slawek/sk-tools";
import type {
AccountInfo,
AuthenticationResult,
ICachePlugin,
TokenCacheContext,
} from "@azure/msal-node";
import os from "node:os";
const RESOURCE_SCOPE_BY_NAME = {
graph: "https://graph.microsoft.com/.default",
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
arm: "https://management.azure.com/.default",
} as const;
import type { ResourceName } from "../azure/index.ts";
import { RESOURCE_SCOPE_BY_NAME, DEFAULT_RESOURCES } from "../azure/index.ts";
import { translateResourceNamesToScopes } from "./index.ts";
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 SESSION_STATE_NAME = "session-state";
type SessionState = {
activeAccountUpn: string | null;
};
type BrowserOptions = {
browser?: string;
browserProfile?: string;
};
type LoginInteractiveOptions = {
tenantId?: string;
clientId?: string;
scopes: string[];
showAuthUrlOnly?: boolean;
browser?: string;
browserProfile?: string;
};
type LoginDeviceCodeOptions = {
tenantId?: string;
clientId?: string;
scopes: string[];
};
type LoginOptions = {
tenantId?: string;
clientId?: string;
resourcesCsv?: string;
useDeviceCode?: boolean;
noBrowser?: boolean;
browser?: string;
browserProfile?: string;
};
type AcquireResourceTokenOptions = {
tenantId?: string;
clientId?: string;
resource?: string;
};
type LogoutOptions = {
tenantId?: string;
clientId?: string;
clearAll?: boolean;
userPrincipalName?: string;
};
function getCacheRoot(): string {
const isWindows = process.platform === "win32";
const userRoot = isWindows
? process.env.LOCALAPPDATA || os.homedir()
: os.homedir();
return isWindows
? path.join(userRoot, "sk-az-tools")
: path.join(userRoot, ".config", "sk-az-tools");
}
function getSessionFilePath(): string {
return path.join(getCacheRoot(), "login-session.json");
}
async function readSessionState(): Promise<SessionState> {
try {
const sessionJson = await readFile(getSessionFilePath(), "utf8");
const parsed = JSON.parse(sessionJson) as { activeAccountUpn?: unknown };
return {
activeAccountUpn:
typeof parsed?.activeAccountUpn === "string"
? parsed.activeAccountUpn
: null,
};
} catch (err) {
if ((err as { code?: string } | null)?.code === "ENOENT") {
return { activeAccountUpn: null };
}
throw err;
}
const parsed = (await getConfig("sk-az-tools", SESSION_STATE_NAME)) as { activeAccountUpn?: unknown };
return {
activeAccountUpn:
typeof parsed?.activeAccountUpn === "string"
? parsed.activeAccountUpn
: null,
};
}
async function writeSessionState(state: SessionState): Promise<void> {
const sessionPath = getSessionFilePath();
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_NAME}.json`);
await mkdir(path.dirname(sessionPath), { recursive: true });
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
}
async function clearSessionState(): Promise<void> {
try {
await unlink(getSessionFilePath());
const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_NAME}.json`);
await unlink(sessionPath);
} catch (err) {
if ((err as { code?: string } | null)?.code !== "ENOENT") {
throw err;
@@ -124,10 +55,6 @@ async function clearSessionState(): Promise<void> {
}
}
function normalizeUpn(upn: unknown): string {
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
}
function writeStderr(message: string): void {
process.stderr.write(`${message}\n`);
}
@@ -165,7 +92,7 @@ function getBrowserKeyword(browser?: string): string {
return keyword.toLowerCase();
}
function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters<typeof open>[1] {
function getBrowserOpenOptions(browser?: string, browserProfile?: string): Parameters<typeof open>[1] {
const browserName = getBrowserAppName(browser);
if (!browserProfile || browserProfile.trim() === "") {
@@ -177,24 +104,24 @@ function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Par
const browserKeyword = getBrowserKeyword(browser);
if (!CHROMIUM_BROWSERS.has(browserKeyword)) {
throw new Error(
"--browser-profile is supported only with --browser edge|chrome|brave",
"--browser-profile is supported only with --browser-name edge|chrome|brave",
);
}
if (!browserName) {
throw new Error("--browser-profile requires --browser");
throw new Error("--browser-profile requires --browser-name");
}
return {
wait: false,
app: {
name: browserName,
arguments: [`--profile-directory=${browserProfile.trim()}`],
arguments: [`--profile-directory=${browserProfile.trim()}`],
},
};
}
function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): void {
function validateBrowserOptions(browser?: string, browserProfile?: string): void {
if (browser && browser.trim() !== "") {
getBrowserAppName(browser);
}
@@ -203,19 +130,18 @@ function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): vo
const browserKeyword = getBrowserKeyword(browser);
if (!CHROMIUM_BROWSERS.has(browserKeyword)) {
throw new Error(
"--browser-profile is supported only with --browser edge|chrome|brave",
"--browser-profile is supported only with --browser-name edge|chrome|brave",
);
}
}
}
export function parseResources(resourcesCsv?: string): ResourceName[] {
if (!resourcesCsv || resourcesCsv.trim() === "") {
export function parseResources(resourcesInput?: string[]): ResourceName[] {
if (!resourcesInput || resourcesInput.length === 0) {
return [...DEFAULT_RESOURCES];
}
const resources = resourcesCsv
.split(",")
const resources = resourcesInput
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
@@ -246,8 +172,8 @@ function fileCachePlugin(cachePath: string): ICachePlugin {
};
}
async function createPca({ tenantId, clientId }: { tenantId: string; clientId: string }): Promise<PublicClientApplication> {
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: ICachePlugin;
try {
@@ -281,15 +207,11 @@ async function createPca({ tenantId, clientId }: { tenantId: string; clientId: s
});
}
async function acquireTokenWithCache({
pca,
scopes,
account,
}: {
pca: PublicClientApplication;
scopes: string[];
account?: AccountInfo | null;
}): Promise<AuthenticationResult | null> {
async function acquireTokenWithCache(
pca: PublicClientApplication,
scopes: string[],
account?: AccountInfo | null,
): Promise<AuthenticationResult | null> {
if (account) {
try {
return await pca.acquireTokenSilent({
@@ -316,43 +238,40 @@ async function acquireTokenWithCache({
return null;
}
async function findAccountByUpn({
pca,
upn,
}: {
pca: PublicClientApplication;
upn: string | null;
}): Promise<AccountInfo | null> {
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,
}: LoginInteractiveOptions): Promise<AuthenticationResult | null> {
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) {
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 pca.acquireTokenInteractive({
@@ -362,7 +281,7 @@ export async function loginInteractive({
writeStderr(`Visit:\n${url}`);
return;
}
const options = getBrowserOpenOptions({ browser, browserProfile });
const options = getBrowserOpenOptions(browser, browserProfile);
await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`);
});
@@ -370,16 +289,20 @@ export async function loginInteractive({
});
}
export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise<AuthenticationResult | null> {
export async function loginDeviceCode(
tenantId: string,
clientId: string,
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) {
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 pca.acquireTokenByDeviceCode({
@@ -390,15 +313,15 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDevic
});
}
export async function login({
tenantId,
clientId,
resourcesCsv,
export async function login(
tenantId: string,
clientId: string,
resourcesInput?: string[],
useDeviceCode = false,
noBrowser = false,
browser,
browserProfile,
}: LoginOptions): Promise<{
browser?: string,
browserProfile?: string,
): Promise<{
accountUpn: string | null;
resources: Array<{ resource: string; expiresOn: string | null }>;
flow: "device-code" | "interactive";
@@ -406,51 +329,46 @@ export async function login({
}> {
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 resources = parseResources(resourcesInput);
const scopes = translateResourceNamesToScopes(resources) as string[];
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: 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, scopes, selectedAccount);
if (!token) {
if (useDeviceCode) {
token = await pca.acquireTokenByDeviceCode({
scopes: scope,
deviceCodeCallback: (response) => {
writeStderr(response.message);
},
});
} else {
token = await pca.acquireTokenInteractive({
scopes: scope,
openBrowser: async (url: string) => {
if (noBrowser) {
writeStderr(`Visit:\n${url}`);
return;
}
const options = getBrowserOpenOptions({ browser, browserProfile });
await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`);
});
},
});
}
if (token?.account) {
selectedAccount = token.account;
}
if (!token) {
if (useDeviceCode) {
token = await pca.acquireTokenByDeviceCode({
scopes: scopes,
deviceCodeCallback: (response) => {
writeStderr(response.message);
},
});
} else {
token = await pca.acquireTokenInteractive({
scopes: scopes,
openBrowser: async (url: string) => {
if (noBrowser) {
writeStderr(`Visit:\n${url}`);
return;
}
const options = getBrowserOpenOptions(browser, browserProfile);
await open(url, options).catch(() => {
writeStderr(`Visit:\n${url}`);
});
},
});
}
if (token?.account) {
@@ -458,11 +376,16 @@ export async function login({
}
results.push({
resource,
resource: resources.join(","),
expiresOn: token?.expiresOn?.toISOString?.() ?? null,
});
}
if (!selectedAccount) {
const accounts = await pca.getTokenCache().getAllAccounts();
selectedAccount = accounts[0] ?? null;
}
const activeAccountUpn = selectedAccount?.username ?? null;
if (activeAccountUpn) {
await writeSessionState({ activeAccountUpn });
@@ -476,55 +399,49 @@ export async function login({
};
}
export async function acquireResourceTokenFromLogin({
tenantId,
clientId,
resource,
}: AcquireResourceTokenOptions): Promise<AuthenticationResult | null> {
export async function getTokenUsingMsal(
tenantId: string,
clientId: string,
resources: 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");
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];
if (!resources || resources.length === 0) throw new Error("resources are required");
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);
}
// Convert short names of scopes to full resource scopes
const scopes = resources.map((res) => RESOURCE_SCOPE_BY_NAME[res as ResourceName] || res);
try {
return await pca.acquireTokenSilent({
account,
scopes: [scope],
scopes,
});
} catch {
throw new Error(LOGIN_REQUIRED_MESSAGE);
}
}
export async function logout({
tenantId,
clientId,
export async function logout(
tenantId: string,
clientId: string,
clearAll = false,
userPrincipalName,
}: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> {
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();
@@ -540,9 +457,10 @@ export async function logout({
};
}
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) {

View File

@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import { getTokenUsingMsal } from "./pca-auth.ts";
export class SkAzureCredential implements TokenCredential {
constructor(
private tenantId: string,
private clientId: string,
) {}
async getToken(
scopes: string | string[],
_options?: GetTokenOptions,
): Promise<AccessToken | null> {
const resources = Array.isArray(scopes) ? scopes : [scopes];
const result = await getTokenUsingMsal(this.tenantId, this.clientId, resources);
if (!result?.accessToken) {
return null;
}
return {
token: result.accessToken,
expiresOnTimestamp: result.expiresOn
? result.expiresOn.getTime()
: Date.now() + 55 * 60 * 1000,
};
}
}

View File

@@ -1,156 +1,141 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import { parseArgs } from "node:util";
import { Argument, Command, Option } from "commander";
import { renderCliOutput } from "@slawek/sk-tools";
import { supportedResourceNames, ResourceName } from "./azure/index.ts";
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";
import {
omitPermissionGuidColumns,
} from "./cli/permission-utils.ts";
// Commands
import { runGetTokenCommand } from "./cli/commands/get-token.ts";
import { runListAppGrantsCommand } from "./cli/commands/list-app-grants.ts";
import { runListAppPermissionsCommand } from "./cli/commands/list-app-permissions.ts";
import { runListAppsCommand } from "./cli/commands/list-apps.ts";
import { runListResourcePermissionsCommand } from "./cli/commands/list-resource-permissions.ts";
import { runLoginCommand } from "./cli/commands/login.ts";
import { runLogoutCommand } from "./cli/commands/logout.ts";
import { runRestCommand } from "./cli/commands/rest.ts";
import { runTestCommand } from "./cli/commands/test-command.ts";
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()}`;
}
}
import pkg from "../package.json" with { type: "json" };
const { version: packageVersion } = pkg;
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 skAzTools = new Command();
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,
});
skAzTools
.name("sk-az-tools")
.description("A collection of tools for Azure and Microsoft Entra management")
.version(packageVersion)
.option("-q, --query <jmespath>", "JMESPath query to filter output")
.option("-C, --columns <definition>", "Column tokens: col (raw), col: (auto), col:Label (custom), exact via = prefix")
.addOption(new Option("-o, --output <format>", "Output format: table|t|alignedtable|at|prettytable|pt|tsv")
.choices(["table", "t", "alignedtable", "at", "prettytable", "pt", "tsv"])
);
const typedValues = values as CliValues;
skAzTools
.command("login")
.description("Authenticate selected resources")
.addArgument(
new Argument("[resource...]", "Resources: graph|devops|azurerm")
.choices(supportedResourceNames())
.default(["azurerm"]),
)
.option("--use-device-code", "Use device code flow")
.option("--no-browser", "Do not launch browser")
.option("--browser-name <name>", "Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox")
.option("--browser-profile <name>", "Chromium profile name")
.action(runLoginCommand);
if (typedValues.help) {
console.log(usageCommand(command));
process.exit(0);
}
skAzTools
.command("logout")
.description("Sign out and clear login state")
.option("--all", "Clear login state and remove all cached accounts")
.action(runLogoutCommand);
const outputFormat = normalizeOutputFormat(typedValues.output);
const result = await runCommand(command, typedValues);
const filtered = outputFiltered(result, typedValues.query);
const output = command === "list-app-permissions" && typedValues.short
? omitPermissionGuidColumns(filtered)
: filtered;
const headerSpec = command === "rest"
? parseHeaderSpec(undefined)
: parseHeaderSpec(typedValues.header);
skAzTools
.command("get-token")
.description("Get an access token for a resource or resources.")
.addArgument(new Argument("<type>", "Token type.").choices(supportedResourceNames()))
.action(runGetTokenCommand);
renderOutput(outputFormat, headerSpec, output);
skAzTools
.command("rest")
.description("Call REST API endpoint")
.argument("<url>", "Full URL to call")
.addOption(new Option("-X, --method <httpMethod>", "HTTP method")
.choices(["GET", "POST", "PUT", "PATCH", "DELETE"]))
.option("-H, --header <name: value>", "Extra request header")
.addHelpText("after", `
Authorization is added automatically for:
management.azure.com Uses azurerm token
dev.azure.com Uses devops token
graph.microsoft.com Uses graph token
cognitiveservices.azure.com Uses openai token
*.openai.azure.com Uses openai token`)
.action(async (url, options, command) => {
const output = await runRestCommand(url, options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-apps")
.description("List Entra applications")
.option("-n, --display-name <name>", "Get app by display name")
.option("-i, --app-id <id>", "Get app by id")
.option("-f, --filter <pattern>", "Filter display name glob")
.action(async (options, command) => {
const output = await runListAppsCommand(options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-app-permissions")
.description("List required permissions for an app")
.option("-i, --app-id <appId>", "Application (client) ID")
.option("-r, --resolve", "Resolve permission GUIDs to human-readable values")
.option("-s, --short", "Makes output more compact")
.option("-f, --filter <glob>", "Filter by permission name glob")
.action(async (options, command) => {
const output = await runListAppPermissionsCommand(options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-app-grants")
.description("List OAuth2 grants for an app")
.option("-i, --app-id <appId>", "Application (client) ID")
.action(async (options, command) => {
const output = await runListAppGrantsCommand(options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
skAzTools
.command("list-resource-permissions")
.description("List available permissions for a resource app")
.option("-i, --app-id <appId>", "Resource app ID")
.option("-n, --display-name <name>", "Resource app display name")
.option("-f, --filter <glob>", "Filter by permission name glob")
.action(async (options, command) => {
const output = await runListResourcePermissionsCommand(options);
const allOptions = command.optsWithGlobals();
renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns);
});
// Hidden test command for development purposes
skAzTools
.command("test", { hidden: true })
.description("Test command for development")
.action(runTestCommand);
await skAzTools.parseAsync();
}
main().catch((err: unknown) => {
const error = err as Error;
console.error(`Error: ${error.message}`);
console.error(usage());
process.exit(1);
});

View File

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

View File

@@ -1,57 +1,21 @@
// SPDX-License-Identifier: MIT
import { acquireResourceTokenFromLogin } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadPublicConfig } from "../../index.ts";
import { RESOURCE_SCOPE_BY_NAME, ResourceName, supportedResourceNames, getTokenCredential } from "../../azure/index.ts";
import type { CommandValues } from "./types.ts";
export async function runGetTokenCommand(
type: ResourceName,
): Promise<void> {
if (!type || !supportedResourceNames().includes(type)) {
throw new Error(`Token type is required for get-token (allowed: ${supportedResourceNames().join(", ")})`);
}
export function usageGetToken(): string {
return `Usage: sk-az-tools get-token --type|-t <azurerm|devops> [global options]
const credential = await getTokenCredential();
Options:
--type, -t <value> Token type: azurerm|devops`;
}
export async function runGetTokenCommand(values: CommandValues): Promise<unknown> {
const tokenType = (values.type ?? "").toString().trim().toLowerCase();
if (!tokenType) {
throw new Error("--type is required for get-token (allowed: azurerm, devops)");
}
const config = await loadPublicConfig();
if (!config.tenantId) {
throw new Error("tenantId is required");
}
if (!config.clientId) {
throw new Error("clientId is required");
}
if (tokenType === "azurerm") {
const result = await acquireResourceTokenFromLogin({
tenantId: config.tenantId,
clientId: config.clientId,
resource: "arm",
});
const accessToken = result?.accessToken;
if (!accessToken) {
throw new Error("Failed to obtain AzureRM token");
}
return {
tokenType,
accessToken,
};
}
if (tokenType === "devops") {
const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId);
return {
tokenType,
accessToken,
};
}
throw new Error(`Invalid --type '${values.type}'. Allowed: azurerm, devops`);
const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[type]);
if (!accessToken) {
throw new Error("Failed to obtain access token.");
}
// Output only the token string for easy consumption in scripts
console.log(accessToken.token);
}

View File

@@ -1,22 +1,17 @@
// SPDX-License-Identifier: MIT
import { listAppGrants } from "../../graph/app.ts";
import { getGraphClient } from "../../graph/index.ts";
import { getGraphClientFromPublicConfig } from "./shared.ts";
import type { CommandValues } from "./types.ts";
type ListAppGrantsOptions = {
appId?: string;
};
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"]) {
export async function runListAppGrantsCommand(options: ListAppGrantsOptions): Promise<unknown> {
if (!options.appId) {
throw new Error("--app-id is required for list-app-grants");
}
const { client } = await getGraphClientFromPublicConfig();
return listAppGrants(client, values["app-id"]);
const client = await getGraphClient();
return listAppGrants(client, options.appId);
}

View File

@@ -2,30 +2,47 @@
import { listAppPermissions, listAppPermissionsResolved } from "../../graph/app.ts";
import { filterByPermissionName, getGraphClientFromPublicConfig } from "./shared.ts";
import type { CommandValues } from "./types.ts";
import { filterByPermissionName } from "./shared.ts";
import { getGraphClient } from "../../graph/index.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]
type ListAppPermissionsOptions = {
appId?: string;
resolve?: boolean;
short?: boolean;
filter?: string;
};
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`;
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export async function runListAppPermissionsCommand(values: CommandValues): Promise<unknown> {
if (!values["app-id"]) {
function omitColumns(input: unknown, names: string[]): unknown {
if (!Array.isArray(input) || !input.every(isRecord)) {
return input;
}
const namesSet = new Set(names);
return input.map((record) =>
Object.fromEntries(
Object.entries(record).filter(([key]) => !namesSet.has(key)),
)
);
}
export async function runListAppPermissionsCommand(options: ListAppPermissionsOptions): Promise<unknown> {
if (!options.appId) {
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);
const client = await getGraphClient();
let result: unknown = options.resolve || options.filter
? await listAppPermissionsResolved(client, options.appId)
: await listAppPermissions(client, options.appId);
if (options.short) {
result = omitColumns(result, ["resourceAppId", "permissionId"]);
}
if (options.filter) {
result = filterByPermissionName(result as Array<Record<string, unknown>>, options.filter);
}
return result;
}

View File

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

View File

@@ -1,34 +1,31 @@
// SPDX-License-Identifier: MIT
import { listResourcePermissions } from "../../graph/app.ts";
import { getGraphClient } from "../../graph/index.ts";
import { filterByPermissionName } from "./shared.ts";
import { filterByPermissionName, getGraphClientFromPublicConfig } from "./shared.ts";
import type { CommandValues } from "./types.ts";
type ListResourcePermissionsOptions = {
appId?: string;
displayName?: string;
filter?: string;
};
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"]) {
export async function runListResourcePermissionsCommand(options: ListResourcePermissionsOptions): Promise<unknown> {
if (!options.appId && !options.displayName) {
throw new Error("--app-id or --display-name is required for list-resource-permissions");
}
if (values["app-id"] && values["display-name"]) {
if (options.appId && options.displayName) {
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);
const client = await getGraphClient();
let result = await listResourcePermissions(
client,
options.appId,
options.displayName,
);
if (options.filter) {
result = filterByPermissionName(result, options.filter);
}
return result;
}

View File

@@ -1,30 +1,35 @@
// SPDX-License-Identifier: MIT
import { login } from "../../azure/index.ts";
import { loadPublicConfig } from "../../index.ts";
import type { ResourceName } from "../../azure/index.ts";
import { loadAuthConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts";
type LoginOptions = {
useDeviceCode?: boolean;
noBrowser?: boolean;
browserName?: string;
browserProfile?: string;
};
export function usageLogin(): string {
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]
type LoginResult = {
accountUpn: string | null;
resources: Array<{ resource: string; expiresOn: string | null }>;
flow: "device-code" | "interactive";
browserLaunchAttempted: boolean;
};
Options:
--resources <csv> Comma-separated resources: graph,devops,arm (default: all)
--use-device-code Use device code flow instead of interactive flow
--no-browser Do not launch browser; print interactive URL to stderr
--browser <name> Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox
--browser-profile <name> Chromium profile name (e.g. Default, "Profile 1")`;
}
export async function runLoginCommand(values: CommandValues): Promise<unknown> {
const config = await loadPublicConfig();
return login({
tenantId: config.tenantId,
clientId: config.clientId,
resourcesCsv: values.resources,
useDeviceCode: Boolean(values["use-device-code"]),
noBrowser: Boolean(values["no-browser"]),
browser: values.browser,
browserProfile: values["browser-profile"],
});
export async function runLoginCommand(resources: ResourceName[], options: LoginOptions): Promise<void> {
const config = await loadAuthConfig("public-config");
const result = await login(
config.tenantId,
config.clientId,
resources,
Boolean(options.useDeviceCode),
Boolean(options.noBrowser),
options.browserName,
options.browserProfile,
) as LoginResult;
console.log(`Logged in as ${result.accountUpn ?? "<unknown>"} using ${result.flow} flow for resources: ${resources.join(",")}`);
}

View File

@@ -1,22 +1,34 @@
// SPDX-License-Identifier: MIT
import { logout } from "../../azure/index.ts";
import { loadPublicConfig } from "../../index.ts";
import { loadAuthConfig } from "../../index.ts";
import type { CommandValues } from "./types.ts";
type LogoutOptions = {
all?: boolean;
};
export function usageLogout(): string {
return `Usage: sk-az-tools logout [--all] [global options]
type LogoutResult = {
clearedAll: boolean;
signedOut: string[];
};
Options:
--all Clear login state and remove all cached accounts`;
}
export async function runLogoutCommand(values: CommandValues): Promise<unknown> {
const config = await loadPublicConfig();
return logout({
tenantId: config.tenantId,
clientId: config.clientId,
clearAll: Boolean(values.all),
});
export async function runLogoutCommand(options: LogoutOptions): Promise<void> {
const config = await loadAuthConfig("public-config");
const result = await logout(config.tenantId, config.clientId, Boolean(options.all)) as LogoutResult;
if (result.signedOut.length === 0) {
console.log(
result.clearedAll
? "Cleared all cached accounts."
: "No active account to sign out.",
);
return;
}
if (result.clearedAll) {
console.log(`Cleared all cached accounts: ${result.signedOut.join(", ")}`);
return;
}
console.log(`Signed out: ${result.signedOut.join(", ")}`);
}

View File

@@ -1,25 +1,10 @@
// SPDX-License-Identifier: MIT
import { acquireResourceTokenFromLogin } from "../../azure/index.ts";
import { getDevOpsApiToken } from "../../devops/index.ts";
import { loadPublicConfig } from "../../index.ts";
import { RESOURCE_SCOPE_BY_NAME, ResourceName, getTokenCredential } from "../../azure/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 {
function parseHeaderLine(
header?: string,
): { name: string; value: string } | null {
if (!header || header.trim() === "") {
return null;
}
@@ -48,54 +33,65 @@ function hasAuthorizationHeader(headers: Headers): boolean {
return false;
}
function resolveResourceNameForHost(host: string): ResourceName | null {
switch (host) {
case "management.azure.com":
return "azurerm";
case "dev.azure.com":
return "devops";
case "graph.microsoft.com":
return "graph";
case "cognitiveservices.azure.com":
return "openai";
default:
if (host.endsWith(".openai.azure.com")) {
return "openai";
}
return null;
}
}
async function getAutoAuthorizationHeader(url: URL): Promise<string | null> {
const host = url.hostname.toLowerCase();
if (host !== "management.azure.com" && host !== "dev.azure.com") {
const resourceName = resolveResourceNameForHost(host);
if (!resourceName) {
return null;
}
const config = await loadPublicConfig();
if (!config.tenantId) {
throw new Error("tenantId is required");
}
if (!config.clientId) {
throw new Error("clientId is required");
const credential = await getTokenCredential();
const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[resourceName]);
if (!accessToken?.token) {
throw new Error(`Failed to obtain ${resourceName} token`);
}
if (host === "management.azure.com") {
const result = await acquireResourceTokenFromLogin({
tenantId: config.tenantId,
clientId: config.clientId,
resource: "arm",
});
const accessToken = result?.accessToken;
if (!accessToken) {
throw new Error("Failed to obtain AzureRM token");
}
return `Bearer ${accessToken}`;
}
const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId);
return `Bearer ${accessToken}`;
return `Bearer ${accessToken.token}`;
}
export async function runRestCommand(values: CommandValues): Promise<unknown> {
const method = (values.method ?? "GET").toString().trim().toUpperCase() || "GET";
const urlValue = (values.url ?? "").toString().trim();
type httpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type restOptions = {
method?: httpMethod;
header?: string;
};
export async function runRestCommand(url: string, options: restOptions): Promise<unknown> {
const method = options.method || "GET";
const urlValue = (url ?? "").toString().trim();
if (!urlValue) {
throw new Error("--url is required for rest");
throw new Error("URL is required for rest");
}
let targetUrl: URL;
try {
targetUrl = new URL(urlValue);
} catch {
throw new Error(`Invalid --url '${urlValue}'`);
throw new Error(`Invalid URL '${urlValue}'`);
}
const headers = new Headers();
const customHeader = parseHeaderLine(values.header);
const customHeader = parseHeaderLine(options.header);
if (customHeader) {
headers.set(customHeader.name, customHeader.value);
}
@@ -113,9 +109,9 @@ export async function runRestCommand(values: CommandValues): Promise<unknown> {
});
const contentType = response.headers.get("content-type") ?? "";
let body: unknown;
let body: string;
if (contentType.toLowerCase().includes("application/json")) {
body = await response.json();
body = JSON.stringify(await response.json());
} else {
body = await response.text();
}

View File

@@ -2,9 +2,6 @@
import { minimatch } from "minimatch";
import { loadPublicConfig } from "../../index.ts";
import { getGraphClient } from "../../graph/auth.ts";
type PermissionRow = {
permissionValue?: string | null;
permissionDisplayName?: string | null;
@@ -26,11 +23,3 @@ export function filterByDisplayName<T extends DisplayNameRow>(rows: T[], pattern
minimatch(item.displayName ?? "", pattern, { nocase: true }),
);
}
export async function getGraphClientFromPublicConfig(): Promise<{ client: any }> {
const config = await loadPublicConfig();
return getGraphClient({
tenantId: config.tenantId,
clientId: config.clientId,
});
}

View File

@@ -0,0 +1,6 @@
// SPDX-License-Identifier: MIT
// Hidden test command for development purposes
export async function runTestCommand(): Promise<void> {
console.log("Test command executed.");
}

View File

@@ -1,19 +0,0 @@
// 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,14 +0,0 @@
// SPDX-License-Identifier: MIT
export function omitPermissionGuidColumns(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => omitPermissionGuidColumns(item));
}
if (!value || typeof value !== "object") {
return value;
}
const { resourceAppId, permissionId, ...rest } = value as Record<string, unknown>;
void resourceAppId;
void permissionId;
return rest;
}

View File

@@ -6,12 +6,7 @@ import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { spawnSync } from "node:child_process";
import { parseArgs } from "node:util";
type RunAzOptions = {
quiet?: boolean;
allowFailure?: boolean;
};
import { Command } from "commander";
type RunAzResult = {
status: number;
@@ -19,10 +14,10 @@ type RunAzResult = {
stderr: string;
};
function runAz(args: string[], options: RunAzOptions = {}): RunAzResult {
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"],
});
@@ -31,7 +26,7 @@ function runAz(args: string[], options: RunAzOptions = {}): RunAzResult {
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`,
);
@@ -45,50 +40,17 @@ function runAz(args: string[], options: RunAzOptions = {}): RunAzResult {
}
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`;
const program = new Command(path.basename(process.argv[1]));
program
.description("Create or update public client app and print config template")
.argument("<app-name>", "Application name")
.option("-c, --config <path>", "Write JSON config to file (optional)")
.allowExcessArguments(false)
.parse(process.argv);
let values: Record<string, string | boolean | undefined>;
let positionals: string[];
try {
({ values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
help: { type: "boolean", short: "h" },
config: { type: "string", short: "c" },
},
strict: true,
allowPositionals: true,
}));
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
console.error(usageText);
process.exit(1);
}
if (values.help) {
console.log(usageText);
process.exit(0);
}
if (positionals.length > 1) {
console.error(
"Error: Too many positional arguments. Only one app name positional argument is allowed.",
);
console.error(usageText);
process.exit(1);
}
const appName = positionals[0] || "";
const configPath = typeof values.config === "string" ? values.config : "";
if (!appName) {
console.error("Error: Application name is required.");
console.error(usageText);
process.exit(1);
}
const appName = program.args[0] as string;
const options = program.opts<{ config?: string }>();
const configPath = options.config ?? "";
let appId = runAz([
"ad",
@@ -198,7 +160,7 @@ Options:
"--enable-id-token-issuance",
"true",
],
{ quiet: true },
true,
);
} catch {
console.error(
@@ -210,14 +172,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(

View File

@@ -4,39 +4,27 @@
* A DevOps helpers module.
*/
import { loginInteractive } from "../azure/index.ts";
import { RESOURCE_SCOPE_BY_NAME, getTokenCredential } 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 type DevOpsClients = {
coreClient: Awaited<ReturnType<azdev.WebApi["getCoreApi"]>>;
gitClient: Awaited<ReturnType<azdev.WebApi["getGitApi"]>>;
};
export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise<string> {
const result = await loginInteractive({
tenantId,
clientId,
scopes: AZURE_DEVOPS_SCOPES,
}) as LoginInteractiveResult;
export async function getDevOpsClients(orgUrl: string, tenantId?: string, clientId?: string): Promise<DevOpsClients> {
return getTokenCredential(tenantId, clientId)
.then((credential) => credential.getToken(RESOURCE_SCOPE_BY_NAME.devops))
.then(async (accessToken) => {
if (!accessToken?.token) {
throw new Error("Failed to obtain Azure DevOps API token");
}
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 };
const connection = new azdev.WebApi(orgUrl, azdev.getBearerHandler(accessToken.token));
const [coreClient, gitClient] = await Promise.all([
connection.getCoreApi(),
connection.getGitApi(),
]);
return { coreClient, gitClient };
});
}

View File

@@ -6,11 +6,6 @@ type GraphResult<T = GraphObject> = {
value?: T[];
};
type AppQueryOptions = {
displayName?: string;
appId?: string;
};
type RequiredResourceAccessItem = {
type?: string;
id?: string;
@@ -38,11 +33,6 @@ type ServicePrincipal = {
appRoles?: GraphPermission[];
};
type ResourcePermissionsOptions = {
appId?: string;
displayName?: string;
};
export async function getApp(client: any, displayName: string): Promise<GraphObject | null> {
const result = await client
.api("/applications")
@@ -68,8 +58,11 @@ export async function deleteApp(client: any, appObjectId: string): Promise<void>
await client.api(`/applications/${appObjectId}`).delete();
}
export async function listApps(client: any, options: AppQueryOptions = {}): Promise<GraphObject[]> {
const { displayName, appId } = options;
export async function listApps(
client: any,
displayName?: string,
appId?: string,
): Promise<GraphObject[]> {
let request = client.api("/applications");
const filters: string[] = [];
@@ -219,8 +212,11 @@ export async function listAppGrants(client: any, appId: string): Promise<GraphOb
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
}
export async function listResourcePermissions(client: any, options: ResourcePermissionsOptions = {}): Promise<Array<Record<string, unknown>>> {
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");
}

View File

@@ -1,30 +0,0 @@
// SPDX-License-Identifier: MIT
import { Client } from "@microsoft/microsoft-graph-client";
import { acquireResourceTokenFromLogin } from "../azure/index.ts";
type GraphClientOptions = {
tenantId?: string;
clientId?: string;
};
type GraphApiToken = {
accessToken: string;
[key: string]: unknown;
};
export async function getGraphClient({ tenantId, clientId }: GraphClientOptions): Promise<{ graphApiToken: GraphApiToken; client: any }> {
const graphApiToken = await acquireResourceTokenFromLogin({
tenantId,
clientId,
resource: "graph",
}) as GraphApiToken;
const client = Client.init({
authProvider: (done) => {
done(null, graphApiToken.accessToken);
},
});
return { graphApiToken, client };
}

View File

@@ -1,5 +1,18 @@
// SPDX-License-Identifier: MIT
export * from "./auth.ts";
export * from "./app.ts";
export * from "./sp.ts";
import { Client } from "@microsoft/microsoft-graph-client";
import { RESOURCE_SCOPE_BY_NAME, getTokenCredential } from "../azure/index.ts";
export async function getGraphClient(): Promise<Client> {
return Client.init({
authProvider: (done) => {
void getTokenCredential()
.then((credential) => credential.getToken(RESOURCE_SCOPE_BY_NAME.graph))
.then((accessToken) => done(null, accessToken?.token ?? null))
.catch((err) => done(err as Error, null));
},
});
}

View File

@@ -1,58 +1,42 @@
// SPDX-License-Identifier: MIT
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { validate as validateUuid } from "uuid";
import { getConfig } from "@slawek/sk-tools";
import type { AuthConfig, Config } from "./types.ts";
type Config = {
tenantId?: string;
clientId?: string;
};
type ConfigCandidate = {
tenantId?: unknown;
clientId?: unknown;
};
export function getUserConfigDir(): string {
if (process.platform === "win32") {
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
}
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
}
async function loadConfig(configFileName: string): Promise<Config> {
if (typeof configFileName !== "string" || configFileName.trim() === "") {
export async function loadAuthConfig(configName: string): Promise<AuthConfig> {
if (configName.trim() === "") {
throw new Error(
'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".',
'Invalid config name. Expected a non-empty string like "public-config" or "confidential-config".',
);
}
const envConfig: Config = {
const envConfig = {
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) as ConfigCandidate)
.catch((err: unknown) => {
if ((err as { code?: string } | null)?.code === "ENOENT") {
return {} as ConfigCandidate;
}
throw err;
})
.then((json) => ({
tenantId: typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId,
clientId: typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId,
}));
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,
};
}
export function loadPublicConfig(): Promise<Config> {
return loadConfig("public-config.json");
}
export async function loadConfig(): Promise<Config> {
export function loadConfidentialConfig(): Promise<Config> {
return loadConfig("confidential-config.json");
const json = (await getConfig("sk-az-tools", "config")) as Record<string, unknown>;
return {
activeAccountUpn: typeof json.activeAccountUpn === "string" ? json.activeAccountUpn : undefined,
authMode: typeof json.authMode === "string" ? json.authMode : "msal"
};
}

11
src/types.ts Normal file
View File

@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
export type AuthConfig = {
tenantId: string;
clientId: string;
};
export type Config = {
activeAccountUpn: string | undefined;
authMode: string;
};