From 064ee1db2136215cf2c5c40f0d5a9561a76806fa Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 8 Feb 2026 21:35:40 +0100 Subject: [PATCH] Add browser profile option and TSV output mode - add --browser-profile for login and validate browser/profile combinations\n- validate browser options eagerly and keep default-browser behavior when omitted\n- add TSV output format (no header)\n- change header default to auto; add --header original/-H o\n- remove explicit json/j output mode usage and keep JSON as implicit default\n- add tini to Dockerfile entrypoint path to improve signal handling --- Dockerfile | 5 +-- src/azure/pca-auth.js | 78 ++++++++++++++++++++++++++++++++++--------- src/cli.js | 12 ++++--- src/cli/commands.js | 1 + src/cli/utils.js | 75 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 145 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7ed7be1..402f202 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,10 @@ WORKDIR /work COPY --from=package /package/*.tgz /tmp/sk-az-tools.tgz -RUN npm install --global /tmp/sk-az-tools.tgz \ +RUN apk add --no-cache tini \ + && npm install --global /tmp/sk-az-tools.tgz \ && rm /tmp/sk-az-tools.tgz \ && npm cache clean --force -ENTRYPOINT ["sk-az-tools"] +ENTRYPOINT ["tini", "--", "sk-az-tools"] CMD ["--help"] diff --git a/src/azure/pca-auth.js b/src/azure/pca-auth.js index 9836ab2..b76491f 100644 --- a/src/azure/pca-auth.js +++ b/src/azure/pca-auth.js @@ -17,6 +17,7 @@ const RESOURCE_SCOPE_BY_NAME = { const DEFAULT_RESOURCES = ["graph", "devops", "arm"]; const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login"; const BROWSER_KEYWORDS = Object.keys(apps).sort(); +const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]); function getCacheRoot() { const isWindows = process.platform === "win32"; @@ -80,21 +81,70 @@ function getBrowserAppName(browser) { return null; } - const requested = browser.trim().toLowerCase(); - if (requested === "default") { - return null; - } - - const keyword = BROWSER_KEYWORDS.find((name) => name.toLowerCase() === requested); + const keyword = BROWSER_KEYWORDS.find( + (name) => name.toLowerCase() === browser.trim().toLowerCase(), + ); if (!keyword) { throw new Error( - `Invalid browser '${browser}'. Allowed: default, ${BROWSER_KEYWORDS.join(", ")}`, + `Invalid browser '${browser}'. Allowed: ${BROWSER_KEYWORDS.join(", ")}`, ); } return apps[keyword]; } +function getBrowserKeyword(browser) { + if (!browser || browser.trim() === "") { + return ""; + } + + const requested = browser.trim().toLowerCase(); + const keyword = BROWSER_KEYWORDS.find((name) => name.toLowerCase() === requested); + if (!keyword) { + throw new Error( + `Invalid browser '${browser}'. Allowed: ${BROWSER_KEYWORDS.join(", ")}`, + ); + } + + return keyword.toLowerCase(); +} + +function getBrowserOpenOptions({ browser, browserProfile }) { + const browserName = getBrowserAppName(browser); + const options = browserName + ? { wait: false, app: { name: browserName } } + : { wait: false }; + + if (!browserProfile || browserProfile.trim() === "") { + return options; + } + + const browserKeyword = getBrowserKeyword(browser); + if (!CHROMIUM_BROWSERS.has(browserKeyword)) { + throw new Error( + "--browser-profile is supported only with --browser edge|chrome|brave", + ); + } + + options.app.arguments = [`--profile-directory=${browserProfile.trim()}`]; + return options; +} + +function validateBrowserOptions({ browser, browserProfile }) { + if (browser && browser.trim() !== "") { + getBrowserAppName(browser); + } + + if (browserProfile && browserProfile.trim() !== "") { + const browserKeyword = getBrowserKeyword(browser); + if (!CHROMIUM_BROWSERS.has(browserKeyword)) { + throw new Error( + "--browser-profile is supported only with --browser edge|chrome|brave", + ); + } + } +} + export function parseResources(resourcesCsv) { if (!resourcesCsv || resourcesCsv.trim() === "") { return [...DEFAULT_RESOURCES]; @@ -217,11 +267,13 @@ export async function loginInteractive({ scopes, showAuthUrlOnly = false, browser, + browserProfile, }) { 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 }); const pca = await createPca({ tenantId, clientId }); @@ -235,10 +287,7 @@ export async function loginInteractive({ writeStderr(`Visit:\n${url}`); return; } - const browserName = getBrowserAppName(browser); - const options = browserName - ? { wait: false, app: { name: browserName } } - : { wait: false }; + const options = getBrowserOpenOptions({ browser, browserProfile }); return open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); @@ -272,9 +321,11 @@ export async function login({ useDeviceCode = false, noBrowser = false, browser, + browserProfile, }) { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); + validateBrowserOptions({ browser, browserProfile }); const resources = parseResources(resourcesCsv); const scopes = getScopesForResources(resources); @@ -312,10 +363,7 @@ export async function login({ writeStderr(`Visit:\n${url}`); return; } - const browserName = getBrowserAppName(browser); - const options = browserName - ? { wait: false, app: { name: browserName } } - : { wait: false }; + const options = getBrowserOpenOptions({ browser, browserProfile }); return open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); diff --git a/src/cli.js b/src/cli.js index 596f229..4e1fb93 100755 --- a/src/cli.js +++ b/src/cli.js @@ -26,7 +26,7 @@ Commands: Global options (all commands): -q, --query - -o, --output json|j|table|t|alignedtable|at|prettytable|pt + -o, --output table|t|alignedtable|at|prettytable|pt|tsv -h, --help Use: sk-az-tools --help @@ -43,13 +43,14 @@ Options: } function usageLogin() { - return `Usage: sk-az-tools login [--resources ] [--use-device-code] [--no-browser] [--browser ] [global options] + return `Usage: sk-az-tools login [--resources ] [--use-device-code] [--no-browser] [--browser ] [--browser-profile ] [global options] Options: --resources 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 Browser keyword: default|brave|browser|browserPrivate|chrome|edge|firefox`; + --browser Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox + --browser-profile Chromium profile name (e.g. Default, "Profile 1")`; } function usageLogout() { @@ -86,10 +87,10 @@ Options: } function usageTable() { - return `Usage: sk-az-tools table [--header|-H ] [global options] + return `Usage: sk-az-tools table [--header|-H ] [global options] Options: - -H, --header Header mode/spec: auto|a OR "col1, col2" OR "key1: Label 1, key2: Label 2"`; + -H, --header Header mode/spec: auto|a (default), original|o, OR "col1, col2" OR "key1: Label 1, key2: Label 2"`; } function usageCommand(command) { @@ -136,6 +137,7 @@ async function main() { "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" }, diff --git a/src/cli/commands.js b/src/cli/commands.js index 3f94425..1f45c24 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -48,6 +48,7 @@ async function runLoginCommand(values) { useDeviceCode: Boolean(values["use-device-code"]), noBrowser: Boolean(values["no-browser"]), browser: values.browser, + browserProfile: values["browser-profile"], }); } diff --git a/src/cli/utils.js b/src/cli/utils.js index 224ef17..1ee3053 100644 --- a/src/cli/utils.js +++ b/src/cli/utils.js @@ -12,13 +12,16 @@ export function outputFiltered(object, query) { export function parseHeaderSpec(headerValue) { if (!headerValue) { - return { mode: "default" }; + return { mode: "auto" }; } const raw = headerValue.trim(); if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") { return { mode: "auto" }; } + if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") { + return { mode: "original" }; + } const parts = raw.split(",").map((p) => p.trim()).filter(Boolean); const isMap = parts.some((p) => p.includes(":")); @@ -45,12 +48,71 @@ export function parseHeaderSpec(headerValue) { } export function normalizeOutputFormat(outputValue) { - const raw = (outputValue ?? "json").toLowerCase(); - if (raw === "json" || raw === "j") return "json"; + if (outputValue == null) { + return "json"; + } + + const raw = outputValue.toLowerCase(); + if (raw === "json") { + throw new Error("JSON is the default output. Omit --output to use it."); + } + if (raw === "j") { + throw new Error("JSON is the default output. Omit --output to use it."); + } if (raw === "table" || raw === "t") return "table"; if (raw === "alignedtable" || raw === "at") return "alignedtable"; if (raw === "prettytable" || raw === "pt") return "prettytable"; - throw new Error("--output must be one of: json|j, table|t, alignedtable|at, prettytable|pt"); + if (raw === "tsv") return "tsv"; + throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv"); +} + +function getScalarRowsAndHeaders(value) { + let rows; + if (Array.isArray(value)) { + rows = value.map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? item + : { value: item }, + ); + } else if (value && typeof value === "object") { + rows = [value]; + } else { + rows = [{ value }]; + } + + if (rows.length === 0) { + return { + headers: ["result"], + rows: [{ result: "" }], + }; + } + + const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] + .filter((key) => + rows.every((row) => { + const v = row[key]; + return v == null || typeof v !== "object"; + }), + ); + + if (headers.length === 0) { + return { + headers: ["result"], + rows: [{ result: "" }], + }; + } + + return { headers, rows }; +} + +function toTsv(value) { + const { headers, rows } = getScalarRowsAndHeaders(value); + const lines = rows.map((row) => + headers + .map((header) => (row[header] == null ? "" : String(row[header]).replaceAll("\t", " ").replaceAll("\n", " "))) + .join("\t"), + ); + return lines.join("\n"); } export function omitPermissionGuidColumns(value) { @@ -90,6 +152,11 @@ export async function readJsonFromStdin() { } export function renderOutput(command, output, outputFormat, headerSpec) { + if (outputFormat === "tsv") { + console.log(toTsv(output)); + return; + } + if (command === "table") { console.log(toMarkdownTable( output,