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
This commit is contained in:
@@ -15,9 +15,10 @@ WORKDIR /work
|
|||||||
|
|
||||||
COPY --from=package /package/*.tgz /tmp/sk-az-tools.tgz
|
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 \
|
&& rm /tmp/sk-az-tools.tgz \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
ENTRYPOINT ["sk-az-tools"]
|
ENTRYPOINT ["tini", "--", "sk-az-tools"]
|
||||||
CMD ["--help"]
|
CMD ["--help"]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const RESOURCE_SCOPE_BY_NAME = {
|
|||||||
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
|
const DEFAULT_RESOURCES = ["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 CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
||||||
|
|
||||||
function getCacheRoot() {
|
function getCacheRoot() {
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
@@ -80,21 +81,70 @@ function getBrowserAppName(browser) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requested = browser.trim().toLowerCase();
|
const keyword = BROWSER_KEYWORDS.find(
|
||||||
if (requested === "default") {
|
(name) => name.toLowerCase() === browser.trim().toLowerCase(),
|
||||||
return null;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const keyword = BROWSER_KEYWORDS.find((name) => name.toLowerCase() === requested);
|
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid browser '${browser}'. Allowed: default, ${BROWSER_KEYWORDS.join(", ")}`,
|
`Invalid browser '${browser}'. Allowed: ${BROWSER_KEYWORDS.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apps[keyword];
|
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) {
|
export function parseResources(resourcesCsv) {
|
||||||
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
||||||
return [...DEFAULT_RESOURCES];
|
return [...DEFAULT_RESOURCES];
|
||||||
@@ -217,11 +267,13 @@ export async function loginInteractive({
|
|||||||
scopes,
|
scopes,
|
||||||
showAuthUrlOnly = false,
|
showAuthUrlOnly = false,
|
||||||
browser,
|
browser,
|
||||||
|
browserProfile,
|
||||||
}) {
|
}) {
|
||||||
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 });
|
||||||
|
|
||||||
const pca = await createPca({ tenantId, clientId });
|
const pca = await createPca({ tenantId, clientId });
|
||||||
|
|
||||||
@@ -235,10 +287,7 @@ export async function loginInteractive({
|
|||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const browserName = getBrowserAppName(browser);
|
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||||
const options = browserName
|
|
||||||
? { wait: false, app: { name: browserName } }
|
|
||||||
: { wait: false };
|
|
||||||
return open(url, options).catch(() => {
|
return open(url, options).catch(() => {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
});
|
});
|
||||||
@@ -272,9 +321,11 @@ export async function login({
|
|||||||
useDeviceCode = false,
|
useDeviceCode = false,
|
||||||
noBrowser = false,
|
noBrowser = false,
|
||||||
browser,
|
browser,
|
||||||
|
browserProfile,
|
||||||
}) {
|
}) {
|
||||||
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 });
|
||||||
|
|
||||||
const resources = parseResources(resourcesCsv);
|
const resources = parseResources(resourcesCsv);
|
||||||
const scopes = getScopesForResources(resources);
|
const scopes = getScopesForResources(resources);
|
||||||
@@ -312,10 +363,7 @@ export async function login({
|
|||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const browserName = getBrowserAppName(browser);
|
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||||
const options = browserName
|
|
||||||
? { wait: false, app: { name: browserName } }
|
|
||||||
: { wait: false };
|
|
||||||
return open(url, options).catch(() => {
|
return open(url, options).catch(() => {
|
||||||
writeStderr(`Visit:\n${url}`);
|
writeStderr(`Visit:\n${url}`);
|
||||||
});
|
});
|
||||||
|
|||||||
12
src/cli.js
12
src/cli.js
@@ -26,7 +26,7 @@ Commands:
|
|||||||
|
|
||||||
Global options (all commands):
|
Global options (all commands):
|
||||||
-q, --query <jmespath>
|
-q, --query <jmespath>
|
||||||
-o, --output <format> json|j|table|t|alignedtable|at|prettytable|pt
|
-o, --output <format> table|t|alignedtable|at|prettytable|pt|tsv
|
||||||
-h, --help
|
-h, --help
|
||||||
|
|
||||||
Use: sk-az-tools --help <command>
|
Use: sk-az-tools --help <command>
|
||||||
@@ -43,13 +43,14 @@ Options:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usageLogin() {
|
function usageLogin() {
|
||||||
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [global options]
|
return `Usage: sk-az-tools login [--resources <csv>] [--use-device-code] [--no-browser] [--browser <name>] [--browser-profile <profile>] [global options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--resources <csv> Comma-separated resources: graph,devops,arm (default: all)
|
--resources <csv> Comma-separated resources: graph,devops,arm (default: all)
|
||||||
--use-device-code Use device code flow instead of interactive flow
|
--use-device-code Use device code flow instead of interactive flow
|
||||||
--no-browser Do not launch browser; print interactive URL to stderr
|
--no-browser Do not launch browser; print interactive URL to stderr
|
||||||
--browser <name> Browser keyword: default|brave|browser|browserPrivate|chrome|edge|firefox`;
|
--browser <name> Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox
|
||||||
|
--browser-profile <name> Chromium profile name (e.g. Default, "Profile 1")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageLogout() {
|
function usageLogout() {
|
||||||
@@ -86,10 +87,10 @@ Options:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usageTable() {
|
function usageTable() {
|
||||||
return `Usage: sk-az-tools table [--header|-H <spec|auto|a>] [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 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) {
|
||||||
@@ -136,6 +137,7 @@ async function main() {
|
|||||||
"use-device-code": { type: "boolean" },
|
"use-device-code": { type: "boolean" },
|
||||||
"no-browser": { type: "boolean" },
|
"no-browser": { type: "boolean" },
|
||||||
browser: { type: "string" },
|
browser: { type: "string" },
|
||||||
|
"browser-profile": { type: "string" },
|
||||||
all: { type: "boolean" },
|
all: { type: "boolean" },
|
||||||
resolve: { type: "boolean", short: "r" },
|
resolve: { type: "boolean", short: "r" },
|
||||||
short: { type: "boolean", short: "s" },
|
short: { type: "boolean", short: "s" },
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async function runLoginCommand(values) {
|
|||||||
useDeviceCode: Boolean(values["use-device-code"]),
|
useDeviceCode: Boolean(values["use-device-code"]),
|
||||||
noBrowser: Boolean(values["no-browser"]),
|
noBrowser: Boolean(values["no-browser"]),
|
||||||
browser: values.browser,
|
browser: values.browser,
|
||||||
|
browserProfile: values["browser-profile"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ export function outputFiltered(object, query) {
|
|||||||
|
|
||||||
export function parseHeaderSpec(headerValue) {
|
export function parseHeaderSpec(headerValue) {
|
||||||
if (!headerValue) {
|
if (!headerValue) {
|
||||||
return { mode: "default" };
|
return { mode: "auto" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = headerValue.trim();
|
const raw = headerValue.trim();
|
||||||
if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") {
|
if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") {
|
||||||
return { mode: "auto" };
|
return { mode: "auto" };
|
||||||
}
|
}
|
||||||
|
if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") {
|
||||||
|
return { mode: "original" };
|
||||||
|
}
|
||||||
|
|
||||||
const parts = raw.split(",").map((p) => p.trim()).filter(Boolean);
|
const parts = raw.split(",").map((p) => p.trim()).filter(Boolean);
|
||||||
const isMap = parts.some((p) => p.includes(":"));
|
const isMap = parts.some((p) => p.includes(":"));
|
||||||
@@ -45,12 +48,71 @@ export function parseHeaderSpec(headerValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeOutputFormat(outputValue) {
|
export function normalizeOutputFormat(outputValue) {
|
||||||
const raw = (outputValue ?? "json").toLowerCase();
|
if (outputValue == null) {
|
||||||
if (raw === "json" || raw === "j") return "json";
|
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 === "table" || raw === "t") return "table";
|
||||||
if (raw === "alignedtable" || raw === "at") return "alignedtable";
|
if (raw === "alignedtable" || raw === "at") return "alignedtable";
|
||||||
if (raw === "prettytable" || raw === "pt") return "prettytable";
|
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) {
|
export function omitPermissionGuidColumns(value) {
|
||||||
@@ -90,6 +152,11 @@ export async function readJsonFromStdin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderOutput(command, output, outputFormat, headerSpec) {
|
export function renderOutput(command, output, outputFormat, headerSpec) {
|
||||||
|
if (outputFormat === "tsv") {
|
||||||
|
console.log(toTsv(output));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === "table") {
|
if (command === "table") {
|
||||||
console.log(toMarkdownTable(
|
console.log(toMarkdownTable(
|
||||||
output,
|
output,
|
||||||
|
|||||||
Reference in New Issue
Block a user