Migrated to TypeScript.
This commit is contained in:
12
.npmignore
12
.npmignore
@@ -1,3 +1,11 @@
|
|||||||
docs
|
src/
|
||||||
|
*.ts
|
||||||
|
tsconfig.json
|
||||||
|
scripts/
|
||||||
|
Dockerfile
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
artifacts/
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
docs
|
||||||
30
README.md
30
README.md
@@ -2,3 +2,33 @@
|
|||||||
|
|
||||||
This repository contains a collection of NodeJS modules that facilitate interaction with Azure and Graph authentication and management of selected Entra ID objects and Azure resources.
|
This repository contains a collection of NodeJS modules that facilitate interaction with Azure and Graph authentication and management of selected Entra ID objects and Azure resources.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @slawek/sk-az-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Build from TypeScript
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watch mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI smoke check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dist/cli.js --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
The package is published from compiled output in `dist/`. See `docs/PACKAGING.md` for the complete release workflow.
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +1,54 @@
|
|||||||
# Developing `hello-world` (ESM) Summary
|
# Packaging sk-az-tools
|
||||||
|
|
||||||
## Minimal Layout
|
## Build model
|
||||||
- `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`:
|
- Source lives in `src/` as TypeScript (`.ts`).
|
||||||
```json
|
- Runtime package is compiled to `dist/` using `npm run build`.
|
||||||
{
|
- Public package entrypoints (`exports` and `bin`) point to `dist/**`.
|
||||||
"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`:
|
## Package surface
|
||||||
```js
|
|
||||||
export function helloWorld() {
|
|
||||||
console.log("Hello World!!!");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sub-modules (Subpath Exports)
|
- `exports` defines what consumers can import.
|
||||||
- Expose sub-modules using explicit subpaths in `exports`.
|
- `files` controls what is shipped to npm.
|
||||||
- Keep public API small and intentional.
|
- Current shipping content is `dist`, `README.md`, and `LICENSE`.
|
||||||
|
|
||||||
Example:
|
## Development workflow
|
||||||
```json
|
|
||||||
{
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.js",
|
|
||||||
"./greetings": "./src/greetings.js",
|
|
||||||
"./callouts": "./src/callouts.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `exports` vs `files`
|
Build once:
|
||||||
- `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
|
```bash
|
||||||
npm link
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Consumer repo:
|
Build in watch mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm link hello-world
|
npm run build:watch
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Smoke check CLI output:
|
||||||
- If you build to `dist/`, run a watch build so the consumer sees updates.
|
|
||||||
- Unlink when done:
|
|
||||||
```bash
|
```bash
|
||||||
npm unlink hello-world
|
node dist/cli.js --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Distribution as a `.tgz` Artifact
|
## Publish checklist
|
||||||
- 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:
|
1. Run `npm run build` and ensure TypeScript compiles without errors.
|
||||||
```bash
|
2. Verify package content with `npm pack --dry-run`.
|
||||||
npm pack
|
3. Create artifact: `npm pack --pack-destination ./artifacts`.
|
||||||
npm install ./hello-world-1.0.0.tgz
|
4. Optionally install the artifact locally and validate CLI/imports.
|
||||||
```
|
|
||||||
|
## Tarball usage
|
||||||
|
|
||||||
|
Create package tarball:
|
||||||
|
|
||||||
Example with output directory:
|
|
||||||
```bash
|
```bash
|
||||||
npm pack --pack-destination ./artifacts
|
npm pack --pack-destination ./artifacts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install from tarball:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install ./artifacts/@slawek/sk-az-tools-<version>.tgz
|
||||||
|
```
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -2,6 +2,17 @@
|
|||||||
"name": "@slawek/sk-az-tools",
|
"name": "@slawek/sk-az-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"build": "npm run clean && tsc",
|
||||||
|
"build:watch": "tsc --watch",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
@@ -13,20 +24,26 @@
|
|||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
"azure-devops-node-api": "^15.1.2",
|
"azure-devops-node-api": "^15.1.2",
|
||||||
"jmespath": "^0.16.0",
|
"jmespath": "^0.16.0",
|
||||||
"minimatch": "^10.1.2"
|
"minimatch": "^10.1.2",
|
||||||
|
"open": "^10.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jmespath": "^0.15.2",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Sławomir Koszewski",
|
"name": "Sławomir Koszewski",
|
||||||
"email": "slawek@koszewscy.waw.pl"
|
"email": "slawek@koszewscy.waw.pl"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"sk-az-tools": "./src/cli.js"
|
"sk-az-tools": "./dist/cli.js"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.js",
|
".": "./dist/index.js",
|
||||||
"./azure": "./src/azure/index.js",
|
"./azure": "./dist/azure/index.js",
|
||||||
"./graph": "./src/graph/index.js",
|
"./graph": "./dist/graph/index.js",
|
||||||
"./devops": "./src/devops/index.js"
|
"./devops": "./dist/devops/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,18 @@
|
|||||||
|
|
||||||
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
|
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
|
||||||
|
|
||||||
export async function getCredential(credentialType, options) {
|
type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode";
|
||||||
|
|
||||||
|
type CredentialOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCredential(
|
||||||
|
credentialType: CredentialType,
|
||||||
|
options: CredentialOptions,
|
||||||
|
): Promise<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> {
|
||||||
switch (credentialType) {
|
switch (credentialType) {
|
||||||
case "d":
|
case "d":
|
||||||
case "default":
|
case "default":
|
||||||
1
src/azure/index.d.ts
vendored
1
src/azure/index.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
//
|
|
||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @module azure
|
* @module azure
|
||||||
*
|
*
|
||||||
* This module provides authentication functionalities for Azure services.
|
* This module provides authentication functionalities for Azure services.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { getCredential } from "./client-auth.js";
|
export { getCredential } from "./client-auth.ts";
|
||||||
export {
|
export {
|
||||||
loginInteractive,
|
loginInteractive,
|
||||||
loginDeviceCode,
|
loginDeviceCode,
|
||||||
@@ -15,4 +14,4 @@ export {
|
|||||||
logout,
|
logout,
|
||||||
parseResources,
|
parseResources,
|
||||||
acquireResourceTokenFromLogin,
|
acquireResourceTokenFromLogin,
|
||||||
} from "./pca-auth.js";
|
} from "./pca-auth.ts";
|
||||||
@@ -6,20 +6,76 @@ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { PublicClientApplication } from "@azure/msal-node";
|
import { PublicClientApplication } from "@azure/msal-node";
|
||||||
|
import type {
|
||||||
|
AccountInfo,
|
||||||
|
AuthenticationResult,
|
||||||
|
ICachePlugin,
|
||||||
|
TokenCacheContext,
|
||||||
|
} from "@azure/msal-node";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
const RESOURCE_SCOPE_BY_NAME = {
|
const RESOURCE_SCOPE_BY_NAME = {
|
||||||
graph: "https://graph.microsoft.com/.default",
|
graph: "https://graph.microsoft.com/.default",
|
||||||
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
|
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
|
||||||
arm: "https://management.azure.com/.default",
|
arm: "https://management.azure.com/.default",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
|
type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
|
||||||
|
|
||||||
|
const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"];
|
||||||
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
|
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
|
||||||
const BROWSER_KEYWORDS = Object.keys(apps).sort();
|
const BROWSER_KEYWORDS = Object.keys(apps).sort();
|
||||||
|
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
|
||||||
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
||||||
|
|
||||||
function getCacheRoot() {
|
type SessionState = {
|
||||||
|
activeAccountUpn: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserOptions = {
|
||||||
|
browser?: string;
|
||||||
|
browserProfile?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginInteractiveOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
scopes: string[];
|
||||||
|
showAuthUrlOnly?: boolean;
|
||||||
|
browser?: string;
|
||||||
|
browserProfile?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginDeviceCodeOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
resourcesCsv?: string;
|
||||||
|
useDeviceCode?: boolean;
|
||||||
|
noBrowser?: boolean;
|
||||||
|
browser?: string;
|
||||||
|
browserProfile?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AcquireResourceTokenOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
resource?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogoutOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clearAll?: boolean;
|
||||||
|
userPrincipalName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCacheRoot(): string {
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
const userRoot = isWindows
|
const userRoot = isWindows
|
||||||
? process.env.LOCALAPPDATA || os.homedir()
|
? process.env.LOCALAPPDATA || os.homedir()
|
||||||
@@ -30,14 +86,14 @@ function getCacheRoot() {
|
|||||||
: path.join(userRoot, ".config", "sk-az-tools");
|
: path.join(userRoot, ".config", "sk-az-tools");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionFilePath() {
|
function getSessionFilePath(): string {
|
||||||
return path.join(getCacheRoot(), "login-session.json");
|
return path.join(getCacheRoot(), "login-session.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSessionState() {
|
async function readSessionState(): Promise<SessionState> {
|
||||||
try {
|
try {
|
||||||
const sessionJson = await readFile(getSessionFilePath(), "utf8");
|
const sessionJson = await readFile(getSessionFilePath(), "utf8");
|
||||||
const parsed = JSON.parse(sessionJson);
|
const parsed = JSON.parse(sessionJson) as { activeAccountUpn?: unknown };
|
||||||
return {
|
return {
|
||||||
activeAccountUpn:
|
activeAccountUpn:
|
||||||
typeof parsed?.activeAccountUpn === "string"
|
typeof parsed?.activeAccountUpn === "string"
|
||||||
@@ -45,40 +101,40 @@ async function readSessionState() {
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "ENOENT") {
|
if ((err as { code?: string } | null)?.code === "ENOENT") {
|
||||||
return { activeAccountUpn: null };
|
return { activeAccountUpn: null };
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeSessionState(state) {
|
async function writeSessionState(state: SessionState): Promise<void> {
|
||||||
const sessionPath = getSessionFilePath();
|
const sessionPath = getSessionFilePath();
|
||||||
await mkdir(path.dirname(sessionPath), { recursive: true });
|
await mkdir(path.dirname(sessionPath), { recursive: true });
|
||||||
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
|
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearSessionState() {
|
async function clearSessionState(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await unlink(getSessionFilePath());
|
await unlink(getSessionFilePath());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code !== "ENOENT") {
|
if ((err as { code?: string } | null)?.code !== "ENOENT") {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUpn(upn) {
|
function normalizeUpn(upn: unknown): string {
|
||||||
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
|
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStderr(message) {
|
function writeStderr(message: string): void {
|
||||||
process.stderr.write(`${message}\n`);
|
process.stderr.write(`${message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrowserAppName(browser) {
|
function getBrowserAppName(browser?: string): string | readonly string[] | undefined {
|
||||||
if (!browser || browser.trim() === "") {
|
if (!browser || browser.trim() === "") {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyword = BROWSER_KEYWORDS.find(
|
const keyword = BROWSER_KEYWORDS.find(
|
||||||
@@ -90,10 +146,10 @@ function getBrowserAppName(browser) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apps[keyword];
|
return OPEN_APPS[keyword];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrowserKeyword(browser) {
|
function getBrowserKeyword(browser?: string): string {
|
||||||
if (!browser || browser.trim() === "") {
|
if (!browser || browser.trim() === "") {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -109,14 +165,13 @@ function getBrowserKeyword(browser) {
|
|||||||
return keyword.toLowerCase();
|
return keyword.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrowserOpenOptions({ browser, browserProfile }) {
|
function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters<typeof open>[1] {
|
||||||
const browserName = getBrowserAppName(browser);
|
const browserName = getBrowserAppName(browser);
|
||||||
const options = browserName
|
|
||||||
? { wait: false, app: { name: browserName } }
|
|
||||||
: { wait: false };
|
|
||||||
|
|
||||||
if (!browserProfile || browserProfile.trim() === "") {
|
if (!browserProfile || browserProfile.trim() === "") {
|
||||||
return options;
|
return browserName
|
||||||
|
? { wait: false, app: { name: browserName } }
|
||||||
|
: { wait: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserKeyword = getBrowserKeyword(browser);
|
const browserKeyword = getBrowserKeyword(browser);
|
||||||
@@ -126,11 +181,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`];
|
if (!browserName) {
|
||||||
return options;
|
throw new Error("--browser-profile requires --browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wait: false,
|
||||||
|
app: {
|
||||||
|
name: browserName,
|
||||||
|
arguments: [`--profile-directory=${browserProfile.trim()}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateBrowserOptions({ browser, browserProfile }) {
|
function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): void {
|
||||||
if (browser && browser.trim() !== "") {
|
if (browser && browser.trim() !== "") {
|
||||||
getBrowserAppName(browser);
|
getBrowserAppName(browser);
|
||||||
}
|
}
|
||||||
@@ -145,7 +209,7 @@ function validateBrowserOptions({ browser, browserProfile }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseResources(resourcesCsv) {
|
export function parseResources(resourcesCsv?: string): ResourceName[] {
|
||||||
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
||||||
return [...DEFAULT_RESOURCES];
|
return [...DEFAULT_RESOURCES];
|
||||||
}
|
}
|
||||||
@@ -156,24 +220,24 @@ export function parseResources(resourcesCsv) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const unique = [...new Set(resources)];
|
const unique = [...new Set(resources)];
|
||||||
const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]);
|
const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name));
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
|
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return unique;
|
return unique as ResourceName[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileCachePlugin(cachePath) {
|
function fileCachePlugin(cachePath: string): ICachePlugin {
|
||||||
return {
|
return {
|
||||||
beforeCacheAccess: async (ctx) => {
|
beforeCacheAccess: async (ctx: TokenCacheContext) => {
|
||||||
if (fs.existsSync(cachePath)) {
|
if (fs.existsSync(cachePath)) {
|
||||||
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
|
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
afterCacheAccess: async (ctx) => {
|
afterCacheAccess: async (ctx: TokenCacheContext) => {
|
||||||
if (!ctx.cacheHasChanged) return;
|
if (!ctx.cacheHasChanged) return;
|
||||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||||
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
||||||
@@ -182,10 +246,10 @@ function fileCachePlugin(cachePath) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPca({ tenantId, clientId }) {
|
async function createPca({ tenantId, clientId }: { tenantId: string; clientId: string }): Promise<PublicClientApplication> {
|
||||||
const cacheRoot = getCacheRoot();
|
const cacheRoot = getCacheRoot();
|
||||||
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
|
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
|
||||||
let cachePlugin;
|
let cachePlugin: ICachePlugin;
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
DataProtectionScope,
|
DataProtectionScope,
|
||||||
@@ -201,7 +265,7 @@ async function createPca({ tenantId, clientId }) {
|
|||||||
usePlaintextFileOnLinux: true,
|
usePlaintextFileOnLinux: true,
|
||||||
});
|
});
|
||||||
cachePlugin = new PersistenceCachePlugin(persistence);
|
cachePlugin = new PersistenceCachePlugin(persistence);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Fallback when msal-node-extensions/keytar/libsecret are unavailable.
|
// Fallback when msal-node-extensions/keytar/libsecret are unavailable.
|
||||||
cachePlugin = fileCachePlugin(cachePath);
|
cachePlugin = fileCachePlugin(cachePath);
|
||||||
}
|
}
|
||||||
@@ -217,7 +281,15 @@ async function createPca({ tenantId, clientId }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acquireTokenWithCache({ pca, scopes, account }) {
|
async function acquireTokenWithCache({
|
||||||
|
pca,
|
||||||
|
scopes,
|
||||||
|
account,
|
||||||
|
}: {
|
||||||
|
pca: PublicClientApplication;
|
||||||
|
scopes: string[];
|
||||||
|
account?: AccountInfo | null;
|
||||||
|
}): Promise<AuthenticationResult | null> {
|
||||||
if (account) {
|
if (account) {
|
||||||
try {
|
try {
|
||||||
return await pca.acquireTokenSilent({
|
return await pca.acquireTokenSilent({
|
||||||
@@ -244,7 +316,13 @@ async function acquireTokenWithCache({ pca, scopes, account }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findAccountByUpn({ pca, upn }) {
|
async function findAccountByUpn({
|
||||||
|
pca,
|
||||||
|
upn,
|
||||||
|
}: {
|
||||||
|
pca: PublicClientApplication;
|
||||||
|
upn: string | null;
|
||||||
|
}): Promise<AccountInfo | null> {
|
||||||
const normalized = normalizeUpn(upn);
|
const normalized = normalizeUpn(upn);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
@@ -264,11 +342,12 @@ export async function loginInteractive({
|
|||||||
showAuthUrlOnly = false,
|
showAuthUrlOnly = false,
|
||||||
browser,
|
browser,
|
||||||
browserProfile,
|
browserProfile,
|
||||||
}) {
|
}: LoginInteractiveOptions): Promise<AuthenticationResult | null> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||||
throw new Error("scopes[] is required");
|
throw new Error("scopes[] is required");
|
||||||
|
}
|
||||||
validateBrowserOptions({ browser, browserProfile });
|
validateBrowserOptions({ browser, browserProfile });
|
||||||
|
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca({ tenantId, clientId });
|
||||||
@@ -276,33 +355,34 @@ export async function loginInteractive({
|
|||||||
const cached = await acquireTokenWithCache({ pca, scopes });
|
const cached = await acquireTokenWithCache({ pca, scopes });
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
return await pca.acquireTokenInteractive({
|
return pca.acquireTokenInteractive({
|
||||||
scopes,
|
scopes,
|
||||||
openBrowser: async (url) => {
|
openBrowser: async (url: string) => {
|
||||||
if (showAuthUrlOnly) {
|
if (showAuthUrlOnly) {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||||
return open(url, options).catch(() => {
|
await open(url, options).catch(() => {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginDeviceCode({ tenantId, clientId, scopes }) {
|
export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise<AuthenticationResult | null> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||||
throw new Error("scopes[] is required");
|
throw new Error("scopes[] is required");
|
||||||
|
}
|
||||||
|
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca({ tenantId, clientId });
|
||||||
|
|
||||||
const cached = await acquireTokenWithCache({ pca, scopes });
|
const cached = await acquireTokenWithCache({ pca, scopes });
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
return await pca.acquireTokenByDeviceCode({
|
return pca.acquireTokenByDeviceCode({
|
||||||
scopes,
|
scopes,
|
||||||
deviceCodeCallback: (response) => {
|
deviceCodeCallback: (response) => {
|
||||||
writeStderr(response.message);
|
writeStderr(response.message);
|
||||||
@@ -318,7 +398,12 @@ export async function login({
|
|||||||
noBrowser = false,
|
noBrowser = false,
|
||||||
browser,
|
browser,
|
||||||
browserProfile,
|
browserProfile,
|
||||||
}) {
|
}: LoginOptions): Promise<{
|
||||||
|
accountUpn: string | null;
|
||||||
|
resources: Array<{ resource: string; expiresOn: string | null }>;
|
||||||
|
flow: "device-code" | "interactive";
|
||||||
|
browserLaunchAttempted: boolean;
|
||||||
|
}> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
validateBrowserOptions({ browser, browserProfile });
|
validateBrowserOptions({ browser, browserProfile });
|
||||||
@@ -332,8 +417,8 @@ export async function login({
|
|||||||
upn: session.activeAccountUpn,
|
upn: session.activeAccountUpn,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = [];
|
const results: Array<{ resource: string; expiresOn: string | null }> = [];
|
||||||
let selectedAccount = preferredAccount;
|
let selectedAccount: AccountInfo | null = preferredAccount;
|
||||||
for (let index = 0; index < resources.length; index += 1) {
|
for (let index = 0; index < resources.length; index += 1) {
|
||||||
const resource = resources[index];
|
const resource = resources[index];
|
||||||
const scope = [scopes[index]];
|
const scope = [scopes[index]];
|
||||||
@@ -354,13 +439,13 @@ export async function login({
|
|||||||
} else {
|
} else {
|
||||||
token = await pca.acquireTokenInteractive({
|
token = await pca.acquireTokenInteractive({
|
||||||
scopes: scope,
|
scopes: scope,
|
||||||
openBrowser: async (url) => {
|
openBrowser: async (url: string) => {
|
||||||
if (noBrowser) {
|
if (noBrowser) {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||||
return open(url, options).catch(() => {
|
await open(url, options).catch(() => {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -395,16 +480,17 @@ export async function acquireResourceTokenFromLogin({
|
|||||||
tenantId,
|
tenantId,
|
||||||
clientId,
|
clientId,
|
||||||
resource,
|
resource,
|
||||||
}) {
|
}: AcquireResourceTokenOptions): Promise<AuthenticationResult | null> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
if (!resource) throw new Error("resource is required");
|
if (!resource) throw new Error("resource is required");
|
||||||
|
|
||||||
const scope = RESOURCE_SCOPE_BY_NAME[resource];
|
if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) {
|
||||||
if (!scope) {
|
|
||||||
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
|
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName];
|
||||||
|
|
||||||
const session = await readSessionState();
|
const session = await readSessionState();
|
||||||
if (!session.activeAccountUpn) {
|
if (!session.activeAccountUpn) {
|
||||||
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
||||||
@@ -434,7 +520,7 @@ export async function logout({
|
|||||||
clientId,
|
clientId,
|
||||||
clearAll = false,
|
clearAll = false,
|
||||||
userPrincipalName,
|
userPrincipalName,
|
||||||
}) {
|
}: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
|
|
||||||
@@ -450,7 +536,7 @@ export async function logout({
|
|||||||
await clearSessionState();
|
await clearSessionState();
|
||||||
return {
|
return {
|
||||||
clearedAll: true,
|
clearedAll: true,
|
||||||
signedOut: accounts.map((account) => account.username).filter(Boolean),
|
signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +554,6 @@ export async function logout({
|
|||||||
await clearSessionState();
|
await clearSessionState();
|
||||||
return {
|
return {
|
||||||
clearedAll: false,
|
clearedAll: false,
|
||||||
signedOut: [accountToSignOut.username].filter(Boolean),
|
signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
62
src/cli.js → src/cli.ts
Executable file → Normal file
62
src/cli.js → src/cli.ts
Executable file → Normal file
@@ -3,16 +3,35 @@
|
|||||||
|
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
import { runCommand } from "./cli/commands.js";
|
import { runCommand } from "./cli/commands.ts";
|
||||||
import {
|
import {
|
||||||
normalizeOutputFormat,
|
normalizeOutputFormat,
|
||||||
omitPermissionGuidColumns,
|
omitPermissionGuidColumns,
|
||||||
outputFiltered,
|
outputFiltered,
|
||||||
parseHeaderSpec,
|
parseHeaderSpec,
|
||||||
renderOutput,
|
renderOutput,
|
||||||
} from "./cli/utils.js";
|
} from "./cli/utils.ts";
|
||||||
|
|
||||||
function usage() {
|
type CliValues = {
|
||||||
|
help?: boolean;
|
||||||
|
"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]
|
return `Usage: sk-az-tools <command> [options]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
@@ -33,7 +52,7 @@ Use: sk-az-tools --help <command>
|
|||||||
or: sk-az-tools <command> --help`;
|
or: sk-az-tools <command> --help`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageListApps() {
|
function usageListApps(): string {
|
||||||
return `Usage: sk-az-tools list-apps [--display-name|-n <name>] [--app-id|-i <appId>] [--filter|-f <glob>] [global options]
|
return `Usage: sk-az-tools list-apps [--display-name|-n <name>] [--app-id|-i <appId>] [--filter|-f <glob>] [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -42,7 +61,7 @@ Options:
|
|||||||
-f, --filter <glob> Filter by app display name glob`;
|
-f, --filter <glob> Filter by app display name glob`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageLogin() {
|
function usageLogin(): string {
|
||||||
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]
|
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -53,14 +72,14 @@ Options:
|
|||||||
--browser-profile <name> Chromium profile name (e.g. Default, "Profile 1")`;
|
--browser-profile <name> Chromium profile name (e.g. Default, "Profile 1")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageLogout() {
|
function usageLogout(): string {
|
||||||
return `Usage: sk-az-tools logout [--all] [global options]
|
return `Usage: sk-az-tools logout [--all] [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--all Clear login state and remove all cached accounts`;
|
--all Clear login state and remove all cached accounts`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageListAppPermissions() {
|
function usageListAppPermissions(): string {
|
||||||
return `Usage: sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]
|
return `Usage: sk-az-tools list-app-permissions --app-id|-i <appId> [--resolve|-r] [--short|-s] [--filter|-f <glob>] [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -70,14 +89,14 @@ Options:
|
|||||||
-f, --filter <glob> Filter by permission name glob`;
|
-f, --filter <glob> Filter by permission name glob`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageListAppGrants() {
|
function usageListAppGrants(): string {
|
||||||
return `Usage: sk-az-tools list-app-grants --app-id|-i <appId> [global options]
|
return `Usage: sk-az-tools list-app-grants --app-id|-i <appId> [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-i, --app-id <appId> Application (client) ID (required)`;
|
-i, --app-id <appId> Application (client) ID (required)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageListResourcePermissions() {
|
function usageListResourcePermissions(): string {
|
||||||
return `Usage: sk-az-tools list-resource-permissions [--app-id|-i <appId> | --display-name|-n <name>] [--filter|-f <glob>] [global options]
|
return `Usage: sk-az-tools list-resource-permissions [--app-id|-i <appId> | --display-name|-n <name>] [--filter|-f <glob>] [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -86,14 +105,14 @@ Options:
|
|||||||
-f, --filter <glob> Filter by permission name glob`;
|
-f, --filter <glob> Filter by permission name glob`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageTable() {
|
function usageTable(): string {
|
||||||
return `Usage: sk-az-tools table [--header|-H <spec|auto|a|original|o>] [global options]
|
return `Usage: sk-az-tools table [--header|-H <spec|auto|a|original|o>] [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-H, --header <value> Header mode/spec: auto|a (default), original|o, OR "col1, col2" OR "key1: Label 1, key2: Label 2"`;
|
-H, --header <value> Header mode/spec: auto|a (default), original|o, OR "col1, col2" OR "key1: Label 1, key2: Label 2"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageCommand(command) {
|
function usageCommand(command: string): string {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "login":
|
case "login":
|
||||||
return usageLogin();
|
return usageLogin();
|
||||||
@@ -114,7 +133,7 @@ function usageCommand(command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main(): Promise<void> {
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const command = argv[0];
|
const command = argv[0];
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -150,24 +169,27 @@ async function main() {
|
|||||||
allowPositionals: false,
|
allowPositionals: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (values.help) {
|
const typedValues = values as CliValues;
|
||||||
|
|
||||||
|
if (typedValues.help) {
|
||||||
console.log(usageCommand(command));
|
console.log(usageCommand(command));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputFormat = normalizeOutputFormat(values.output);
|
const outputFormat = normalizeOutputFormat(typedValues.output);
|
||||||
const result = await runCommand(command, values);
|
const result = await runCommand(command, typedValues);
|
||||||
const filtered = outputFiltered(result, values.query);
|
const filtered = outputFiltered(result, typedValues.query);
|
||||||
const output = command === "list-app-permissions" && values.short
|
const output = command === "list-app-permissions" && typedValues.short
|
||||||
? omitPermissionGuidColumns(filtered)
|
? omitPermissionGuidColumns(filtered)
|
||||||
: filtered;
|
: filtered;
|
||||||
const headerSpec = parseHeaderSpec(values.header);
|
const headerSpec = parseHeaderSpec(typedValues.header);
|
||||||
|
|
||||||
renderOutput(command, output, outputFormat, headerSpec);
|
renderOutput(command, output, outputFormat, headerSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err: unknown) => {
|
||||||
console.error(`Error: ${err.message}`);
|
const error = err as Error;
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
console.error(usage());
|
console.error(usage());
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -2,32 +2,55 @@
|
|||||||
|
|
||||||
import { minimatch } from "minimatch";
|
import { minimatch } from "minimatch";
|
||||||
|
|
||||||
import { loadPublicConfig } from "../index.js";
|
import { loadPublicConfig } from "../index.ts";
|
||||||
import { getGraphClient } from "../graph/auth.js";
|
import { getGraphClient } from "../graph/auth.ts";
|
||||||
import { login, logout } from "../azure/index.js";
|
import { login, logout } from "../azure/index.ts";
|
||||||
import {
|
import {
|
||||||
listApps,
|
listApps,
|
||||||
listAppPermissions,
|
listAppPermissions,
|
||||||
listAppPermissionsResolved,
|
listAppPermissionsResolved,
|
||||||
listAppGrants,
|
listAppGrants,
|
||||||
listResourcePermissions,
|
listResourcePermissions,
|
||||||
} from "../graph/app.js";
|
} from "../graph/app.ts";
|
||||||
import { readJsonFromStdin } from "./utils.js";
|
import { readJsonFromStdin } from "./utils.ts";
|
||||||
|
|
||||||
function filterByPermissionName(rows, pattern) {
|
type CommandValues = {
|
||||||
|
[key: string]: string | boolean | undefined;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PermissionRow = {
|
||||||
|
permissionValue?: string | null;
|
||||||
|
permissionDisplayName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DisplayNameRow = {
|
||||||
|
displayName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterByPermissionName<T extends PermissionRow>(rows: T[], pattern: string): T[] {
|
||||||
return rows.filter((item) =>
|
return rows.filter((item) =>
|
||||||
minimatch(item.permissionValue ?? "", pattern, { nocase: true })
|
minimatch(item.permissionValue ?? "", pattern, { nocase: true })
|
||||||
|| minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true })
|
|| minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByDisplayName(rows, pattern) {
|
function filterByDisplayName<T extends DisplayNameRow>(rows: T[], pattern: string): T[] {
|
||||||
return rows.filter((item) =>
|
return rows.filter((item) =>
|
||||||
minimatch(item.displayName ?? "", pattern, { nocase: true })
|
minimatch(item.displayName ?? "", pattern, { nocase: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGraphClientFromPublicConfig() {
|
async function getGraphClientFromPublicConfig(): Promise<{ client: any }> {
|
||||||
const config = await loadPublicConfig();
|
const config = await loadPublicConfig();
|
||||||
return getGraphClient({
|
return getGraphClient({
|
||||||
tenantId: config.tenantId,
|
tenantId: config.tenantId,
|
||||||
@@ -35,11 +58,11 @@ async function getGraphClientFromPublicConfig() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTableCommand() {
|
async function runTableCommand(): Promise<unknown> {
|
||||||
return readJsonFromStdin();
|
return readJsonFromStdin();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLoginCommand(values) {
|
async function runLoginCommand(values: CommandValues): Promise<unknown> {
|
||||||
const config = await loadPublicConfig();
|
const config = await loadPublicConfig();
|
||||||
return login({
|
return login({
|
||||||
tenantId: config.tenantId,
|
tenantId: config.tenantId,
|
||||||
@@ -52,7 +75,7 @@ async function runLoginCommand(values) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLogoutCommand(values) {
|
async function runLogoutCommand(values: CommandValues): Promise<unknown> {
|
||||||
const config = await loadPublicConfig();
|
const config = await loadPublicConfig();
|
||||||
return logout({
|
return logout({
|
||||||
tenantId: config.tenantId,
|
tenantId: config.tenantId,
|
||||||
@@ -61,7 +84,7 @@ async function runLogoutCommand(values) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runListAppsCommand(values) {
|
async function runListAppsCommand(values: CommandValues): Promise<unknown> {
|
||||||
const { client } = await getGraphClientFromPublicConfig();
|
const { client } = await getGraphClientFromPublicConfig();
|
||||||
let result = await listApps(client, {
|
let result = await listApps(client, {
|
||||||
displayName: values["display-name"],
|
displayName: values["display-name"],
|
||||||
@@ -76,7 +99,7 @@ async function runListAppsCommand(values) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runListAppPermissionsCommand(values) {
|
async function runListAppPermissionsCommand(values: CommandValues): Promise<unknown> {
|
||||||
if (!values["app-id"]) {
|
if (!values["app-id"]) {
|
||||||
throw new Error("--app-id is required for list-app-permissions");
|
throw new Error("--app-id is required for list-app-permissions");
|
||||||
}
|
}
|
||||||
@@ -91,7 +114,7 @@ async function runListAppPermissionsCommand(values) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runListAppGrantsCommand(values) {
|
async function runListAppGrantsCommand(values: CommandValues): Promise<unknown> {
|
||||||
if (!values["app-id"]) {
|
if (!values["app-id"]) {
|
||||||
throw new Error("--app-id is required for list-app-grants");
|
throw new Error("--app-id is required for list-app-grants");
|
||||||
}
|
}
|
||||||
@@ -100,7 +123,7 @@ async function runListAppGrantsCommand(values) {
|
|||||||
return listAppGrants(client, values["app-id"]);
|
return listAppGrants(client, values["app-id"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runListResourcePermissionsCommand(values) {
|
async function runListResourcePermissionsCommand(values: CommandValues): Promise<unknown> {
|
||||||
if (!values["app-id"] && !values["display-name"]) {
|
if (!values["app-id"] && !values["display-name"]) {
|
||||||
throw new Error("--app-id or --display-name is required for list-resource-permissions");
|
throw new Error("--app-id or --display-name is required for list-resource-permissions");
|
||||||
}
|
}
|
||||||
@@ -119,7 +142,7 @@ async function runListResourcePermissionsCommand(values) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCommand(command, values) {
|
export async function runCommand(command: string, values: CommandValues): Promise<unknown> {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "login":
|
case "login":
|
||||||
return runLoginCommand(values);
|
return runLoginCommand(values);
|
||||||
@@ -2,15 +2,26 @@
|
|||||||
|
|
||||||
import jmespath from "jmespath";
|
import jmespath from "jmespath";
|
||||||
|
|
||||||
import { toMarkdownTable } from "../markdown.js";
|
import { toMarkdownTable } from "../markdown.ts";
|
||||||
|
|
||||||
export function outputFiltered(object, query) {
|
type HeaderSpec =
|
||||||
|
| { mode: "auto" }
|
||||||
|
| { mode: "original" }
|
||||||
|
| { mode: "list"; labels: string[] }
|
||||||
|
| { mode: "map"; map: Record<string, string> };
|
||||||
|
|
||||||
|
type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv";
|
||||||
|
|
||||||
|
type Scalar = string | number | boolean | null | undefined;
|
||||||
|
type ScalarRow = Record<string, Scalar>;
|
||||||
|
|
||||||
|
export function outputFiltered(object: unknown, query?: string): unknown {
|
||||||
return query
|
return query
|
||||||
? jmespath.search(object, query)
|
? jmespath.search(object, query)
|
||||||
: object;
|
: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseHeaderSpec(headerValue) {
|
export function parseHeaderSpec(headerValue?: string): HeaderSpec {
|
||||||
if (!headerValue) {
|
if (!headerValue) {
|
||||||
return { mode: "auto" };
|
return { mode: "auto" };
|
||||||
}
|
}
|
||||||
@@ -30,7 +41,7 @@ export function parseHeaderSpec(headerValue) {
|
|||||||
return { mode: "list", labels: parts };
|
return { mode: "list", labels: parts };
|
||||||
}
|
}
|
||||||
|
|
||||||
const map = {};
|
const map: Record<string, string> = {};
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const idx = part.indexOf(":");
|
const idx = part.indexOf(":");
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
@@ -47,7 +58,7 @@ export function parseHeaderSpec(headerValue) {
|
|||||||
return { mode: "map", map };
|
return { mode: "map", map };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeOutputFormat(outputValue) {
|
export function normalizeOutputFormat(outputValue?: string): OutputFormat {
|
||||||
if (outputValue == null) {
|
if (outputValue == null) {
|
||||||
return "json";
|
return "json";
|
||||||
}
|
}
|
||||||
@@ -66,16 +77,20 @@ export function normalizeOutputFormat(outputValue) {
|
|||||||
throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv");
|
throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScalarRowsAndHeaders(value) {
|
function isScalar(value: unknown): value is Scalar {
|
||||||
let rows;
|
return value == null || typeof value !== "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } {
|
||||||
|
let rows: Array<Record<string, unknown>>;
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
rows = value.map((item) =>
|
rows = value.map((item) =>
|
||||||
item && typeof item === "object" && !Array.isArray(item)
|
item && typeof item === "object" && !Array.isArray(item)
|
||||||
? item
|
? item as Record<string, unknown>
|
||||||
: { value: item },
|
: { value: item },
|
||||||
);
|
);
|
||||||
} else if (value && typeof value === "object") {
|
} else if (value && typeof value === "object") {
|
||||||
rows = [value];
|
rows = [value as Record<string, unknown>];
|
||||||
} else {
|
} else {
|
||||||
rows = [{ value }];
|
rows = [{ value }];
|
||||||
}
|
}
|
||||||
@@ -89,10 +104,7 @@ function getScalarRowsAndHeaders(value) {
|
|||||||
|
|
||||||
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
|
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
|
||||||
.filter((key) =>
|
.filter((key) =>
|
||||||
rows.every((row) => {
|
rows.every((row) => isScalar(row[key])),
|
||||||
const v = row[key];
|
|
||||||
return v == null || typeof v !== "object";
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (headers.length === 0) {
|
if (headers.length === 0) {
|
||||||
@@ -102,10 +114,20 @@ function getScalarRowsAndHeaders(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { headers, rows };
|
const scalarRows: ScalarRow[] = rows.map((row) => {
|
||||||
|
const result: ScalarRow = {};
|
||||||
|
for (const [key, rowValue] of Object.entries(row)) {
|
||||||
|
if (isScalar(rowValue)) {
|
||||||
|
result[key] = rowValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { headers, rows: scalarRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTsv(value) {
|
function toTsv(value: unknown): string {
|
||||||
const { headers, rows } = getScalarRowsAndHeaders(value);
|
const { headers, rows } = getScalarRowsAndHeaders(value);
|
||||||
const lines = rows.map((row) =>
|
const lines = rows.map((row) =>
|
||||||
headers
|
headers
|
||||||
@@ -115,22 +137,24 @@ function toTsv(value) {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function omitPermissionGuidColumns(value) {
|
export function omitPermissionGuidColumns(value: unknown): unknown {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((item) => omitPermissionGuidColumns(item));
|
return value.map((item) => omitPermissionGuidColumns(item));
|
||||||
}
|
}
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
const { resourceAppId, permissionId, ...rest } = value;
|
const { resourceAppId, permissionId, ...rest } = value as Record<string, unknown>;
|
||||||
|
void resourceAppId;
|
||||||
|
void permissionId;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readJsonFromStdin() {
|
export async function readJsonFromStdin(): Promise<unknown> {
|
||||||
const input = await new Promise((resolve, reject) => {
|
const input = await new Promise<string>((resolve, reject) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
process.stdin.on("data", (chunk) => {
|
process.stdin.on("data", (chunk: string) => {
|
||||||
data += chunk;
|
data += chunk;
|
||||||
});
|
});
|
||||||
process.stdin.on("end", () => {
|
process.stdin.on("end", () => {
|
||||||
@@ -145,13 +169,18 @@ export async function readJsonFromStdin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(input);
|
return JSON.parse(input) as unknown;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Invalid JSON input on stdin: ${err.message}`);
|
throw new Error(`Invalid JSON input on stdin: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderOutput(command, output, outputFormat, headerSpec) {
|
export function renderOutput(
|
||||||
|
command: string,
|
||||||
|
output: unknown,
|
||||||
|
outputFormat: OutputFormat,
|
||||||
|
headerSpec: HeaderSpec,
|
||||||
|
): void {
|
||||||
if (outputFormat === "tsv") {
|
if (outputFormat === "tsv") {
|
||||||
console.log(toTsv(output));
|
console.log(toTsv(output));
|
||||||
return;
|
return;
|
||||||
1
src/devops/index.d.ts
vendored
1
src/devops/index.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
//
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A DevOps helpers module.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { loginInteractive } from "../azure/index.js";
|
|
||||||
import * as azdev from "azure-devops-node-api";
|
|
||||||
|
|
||||||
const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Azure DevOps API token.
|
|
||||||
*
|
|
||||||
* @param { string } tenantId - The Azure AD tenant ID
|
|
||||||
* @param { string } clientId - The Azure AD client ID
|
|
||||||
* @returns { Promise<string> } Azure DevOps API access token
|
|
||||||
*/
|
|
||||||
|
|
||||||
export async function getDevOpsApiToken(tenantId, clientId) {
|
|
||||||
const result = await loginInteractive({
|
|
||||||
tenantId,
|
|
||||||
clientId,
|
|
||||||
scopes: AZURE_DEVOPS_SCOPES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const accessToken = result?.accessToken;
|
|
||||||
|
|
||||||
if(!accessToken) {
|
|
||||||
throw new Error("Failed to obtain Azure DevOps API token");
|
|
||||||
}
|
|
||||||
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Azure DevOps clients - Core and Git.
|
|
||||||
*
|
|
||||||
* @param { string } orgUrl - The Azure DevOps organization URL
|
|
||||||
* @param { string } tenantId - The Azure AD tenant ID
|
|
||||||
* @param { string } clientId - The Azure AD client ID
|
|
||||||
* @returns { Promise<{ coreClient: Object, gitClient: Object }> }
|
|
||||||
*/
|
|
||||||
|
|
||||||
export async function getDevOpsClients(orgUrl, tenantId, clientId) {
|
|
||||||
const accessToken = await getDevOpsApiToken(tenantId, clientId);
|
|
||||||
|
|
||||||
const authHandler = azdev.getBearerHandler(accessToken);
|
|
||||||
const connection = new azdev.WebApi(orgUrl, authHandler);
|
|
||||||
|
|
||||||
const coreClient = await connection.getCoreApi();
|
|
||||||
const gitClient = await connection.getGitApi();
|
|
||||||
|
|
||||||
return { coreClient, gitClient };
|
|
||||||
}
|
|
||||||
42
src/devops/index.ts
Normal file
42
src/devops/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DevOps helpers module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loginInteractive } from "../azure/index.ts";
|
||||||
|
import * as azdev from "azure-devops-node-api";
|
||||||
|
|
||||||
|
const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"];
|
||||||
|
|
||||||
|
type LoginInteractiveResult = {
|
||||||
|
accessToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise<string> {
|
||||||
|
const result = await loginInteractive({
|
||||||
|
tenantId,
|
||||||
|
clientId,
|
||||||
|
scopes: AZURE_DEVOPS_SCOPES,
|
||||||
|
}) as LoginInteractiveResult;
|
||||||
|
|
||||||
|
const accessToken = result?.accessToken;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error("Failed to obtain Azure DevOps API token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevOpsClients(orgUrl: string, tenantId: string, clientId: string): Promise<{ coreClient: unknown; gitClient: unknown }> {
|
||||||
|
const accessToken = await getDevOpsApiToken(tenantId, clientId);
|
||||||
|
|
||||||
|
const authHandler = azdev.getBearerHandler(accessToken);
|
||||||
|
const connection = new azdev.WebApi(orgUrl, authHandler);
|
||||||
|
|
||||||
|
const coreClient = await connection.getCoreApi();
|
||||||
|
const gitClient = await connection.getGitApi();
|
||||||
|
|
||||||
|
return { coreClient, gitClient };
|
||||||
|
}
|
||||||
@@ -1,51 +1,77 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
/**
|
type GraphObject = Record<string, unknown>;
|
||||||
* Get an Azure application by its display name.
|
|
||||||
*
|
type GraphResult<T = GraphObject> = {
|
||||||
* @param { Object } client
|
value?: T[];
|
||||||
* @param { string } displayName
|
};
|
||||||
* @returns { Promise<Object|null> }
|
|
||||||
*/
|
type AppQueryOptions = {
|
||||||
export async function getApp(client, displayName) {
|
displayName?: string;
|
||||||
|
appId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequiredResourceAccessItem = {
|
||||||
|
type?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequiredResourceAccess = {
|
||||||
|
resourceAppId?: string;
|
||||||
|
resourceAccess?: RequiredResourceAccessItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphPermission = {
|
||||||
|
id?: string;
|
||||||
|
value?: string;
|
||||||
|
displayName?: string;
|
||||||
|
adminConsentDisplayName?: string;
|
||||||
|
userConsentDisplayName?: string;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServicePrincipal = {
|
||||||
|
id?: string;
|
||||||
|
appId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
oauth2PermissionScopes?: GraphPermission[];
|
||||||
|
appRoles?: GraphPermission[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourcePermissionsOptions = {
|
||||||
|
appId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getApp(client: any, displayName: string): Promise<GraphObject | null> {
|
||||||
const result = await client
|
const result = await client
|
||||||
.api("/applications")
|
.api("/applications")
|
||||||
.filter(`displayName eq '${displayName}'`)
|
.filter(`displayName eq '${displayName}'`)
|
||||||
.get();
|
.get() as GraphResult;
|
||||||
|
|
||||||
// Return the first application found or null if none exists
|
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
|
||||||
return result.value.length > 0 ? result.value[0] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApp(client, displayName) {
|
export async function createApp(client: any, displayName: string): Promise<GraphObject> {
|
||||||
const app = await client.api("/applications").post({
|
const app = await client.api("/applications").post({
|
||||||
displayName,
|
displayName,
|
||||||
});
|
}) as GraphObject;
|
||||||
|
|
||||||
if (!app || !app.appId) {
|
if (!app || typeof app.appId !== "string") {
|
||||||
throw new Error("Failed to create application");
|
throw new Error("Failed to create application");
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApp(client, appObjectId) {
|
export async function deleteApp(client: any, appObjectId: string): Promise<void> {
|
||||||
await client.api(`/applications/${appObjectId}`).delete();
|
await client.api(`/applications/${appObjectId}`).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listApps(client: any, options: AppQueryOptions = {}): Promise<GraphObject[]> {
|
||||||
* List Azure applications, optionally filtered by display name and/or app ID.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { Object } [options]
|
|
||||||
* @param { string } [options.displayName]
|
|
||||||
* @param { string } [options.appId]
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listApps(client, options = {}) {
|
|
||||||
const { displayName, appId } = options;
|
const { displayName, appId } = options;
|
||||||
let request = client.api("/applications");
|
let request = client.api("/applications");
|
||||||
const filters = [];
|
const filters: string[] = [];
|
||||||
|
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
filters.push(`displayName eq '${displayName}'`);
|
filters.push(`displayName eq '${displayName}'`);
|
||||||
@@ -58,18 +84,11 @@ export async function listApps(client, options = {}) {
|
|||||||
request = request.filter(filters.join(" and "));
|
request = request.filter(filters.join(" and "));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await request.get();
|
const result = await request.get() as GraphResult;
|
||||||
return Array.isArray(result?.value) ? result.value : [];
|
return Array.isArray(result?.value) ? result.value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listAppPermissions(client: any, appId: string): Promise<RequiredResourceAccess[]> {
|
||||||
* List required resource access configuration for an application by appId.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { string } appId
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listAppPermissions(client, appId) {
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
throw new Error("appId is required");
|
throw new Error("appId is required");
|
||||||
}
|
}
|
||||||
@@ -78,7 +97,7 @@ export async function listAppPermissions(client, appId) {
|
|||||||
.api("/applications")
|
.api("/applications")
|
||||||
.filter(`appId eq '${appId}'`)
|
.filter(`appId eq '${appId}'`)
|
||||||
.select("id,appId,displayName,requiredResourceAccess")
|
.select("id,appId,displayName,requiredResourceAccess")
|
||||||
.get();
|
.get() as GraphResult<GraphObject>;
|
||||||
|
|
||||||
const app = Array.isArray(result?.value) && result.value.length > 0
|
const app = Array.isArray(result?.value) && result.value.length > 0
|
||||||
? result.value[0]
|
? result.value[0]
|
||||||
@@ -88,19 +107,13 @@ export async function listAppPermissions(client, appId) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(app.requiredResourceAccess)
|
const requiredResourceAccess = app.requiredResourceAccess;
|
||||||
? app.requiredResourceAccess
|
return Array.isArray(requiredResourceAccess)
|
||||||
|
? requiredResourceAccess as RequiredResourceAccess[]
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listAppPermissionsResolved(client: any, appId: string): Promise<Array<Record<string, unknown>>> {
|
||||||
* List required resource access in a resolved, human-readable form.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { string } appId
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listAppPermissionsResolved(client, appId) {
|
|
||||||
const requiredResourceAccess = await listAppPermissions(client, appId);
|
const requiredResourceAccess = await listAppPermissions(client, appId);
|
||||||
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
|
if (!Array.isArray(requiredResourceAccess) || requiredResourceAccess.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -109,7 +122,7 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
const resourceAppIds = [...new Set(
|
const resourceAppIds = [...new Set(
|
||||||
requiredResourceAccess
|
requiredResourceAccess
|
||||||
.map((entry) => entry?.resourceAppId)
|
.map((entry) => entry?.resourceAppId)
|
||||||
.filter(Boolean),
|
.filter((value): value is string => typeof value === "string" && value.length > 0),
|
||||||
)];
|
)];
|
||||||
|
|
||||||
const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
|
const resourceDefinitions = await Promise.all(resourceAppIds.map(async (resourceAppId) => {
|
||||||
@@ -117,17 +130,21 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
.api("/servicePrincipals")
|
.api("/servicePrincipals")
|
||||||
.filter(`appId eq '${resourceAppId}'`)
|
.filter(`appId eq '${resourceAppId}'`)
|
||||||
.select("appId,displayName,oauth2PermissionScopes,appRoles")
|
.select("appId,displayName,oauth2PermissionScopes,appRoles")
|
||||||
.get();
|
.get() as GraphResult<ServicePrincipal>;
|
||||||
|
|
||||||
const sp = Array.isArray(result?.value) && result.value.length > 0
|
const sp = Array.isArray(result?.value) && result.value.length > 0
|
||||||
? result.value[0]
|
? result.value[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const scopesById = new Map(
|
const scopesById = new Map(
|
||||||
(sp?.oauth2PermissionScopes ?? []).map((scope) => [scope.id, scope]),
|
(sp?.oauth2PermissionScopes ?? [])
|
||||||
|
.filter((scope) => typeof scope.id === "string")
|
||||||
|
.map((scope) => [scope.id as string, scope]),
|
||||||
);
|
);
|
||||||
const rolesById = new Map(
|
const rolesById = new Map(
|
||||||
(sp?.appRoles ?? []).map((role) => [role.id, role]),
|
(sp?.appRoles ?? [])
|
||||||
|
.filter((role) => typeof role.id === "string")
|
||||||
|
.map((role) => [role.id as string, role]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -142,9 +159,10 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
|
resourceDefinitions.map((entry) => [entry.resourceAppId, entry]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows = [];
|
const rows: Array<Record<string, unknown>> = [];
|
||||||
for (const resourceEntry of requiredResourceAccess) {
|
for (const resourceEntry of requiredResourceAccess) {
|
||||||
const resourceMeta = byResourceAppId.get(resourceEntry.resourceAppId);
|
const resourceAppId = resourceEntry.resourceAppId ?? "";
|
||||||
|
const resourceMeta = byResourceAppId.get(resourceAppId);
|
||||||
const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
|
const resourceAccessItems = Array.isArray(resourceEntry?.resourceAccess)
|
||||||
? resourceEntry.resourceAccess
|
? resourceEntry.resourceAccess
|
||||||
: [];
|
: [];
|
||||||
@@ -153,8 +171,8 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
const permissionType = item?.type ?? null;
|
const permissionType = item?.type ?? null;
|
||||||
const permissionId = item?.id ?? null;
|
const permissionId = item?.id ?? null;
|
||||||
const resolved = permissionType === "Scope"
|
const resolved = permissionType === "Scope"
|
||||||
? resourceMeta?.scopesById.get(permissionId)
|
? resourceMeta?.scopesById.get(permissionId ?? "")
|
||||||
: resourceMeta?.rolesById.get(permissionId);
|
: resourceMeta?.rolesById.get(permissionId ?? "");
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
resourceAppId: resourceEntry.resourceAppId ?? null,
|
resourceAppId: resourceEntry.resourceAppId ?? null,
|
||||||
@@ -174,14 +192,7 @@ export async function listAppPermissionsResolved(client, appId) {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listAppGrants(client: any, appId: string): Promise<GraphObject[]> {
|
||||||
* List delegated OAuth2 permission grants for an application by appId.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { string } appId
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listAppGrants(client, appId) {
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
throw new Error("appId is required");
|
throw new Error("appId is required");
|
||||||
}
|
}
|
||||||
@@ -190,7 +201,7 @@ export async function listAppGrants(client, appId) {
|
|||||||
.api("/servicePrincipals")
|
.api("/servicePrincipals")
|
||||||
.filter(`appId eq '${appId}'`)
|
.filter(`appId eq '${appId}'`)
|
||||||
.select("id,appId,displayName")
|
.select("id,appId,displayName")
|
||||||
.get();
|
.get() as GraphResult<ServicePrincipal>;
|
||||||
|
|
||||||
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
|
const servicePrincipal = Array.isArray(spResult?.value) && spResult.value.length > 0
|
||||||
? spResult.value[0]
|
? spResult.value[0]
|
||||||
@@ -203,21 +214,12 @@ export async function listAppGrants(client, appId) {
|
|||||||
const grantsResult = await client
|
const grantsResult = await client
|
||||||
.api("/oauth2PermissionGrants")
|
.api("/oauth2PermissionGrants")
|
||||||
.filter(`clientId eq '${servicePrincipal.id}'`)
|
.filter(`clientId eq '${servicePrincipal.id}'`)
|
||||||
.get();
|
.get() as GraphResult;
|
||||||
|
|
||||||
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
|
return Array.isArray(grantsResult?.value) ? grantsResult.value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listResourcePermissions(client: any, options: ResourcePermissionsOptions = {}): Promise<Array<Record<string, unknown>>> {
|
||||||
* List available delegated scopes and app roles for a resource app.
|
|
||||||
*
|
|
||||||
* @param { Object } client
|
|
||||||
* @param { Object } options
|
|
||||||
* @param { string } [options.appId]
|
|
||||||
* @param { string } [options.displayName]
|
|
||||||
* @returns { Promise<Array> }
|
|
||||||
*/
|
|
||||||
export async function listResourcePermissions(client, options = {}) {
|
|
||||||
const { appId, displayName } = options;
|
const { appId, displayName } = options;
|
||||||
if (!appId && !displayName) {
|
if (!appId && !displayName) {
|
||||||
throw new Error("appId or displayName is required");
|
throw new Error("appId or displayName is required");
|
||||||
@@ -233,9 +235,9 @@ export async function listResourcePermissions(client, options = {}) {
|
|||||||
request = request.filter(`displayName eq '${displayName}'`);
|
request = request.filter(`displayName eq '${displayName}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await request.get();
|
const result = await request.get() as GraphResult<ServicePrincipal>;
|
||||||
const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
|
const servicePrincipals = Array.isArray(result?.value) ? result.value : [];
|
||||||
const rows = [];
|
const rows: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (const sp of servicePrincipals) {
|
for (const sp of servicePrincipals) {
|
||||||
for (const scope of sp?.oauth2PermissionScopes ?? []) {
|
for (const scope of sp?.oauth2PermissionScopes ?? []) {
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
import { Client } from "@microsoft/microsoft-graph-client";
|
|
||||||
import { acquireResourceTokenFromLogin } from "../azure/index.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize and return a Microsoft Graph client
|
|
||||||
* along with the authentication token.
|
|
||||||
*
|
|
||||||
* @param { Object } options - Options for authentication
|
|
||||||
* @param { string } options.tenantId - The Azure AD tenant ID
|
|
||||||
* @param { string } options.clientId - The Azure AD client ID
|
|
||||||
* @returns { Promise<{ graphApiToken: Object, client: Object }> } An object containing the Graph API token and client
|
|
||||||
*/
|
|
||||||
export async function getGraphClient({ tenantId, clientId }) {
|
|
||||||
const graphApiToken = await acquireResourceTokenFromLogin({
|
|
||||||
tenantId,
|
|
||||||
clientId,
|
|
||||||
resource: "graph",
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = Client.init({
|
|
||||||
authProvider: (done) => {
|
|
||||||
done(null, graphApiToken.accessToken);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { graphApiToken, client };
|
|
||||||
}
|
|
||||||
30
src/graph/auth.ts
Normal file
30
src/graph/auth.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { Client } from "@microsoft/microsoft-graph-client";
|
||||||
|
import { acquireResourceTokenFromLogin } from "../azure/index.ts";
|
||||||
|
|
||||||
|
type GraphClientOptions = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphApiToken = {
|
||||||
|
accessToken: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getGraphClient({ tenantId, clientId }: GraphClientOptions): Promise<{ graphApiToken: GraphApiToken; client: any }> {
|
||||||
|
const graphApiToken = await acquireResourceTokenFromLogin({
|
||||||
|
tenantId,
|
||||||
|
clientId,
|
||||||
|
resource: "graph",
|
||||||
|
}) as GraphApiToken;
|
||||||
|
|
||||||
|
const client = Client.init({
|
||||||
|
authProvider: (done) => {
|
||||||
|
done(null, graphApiToken.accessToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { graphApiToken, client };
|
||||||
|
}
|
||||||
1
src/graph/index.d.ts
vendored
1
src/graph/index.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
//
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
export * from "./auth.js";
|
|
||||||
export * from "./app.js";
|
|
||||||
export * from "./sp.js";
|
|
||||||
5
src/graph/index.ts
Normal file
5
src/graph/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
export * from "./auth.ts";
|
||||||
|
export * from "./app.ts";
|
||||||
|
export * from "./sp.ts";
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
export async function getServicePrincipal(client, appId) {
|
|
||||||
const result = await client
|
|
||||||
.api("/servicePrincipals")
|
|
||||||
.filter(`appId eq '${appId}'`)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
// Return the first service principal found or null if none exists
|
|
||||||
return result.value.length > 0 ? result.value[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSp(client, appId) {
|
|
||||||
const sp = await client.api("/servicePrincipals").post({
|
|
||||||
appId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sp || !sp.id) {
|
|
||||||
throw new Error("Failed to create service principal");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sp;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSp(client, spId) {
|
|
||||||
await client.api(`/servicePrincipals/${spId}`).delete();
|
|
||||||
}
|
|
||||||
30
src/graph/sp.ts
Normal file
30
src/graph/sp.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
type GraphResult<T = Record<string, unknown>> = {
|
||||||
|
value?: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServicePrincipal(client: any, appId: string): Promise<Record<string, unknown> | null> {
|
||||||
|
const result = await client
|
||||||
|
.api("/servicePrincipals")
|
||||||
|
.filter(`appId eq '${appId}'`)
|
||||||
|
.get() as GraphResult;
|
||||||
|
|
||||||
|
return Array.isArray(result.value) && result.value.length > 0 ? result.value[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSp(client: any, appId: string): Promise<Record<string, unknown>> {
|
||||||
|
const sp = await client.api("/servicePrincipals").post({
|
||||||
|
appId,
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!sp || typeof sp.id !== "string") {
|
||||||
|
throw new Error("Failed to create service principal");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSp(client: any, spId: string): Promise<void> {
|
||||||
|
await client.api(`/servicePrincipals/${spId}`).delete();
|
||||||
|
}
|
||||||
1
src/index.d.ts
vendored
1
src/index.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
//
|
|
||||||
@@ -4,7 +4,17 @@ import { readFile } from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export function getUserConfigDir() {
|
type Config = {
|
||||||
|
tenantId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigCandidate = {
|
||||||
|
tenantId?: unknown;
|
||||||
|
clientId?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getUserConfigDir(): string {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
|
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
|
||||||
}
|
}
|
||||||
@@ -12,37 +22,37 @@ export function getUserConfigDir() {
|
|||||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig(configFileName) {
|
async function loadConfig(configFileName: string): Promise<Config> {
|
||||||
if (typeof configFileName !== "string" || configFileName.trim() === "") {
|
if (typeof configFileName !== "string" || configFileName.trim() === "") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".',
|
'Invalid config file name. Expected a non-empty string like "public-config.json" or "confidential-config.json".',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const envConfig: Config = {
|
||||||
tenantId: process.env.AZURE_TENANT_ID,
|
tenantId: process.env.AZURE_TENANT_ID,
|
||||||
clientId: process.env.AZURE_CLIENT_ID,
|
clientId: process.env.AZURE_CLIENT_ID,
|
||||||
};
|
};
|
||||||
|
|
||||||
const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName);
|
const configPath = path.join(getUserConfigDir(), "sk-az-tools", configFileName);
|
||||||
return readFile(configPath, "utf8")
|
return readFile(configPath, "utf8")
|
||||||
.then((configJson) => JSON.parse(configJson))
|
.then((configJson) => JSON.parse(configJson) as ConfigCandidate)
|
||||||
.catch((err) => {
|
.catch((err: unknown) => {
|
||||||
if (err?.code === "ENOENT") {
|
if ((err as { code?: string } | null)?.code === "ENOENT") {
|
||||||
return {};
|
return {} as ConfigCandidate;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
})
|
})
|
||||||
.then((json) => ({
|
.then((json) => ({
|
||||||
tenantId: json.tenantId || config.tenantId,
|
tenantId: typeof json.tenantId === "string" && json.tenantId ? json.tenantId : envConfig.tenantId,
|
||||||
clientId: json.clientId || config.clientId,
|
clientId: typeof json.clientId === "string" && json.clientId ? json.clientId : envConfig.clientId,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadPublicConfig() {
|
export function loadPublicConfig(): Promise<Config> {
|
||||||
return loadConfig("public-config.json");
|
return loadConfig("public-config.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfidentialConfig() {
|
export function loadConfidentialConfig(): Promise<Config> {
|
||||||
return loadConfig("confidential-config.json");
|
return loadConfig("confidential-config.json");
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
function formatCell(value) {
|
type Scalar = string | number | boolean | null | undefined;
|
||||||
|
type ScalarRow = Record<string, Scalar>;
|
||||||
|
|
||||||
|
type HeaderSpec =
|
||||||
|
| { mode: "default" }
|
||||||
|
| { mode: "auto" }
|
||||||
|
| { mode: "original" }
|
||||||
|
| { mode: "list"; labels: string[] }
|
||||||
|
| { mode: "map"; map: Record<string, string> };
|
||||||
|
|
||||||
|
function formatCell(value: unknown): string {
|
||||||
const text = value == null
|
const text = value == null
|
||||||
? ""
|
? ""
|
||||||
: String(value);
|
: String(value);
|
||||||
return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
|
return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGuid(value) {
|
function isGuid(value: unknown): value is string {
|
||||||
return typeof value === "string"
|
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);
|
&& /^[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) {
|
function toAutoHeaderLabel(key: string): string {
|
||||||
const withSpaces = String(key)
|
const withSpaces = String(key)
|
||||||
.replace(/[_-]+/g, " ")
|
.replace(/[_-]+/g, " ")
|
||||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
@@ -25,16 +35,20 @@ function toAutoHeaderLabel(key) {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScalarRowsAndHeaders(value) {
|
function isScalar(value: unknown): value is Scalar {
|
||||||
let rows;
|
return value == null || typeof value !== "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } {
|
||||||
|
let rows: Array<Record<string, unknown>>;
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
rows = value.map((item) =>
|
rows = value.map((item) =>
|
||||||
item && typeof item === "object" && !Array.isArray(item)
|
item && typeof item === "object" && !Array.isArray(item)
|
||||||
? item
|
? item as Record<string, unknown>
|
||||||
: { value: item }
|
: { value: item },
|
||||||
);
|
);
|
||||||
} else if (value && typeof value === "object") {
|
} else if (value && typeof value === "object") {
|
||||||
rows = [value];
|
rows = [value as Record<string, unknown>];
|
||||||
} else {
|
} else {
|
||||||
rows = [{ value }];
|
rows = [{ value }];
|
||||||
}
|
}
|
||||||
@@ -48,10 +62,7 @@ function getScalarRowsAndHeaders(value) {
|
|||||||
|
|
||||||
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
|
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
|
||||||
.filter((key) =>
|
.filter((key) =>
|
||||||
rows.every((row) => {
|
rows.every((row) => isScalar(row[key])),
|
||||||
const value = row[key];
|
|
||||||
return value == null || typeof value !== "object";
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (headers.length === 0) {
|
if (headers.length === 0) {
|
||||||
@@ -61,25 +72,39 @@ function getScalarRowsAndHeaders(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { headers, rows };
|
const scalarRows: ScalarRow[] = rows.map((row) => {
|
||||||
|
const result: ScalarRow = {};
|
||||||
|
for (const [key, rowValue] of Object.entries(row)) {
|
||||||
|
if (isScalar(rowValue)) {
|
||||||
|
result[key] = rowValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { headers, rows: scalarRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toMarkdownTable(value, pretty = false, quoteGuids = false) {
|
export function toMarkdownTable(
|
||||||
const headerSpec = arguments[3] ?? { mode: "default" };
|
value: unknown,
|
||||||
|
pretty = false,
|
||||||
|
quoteGuids = false,
|
||||||
|
headerSpec: HeaderSpec = { mode: "default" },
|
||||||
|
): string {
|
||||||
const { headers, rows } = getScalarRowsAndHeaders(value);
|
const { headers, rows } = getScalarRowsAndHeaders(value);
|
||||||
const headerDefinitions = headers.map((key, idx) => {
|
const headerDefinitions = headers.map((key, idx) => {
|
||||||
let label = key;
|
let label = key;
|
||||||
if (headerSpec?.mode === "auto") {
|
if (headerSpec.mode === "auto") {
|
||||||
label = toAutoHeaderLabel(key);
|
label = toAutoHeaderLabel(key);
|
||||||
} else if (headerSpec?.mode === "list" && Array.isArray(headerSpec.labels) && headerSpec.labels[idx]) {
|
} else if (headerSpec.mode === "list" && headerSpec.labels[idx]) {
|
||||||
label = headerSpec.labels[idx];
|
label = headerSpec.labels[idx];
|
||||||
} else if (headerSpec?.mode === "map" && headerSpec.map && headerSpec.map[key]) {
|
} else if (headerSpec.mode === "map" && headerSpec.map[key]) {
|
||||||
label = headerSpec.map[key];
|
label = headerSpec.map[key];
|
||||||
}
|
}
|
||||||
return { key, label };
|
return { key, label };
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderCell = (raw) => {
|
const renderCell = (raw: Scalar): string => {
|
||||||
const text = formatCell(raw);
|
const text = formatCell(raw);
|
||||||
return quoteGuids && isGuid(raw) ? `\`${text}\`` : text;
|
return quoteGuids && isGuid(raw) ? `\`${text}\`` : text;
|
||||||
};
|
};
|
||||||
@@ -88,7 +113,7 @@ export function toMarkdownTable(value, pretty = false, quoteGuids = false) {
|
|||||||
const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;
|
const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;
|
||||||
const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`;
|
const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`;
|
||||||
const rowLines = rows.map((row) =>
|
const rowLines = rows.map((row) =>
|
||||||
`| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |`
|
`| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |`,
|
||||||
);
|
);
|
||||||
return [headerLine, separatorLine, ...rowLines].join("\n");
|
return [headerLine, separatorLine, ...rowLines].join("\n");
|
||||||
}
|
}
|
||||||
@@ -100,13 +125,13 @@ export function toMarkdownTable(value, pretty = false, quoteGuids = false) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRow = (values) =>
|
const renderRow = (values: string[]): string =>
|
||||||
`| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`;
|
`| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`;
|
||||||
|
|
||||||
const headerLine = renderRow(headerDefinitions.map((h) => h.label));
|
const headerLine = renderRow(headerDefinitions.map((h) => h.label));
|
||||||
const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`;
|
const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`;
|
||||||
const rowLines = rows.map((row) =>
|
const rowLines = rows.map((row) =>
|
||||||
renderRow(headerDefinitions.map((header) => renderCell(row[header.key])))
|
renderRow(headerDefinitions.map((header) => renderCell(row[header.key]))),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [headerLine, separatorLine, ...rowLines].join("\n");
|
return [headerLine, separatorLine, ...rowLines].join("\n");
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2024",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2024"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user