From eff21107bde8b87c0201754af9b45ac74506065a Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 16:01:18 +0100 Subject: [PATCH 01/11] feat(portals): add an auth0 CLI transport Add a Management API client that runs `auth0 api` instead of talking to a local proxy. The CLI owns the login and the token, so the script never holds a credential. Request bodies go to the command on stdin: they carry client secrets, and process arguments are visible to every process on the machine. Failures are mapped to the errors the rest of the code already handles: the API's status, code and message become an ApiError, a missing scope becomes the 403 the permission check reads, and a session that cannot be used asks the user to log in. The first request runs alone, then up to four run together, so several processes never refresh an expired token at the same time. The client is not used yet. A later change selects it. Co-Authored-By: Claude --- .../universal-portals/_source/src/cli-api.ts | 182 ++++++++++++++++ .../_source/test/cli-api.test.ts | 194 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 internal/cli/data/universal-portals/_source/src/cli-api.ts create mode 100644 internal/cli/data/universal-portals/_source/test/cli-api.test.ts diff --git a/internal/cli/data/universal-portals/_source/src/cli-api.ts b/internal/cli/data/universal-portals/_source/src/cli-api.ts new file mode 100644 index 000000000..e730b4142 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/cli-api.ts @@ -0,0 +1,182 @@ +// Management API client that calls the Auth0 CLI's `auth0 api` command. The CLI owns the +// login and the token, so this module never holds a credential. Request bodies go to the +// command on stdin, because the bodies carry client secrets and arguments are visible to +// every process on the machine. + +import { spawn } from "node:child_process"; + +import { ApiClient, HttpMethod, toApiError } from "./api.js"; +import { PluginError } from "./plugin-error.js"; + +export interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export type RunCommand = (bin: string, args: string[], input: string | undefined) => Promise; + +export interface CliApiOptions { + // Path of the auth0 executable. + bin: string; + // Domain of the tenant to call. The CLI's active tenant is used when it is not set. + tenant?: string; + // Starts the process. Replaced in tests. + run?: RunCommand; + // Requests allowed at once after the first one has finished. + concurrency?: number; +} + +const DEFAULT_CONCURRENCY = 4; +const INSTALL_URL = "https://github.com/auth0/auth0-cli#installation"; + +// What the CLI prints when it cannot use the session. Each needs the user to log in again. +const SIGN_IN_PROBLEM = + /auth token expired|login session has expired|token is invalid|corrupted authentication token|failed to find tenant|config\.json file is missing/i; + +export function createCliApiClient(options: CliApiOptions): ApiClient { + const { bin, tenant, run = runCommand } = options; + const gate = createGate(options.concurrency ?? DEFAULT_CONCURRENCY); + + return { + async request(method: HttpMethod, path: string, body?: unknown): Promise { + await gate.enter(); + try { + const args = ["api", method, path.replace(/^\//, ""), "--no-color", "--no-input"]; + if (tenant) args.push("--tenant", tenant); + if (method === "DELETE") args.push("--force"); + + const result = await runOrExplain(run, bin, args, body === undefined ? undefined : JSON.stringify(body)); + if (result.exitCode !== 0) throw explainFailure(result.stderr, method, path, tenant); + return parseOutput(result.stdout, method, path); + } finally { + gate.leave(); + } + }, + }; +} + +// The first request runs alone. If the CLI has to refresh an expired token it saves the +// session file, and several processes doing that at once could damage it. Once one request +// has finished the token is fresh, so the rest can run side by side. +function createGate(concurrency: number): { enter(): Promise; leave(): void } { + let limit = 1; + let active = 0; + let firstFinished = false; + const waiting: Array<() => void> = []; + + const admit = (): void => { + while (active < limit && waiting.length > 0) { + active += 1; + waiting.shift()?.(); + } + }; + + return { + enter: () => + new Promise((resolve) => { + waiting.push(resolve); + admit(); + }), + leave: () => { + active -= 1; + if (!firstFinished) { + firstFinished = true; + limit = concurrency; + } + admit(); + }, + }; +} + +async function runOrExplain( + run: RunCommand, + bin: string, + args: string[], + input: string | undefined, +): Promise { + try { + return await run(bin, args, input); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new PluginError( + `The Auth0 CLI was not found (tried "${bin}"). Install it from ${INSTALL_URL}, or set AUTH0_CLI_BIN to its path.`, + "auth0_cli_not_found", + ); + } + throw error; + } +} + +function parseOutput(stdout: string, method: HttpMethod, path: string): T { + const text = stdout.trim(); + if (text === "") return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`auth0 api ${method} ${path} printed output that is not JSON: ${text.slice(0, 120)}`); + } +} + +function explainFailure(stderr: string, method: HttpMethod, path: string, tenant: string | undefined): Error { + // The CLI keeps only the first missing scope and drops the API's body, so the message is rebuilt + // in the shape the permission check already reads. + const scope = /lacks scope: ([A-Za-z0-9_:,-]+)/i.exec(stderr)?.[1]; + if (scope) { + return toApiError(403, { + statusCode: 403, + error: "Forbidden", + errorCode: "insufficient_scope", + message: `Insufficient scope, expected any of: ${scope}`, + }); + } + + const api = /(\d{3}): API request failed: (.*)$/m.exec(stderr); + if (api) return toApiError(Number(api[1]), parseBody(api[2])); + + if (SIGN_IN_PROBLEM.test(stderr)) { + return new PluginError( + `The Auth0 CLI cannot use ${tenant ?? "its active tenant"}: ${firstMessage(stderr)} Run auth0 login, then run this command again.`, + "auth0_login_required", + ["auth0 login"], + ); + } + + return new Error(`auth0 api ${method} ${path} failed: ${firstMessage(stderr)}`); +} + +// The CLI ends the message with a period after the API's JSON. +function parseBody(text: string): unknown { + for (const candidate of [text, text.replace(/\.$/, "")]) { + try { + return JSON.parse(candidate); + } catch { + // Try the next form. + } + } + return text; +} + +// The first line of the CLI's message, without its heading and marker. +function firstMessage(stderr: string): string { + const line = stderr + .split("\n") + .map((entry) => entry.trim()) + .find((entry) => entry !== "" && !entry.startsWith("===")); + return (line ?? "no message").replace(/^[^A-Za-z0-9]+/, ""); +} + +export function runCommand(bin: string, args: string[], input: string | undefined): Promise { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk)); + child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); + child.on("error", reject); + child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr })); + // The process may exit before it reads stdin, which is reported through its exit code. + child.stdin.on("error", () => undefined); + child.stdin.end(input ?? ""); + }); +} diff --git a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts new file mode 100644 index 000000000..5cdfd8ad5 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts @@ -0,0 +1,194 @@ +import { strict as assert } from "node:assert"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { ApiError } from "../src/api.js"; +import { CommandResult, createCliApiClient, RunCommand } from "../src/cli-api.js"; +import { PluginError } from "../src/plugin-error.js"; + +// What `auth0 api` prints on stderr when a request fails. The 400, 404 and unknown-tenant +// samples were recorded from the CLI (v1.32.0) with the tenant name replaced. The 403 and 409 +// samples are built from the CLI's own message formats, because the recording tenant's token +// had no missing scope and a conflict needs a write. +const STDERR = { + notFound: + "\n=== acme.us.auth0.com error\n\n ▸ 404: API request failed: " + + '{"statusCode":404,"error":"Not Found","message":"The client does not exist","errorCode":"inexistent_client"}.\n\n', + badQuery: + "\n=== acme.us.auth0.com error\n\n ▸ 400: API request failed: " + + `{"statusCode":400,"error":"Bad Request","message":"Query validation error: 'Value 9999 is greater than maximum 100' on property per_page (Number of results per page. Default value is 50, maximum value is 100).","errorCode":"invalid_query_string"}.\n\n`, + conflict: + "\n=== acme.us.auth0.com error\n\n ▸ 409: API request failed: " + + '{"statusCode":409,"error":"Conflict","message":"Slug already in use","errorCode":"portal_conflict"}.\n\n', + missingScope: + "\n=== acme.us.auth0.com error\n\n ▸ Request failed because access token lacks scope: read:portals.\n " + + "If authenticated via client credentials, add this scope to the designated client. " + + "If authenticated as a user, request this scope during login by running `auth0 login --scopes read:portals`.\n\n", + unknownTenant: + "\n=== nope.us.auth0.com error\n\n ▸ Failed to find tenant: nope.us.auth0.com. " + + "Run 'auth0 tenants list' to see your configured tenants or run 'auth0 login' to configure a new tenant.\n\n", + expiredSession: + "\n=== acme.us.auth0.com error\n\n ▸ Auth token expired and --no-input is set; run 'auth0 login' to re-authenticate.\n\n", + somethingElse: "\n=== acme.us.auth0.com error\n\n ▸ Failed to send request: dial tcp: lookup acme.us.auth0.com: no such host.\n\n", +}; + +interface Call { + bin: string; + args: string[]; + input: string | undefined; +} + +function runner(result: Partial): { run: RunCommand; calls: Call[] } { + const calls: Call[] = []; + const run: RunCommand = async (bin, args, input) => { + calls.push({ bin, args, input }); + return { exitCode: 0, stdout: "", stderr: "", ...result }; + }; + return { run, calls }; +} + +async function failure(stderr: string): Promise { + const { run } = runner({ exitCode: 1, stderr }); + const api = createCliApiClient({ bin: "auth0", tenant: "acme.us.auth0.com", run }); + try { + await api.request("GET", "clients/x"); + } catch (error) { + return error; + } + throw new Error("the request was expected to fail"); +} + +test("a successful request returns the parsed JSON from stdout", async () => { + const { run, calls } = runner({ stdout: '{\n "client_id": "abc"\n}\n' }); + const api = createCliApiClient({ bin: "auth0", tenant: "acme.us.auth0.com", run }); + + const result = await api.request("GET", "clients/abc?fields=client_id"); + + assert.deepEqual(result, { client_id: "abc" }); + assert.deepEqual(calls[0].args, [ + "api", "GET", "clients/abc?fields=client_id", "--no-color", "--no-input", "--tenant", "acme.us.auth0.com", + ]); +}); + +test("an empty response is undefined, like a 204 from the API", async () => { + const { run } = runner({ stdout: "" }); + const api = createCliApiClient({ bin: "auth0", run }); + + assert.equal(await api.request("PATCH", "clients/abc", { name: "x" }), undefined); +}); + +test("the request body travels on stdin and never in the arguments", async () => { + const secret = "s3cr3t-value-that-must-not-leak"; + const { run, calls } = runner({ stdout: "{}" }); + const api = createCliApiClient({ bin: "auth0", run }); + + await api.request("POST", "flows/vault/connections", { setup: { client_secret: secret } }); + + assert.equal(calls[0].input, JSON.stringify({ setup: { client_secret: secret } })); + assert.ok(!calls[0].args.join(" ").includes(secret), "the secret must not appear in the process arguments"); +}); + +test("a request without a body sends nothing on stdin, a leading slash is dropped, and DELETE is forced", async () => { + const { run, calls } = runner({}); + const api = createCliApiClient({ bin: "auth0", run }); + + await api.request("DELETE", "/client-grants/cgr_1"); + + assert.equal(calls[0].input, undefined); + assert.deepEqual(calls[0].args, ["api", "DELETE", "client-grants/cgr_1", "--no-color", "--no-input", "--force"]); +}); + +test("a 404 becomes an ApiError with the status, code and message from the API", async () => { + const error = await failure(STDERR.notFound); + + assert.ok(error instanceof ApiError); + assert.equal(error.status, 404); + assert.equal(error.errorCode, "inexistent_client"); + assert.equal(error.message, "The client does not exist"); +}); + +test("a 400 and a 409 keep their status and error code", async () => { + const bad = await failure(STDERR.badQuery); + const conflict = await failure(STDERR.conflict); + + assert.ok(bad instanceof ApiError && conflict instanceof ApiError); + assert.deepEqual([bad.status, bad.errorCode], [400, "invalid_query_string"]); + assert.deepEqual([conflict.status, conflict.errorCode], [409, "portal_conflict"]); +}); + +test("a missing scope becomes the 403 the preflight already understands", async () => { + const error = await failure(STDERR.missingScope); + + assert.ok(error instanceof ApiError); + assert.equal(error.status, 403); + assert.equal(error.errorCode, "insufficient_scope"); + assert.equal(error.message, "Insufficient scope, expected any of: read:portals"); +}); + +test("an expired session or an unknown tenant asks the user to log in", async () => { + for (const stderr of [STDERR.expiredSession, STDERR.unknownTenant]) { + const error = await failure(stderr); + assert.ok(error instanceof PluginError); + assert.equal(error.code, "auth0_login_required"); + assert.deepEqual(error.fixes, ["auth0 login"]); + assert.match(error.message, /auth0 login/); + } +}); + +test("any other failure is reported with the CLI's own message", async () => { + const error = await failure(STDERR.somethingElse); + + assert.ok(error instanceof Error && !(error instanceof ApiError)); + assert.match(error.message, /auth0 api GET clients\/x failed: Failed to send request: dial tcp/); +}); + +test("a missing auth0 binary tells the user how to install it or point to it", async () => { + const api = createCliApiClient({ bin: join(tmpdir(), "no-such-auth0-binary") }); + + await assert.rejects(api.request("GET", "clients"), (error: unknown) => { + assert.ok(error instanceof PluginError); + assert.equal(error.code, "auth0_cli_not_found"); + assert.match(error.message, /AUTH0_CLI_BIN/); + return true; + }); +}); + +test("the first request runs alone, then up to four run together", async () => { + let active = 0; + let peak = 0; + const peaks: number[] = []; + const run: RunCommand = async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + peaks.push(peak); + active -= 1; + return { exitCode: 0, stdout: "{}", stderr: "" }; + }; + const api = createCliApiClient({ bin: "auth0", run, concurrency: 4 }); + + await Promise.all(Array.from({ length: 9 }, (_, index) => api.request("GET", `clients/${index}`))); + + assert.equal(peaks[0], 1, "the first request must finish before any other starts"); + assert.equal(peak, 4); +}); + +test("the real runner passes the body on stdin to the process and reads its stdout", async () => { + const dir = mkdtempSync(join(tmpdir(), "up-fake-auth0-")); + const script = join(dir, "auth0"); + // A stand-in for the CLI: echoes its arguments and the stdin it received. + writeFileSync( + script, + '#!/usr/bin/env node\nlet input = "";\nprocess.stdin.on("data", (c) => (input += c));\n' + + 'process.stdin.on("end", () => console.log(JSON.stringify({ args: process.argv.slice(2), input })));\n', + ); + chmodSync(script, 0o755); + const api = createCliApiClient({ bin: script, tenant: "acme.us.auth0.com" }); + + const echoed = await api.request<{ args: string[]; input: string }>("POST", "clients", { name: "x" }); + + assert.deepEqual(JSON.parse(echoed.input), { name: "x" }); + assert.deepEqual(echoed.args.slice(0, 3), ["api", "POST", "clients"]); +}); From 0e1c01ae73e6239978aa6ccfae2334fe3d87dd2c Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 16:06:43 +0100 Subject: [PATCH 02/11] feat(portals): run on the auth0 CLI's tenant Decide how the script reaches the Management API when it starts. If the plugin framework passed a proxy, that proxy is used as before. If the Auth0 CLI started the script and passed its path and the tenant, requests go through `auth0 api`. Run by hand with nothing set, the script uses `auth0` on the PATH and the CLI's active tenant. The tenant comes from the --tenant flag first, then the environment, then the CLI's active tenant. One function builds the API client for every command. Tests can no longer reach the real CLI by accident: the test helper gives every run a runner that refuses to start a process, and a test fails if the empty-environment case ever calls it. Co-Authored-By: Claude --- .../_source/src/audit-command.ts | 7 +- .../universal-portals/_source/src/cli-api.ts | 42 ++++-- .../data/universal-portals/_source/src/cli.ts | 3 + .../universal-portals/_source/src/context.ts | 69 +++++++--- .../_source/src/setup-command.ts | 7 +- .../_source/test/api.test.ts | 24 ---- .../_source/test/cli.test.ts | 27 +++- .../_source/test/context.test.ts | 125 ++++++++++++++++++ 8 files changed, 236 insertions(+), 68 deletions(-) create mode 100644 internal/cli/data/universal-portals/_source/test/context.test.ts diff --git a/internal/cli/data/universal-portals/_source/src/audit-command.ts b/internal/cli/data/universal-portals/_source/src/audit-command.ts index d6c145880..98120067c 100644 --- a/internal/cli/data/universal-portals/_source/src/audit-command.ts +++ b/internal/cli/data/universal-portals/_source/src/audit-command.ts @@ -1,7 +1,6 @@ -import { createApiClient } from "./api.js"; import type { ParsedSetupArgs } from "./args.js"; import { CancelledError, type Prompter } from "./prompt.js"; -import { checkTenantFlag, readContext } from "./context.js"; +import { apiFor, checkTenantFlag, readContext } from "./context.js"; import { applyFixRounds, fixableFindings } from "./audit/fix.js"; import type { Finding } from "./audit/checks.js"; import { AuditOptions, AuditResult, runAudit } from "./audit/run.js"; @@ -19,9 +18,9 @@ export async function runAuditCommand( prompter: Prompter | undefined, ): Promise { try { - const context = readContext(env); + const context = await readContext(env, args.tenant, io.run); checkTenantFlag(args.tenant, context); - const api = createApiClient(context.proxyUrl, context.proxyToken, io.fetchImpl); + const api = apiFor(context, { fetchImpl: io.fetchImpl, run: io.run }); const portalId = args.portalId ?? ""; const organizationId = args.organization; const readSnapshot = () => fetchPortalSnapshot(api, portalId, context.tenantDomain, { organizationId }); diff --git a/internal/cli/data/universal-portals/_source/src/cli-api.ts b/internal/cli/data/universal-portals/_source/src/cli-api.ts index e730b4142..e0b10fa96 100644 --- a/internal/cli/data/universal-portals/_source/src/cli-api.ts +++ b/internal/cli/data/universal-portals/_source/src/cli-api.ts @@ -46,9 +46,10 @@ export function createCliApiClient(options: CliApiOptions): ApiClient { if (tenant) args.push("--tenant", tenant); if (method === "DELETE") args.push("--force"); + const what = `auth0 api ${method} ${path}`; const result = await runOrExplain(run, bin, args, body === undefined ? undefined : JSON.stringify(body)); - if (result.exitCode !== 0) throw explainFailure(result.stderr, method, path, tenant); - return parseOutput(result.stdout, method, path); + if (result.exitCode !== 0) throw explainFailure(result.stderr, what, tenant); + return parseOutput(result.stdout, what); } finally { gate.leave(); } @@ -108,17 +109,19 @@ async function runOrExplain( } } -function parseOutput(stdout: string, method: HttpMethod, path: string): T { +function parseOutput(stdout: string, what: string): T { const text = stdout.trim(); if (text === "") return undefined as T; try { return JSON.parse(text) as T; } catch { - throw new Error(`auth0 api ${method} ${path} printed output that is not JSON: ${text.slice(0, 120)}`); + throw new Error(`${what} printed output that is not JSON: ${text.slice(0, 120)}`); } } -function explainFailure(stderr: string, method: HttpMethod, path: string, tenant: string | undefined): Error { +// What went wrong, from the CLI's message. `what` names the command for the messages that +// have no better explanation. +function explainFailure(stderr: string, what: string, tenant: string | undefined): Error { // The CLI keeps only the first missing scope and drops the API's body, so the message is rebuilt // in the shape the permission check already reads. const scope = /lacks scope: ([A-Za-z0-9_:,-]+)/i.exec(stderr)?.[1]; @@ -135,14 +138,14 @@ function explainFailure(stderr: string, method: HttpMethod, path: string, tenant if (api) return toApiError(Number(api[1]), parseBody(api[2])); if (SIGN_IN_PROBLEM.test(stderr)) { - return new PluginError( - `The Auth0 CLI cannot use ${tenant ?? "its active tenant"}: ${firstMessage(stderr)} Run auth0 login, then run this command again.`, - "auth0_login_required", - ["auth0 login"], - ); + return loginRequired(`The Auth0 CLI cannot use ${tenant ?? "its active tenant"}: ${firstMessage(stderr)}`); } - return new Error(`auth0 api ${method} ${path} failed: ${firstMessage(stderr)}`); + return new Error(`${what} failed: ${firstMessage(stderr)}`); +} + +function loginRequired(message: string): PluginError { + return new PluginError(`${message} Run auth0 login, then run this command again.`, "auth0_login_required", ["auth0 login"]); } // The CLI ends the message with a period after the API's JSON. @@ -166,6 +169,23 @@ function firstMessage(stderr: string): string { return (line ?? "no message").replace(/^[^A-Za-z0-9]+/, ""); } +// The tenant the CLI is logged in to by default. +export async function findActiveTenant(bin: string, run: RunCommand = runCommand): Promise { + const what = "auth0 tenants list"; + const result = await runOrExplain(run, bin, ["tenants", "list", "--json", "--no-color", "--no-input"], undefined); + if (result.exitCode !== 0) throw explainFailure(result.stderr, what, undefined); + + let tenants: unknown; + try { + tenants = parseOutput(result.stdout, what); + } catch { + tenants = undefined; + } + const active = Array.isArray(tenants) ? tenants.find((tenant) => tenant?.active === true) : undefined; + if (typeof active?.name !== "string") throw loginRequired("The Auth0 CLI has no active tenant."); + return active.name; +} + export function runCommand(bin: string, args: string[], input: string | undefined): Promise { return new Promise((resolve, reject) => { const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] }); diff --git a/internal/cli/data/universal-portals/_source/src/cli.ts b/internal/cli/data/universal-portals/_source/src/cli.ts index e999d3d6b..6251028be 100644 --- a/internal/cli/data/universal-portals/_source/src/cli.ts +++ b/internal/cli/data/universal-portals/_source/src/cli.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url"; import { parseSetupArgs, ParsedSetupArgs, UsageError, usage } from "./args.js"; import type { FetchLike } from "./api.js"; import { clackPrompter } from "./clack-prompter.js"; +import type { RunCommand } from "./cli-api.js"; import type { Prompter } from "./prompt.js"; import { runAuditCommand } from "./audit-command.js"; import { describeError } from "./errors.js"; @@ -17,6 +18,8 @@ export interface IoStreams { stdinStream: NodeJS.ReadStream; isTTY: boolean; fetchImpl?: FetchLike; + // Starts the auth0 executable when requests go through the CLI. Replaced in tests. + run?: RunCommand; // Answers the questions. Defaults to the terminal when the run is interactive. prompter?: Prompter; // Opens the portal after setup. Defaults to the system browser; previews replace it. diff --git a/internal/cli/data/universal-portals/_source/src/context.ts b/internal/cli/data/universal-portals/_source/src/context.ts index b996597aa..be7620b04 100644 --- a/internal/cli/data/universal-portals/_source/src/context.ts +++ b/internal/cli/data/universal-portals/_source/src/context.ts @@ -1,16 +1,24 @@ -// The only module that reads the plugin runtime environment. The CLI sets -// these variables when it starts the plugin. +// The only module that reads the runtime environment and decides how the script reaches the +// Management API. There are three ways it can be started: +// - By the Auth0 CLI's plugin framework: a local proxy and its token are in the environment. +// - By the Auth0 CLI itself, which passes its own path and the tenant in AUTH0_CLI_BIN and +// AUTH0_CLI_TENANT_DOMAIN. Requests go through `auth0 api`. +// - By hand, with nothing set: `auth0` on the PATH, and the tenant the CLI is logged in to. +import { ApiClient, createApiClient, FetchLike } from "./api.js"; import { UsageError } from "./args.js"; +import { createCliApiClient, findActiveTenant, RunCommand, runCommand } from "./cli-api.js"; + +export type Transport = { kind: "proxy"; url: string; token: string } | { kind: "cli"; bin: string }; export interface PluginContext { - proxyUrl: string; - proxyToken: string; tenantDomain: string; + transport: Transport; } -// The --tenant flag must name the tenant the CLI is running against. The optional -// consequence says what the user can rely on, such as that nothing was written. +// The --tenant flag must name the tenant a proxy is bound to. The optional consequence says what +// the user can rely on, such as that nothing was written. When the script calls the CLI, the +// flag selects the tenant instead, so it always matches. export function checkTenantFlag(requested: string | undefined, context: PluginContext, consequence = ""): void { if (requested && requested !== context.tenantDomain) { throw new UsageError( @@ -19,24 +27,41 @@ export function checkTenantFlag(requested: string | undefined, context: PluginCo } } -export function readContext(env: NodeJS.ProcessEnv): PluginContext { +// The tenant is taken from the --tenant flag first, then AUTH0_CLI_TENANT_DOMAIN, then the CLI's +// active tenant. +export async function readContext( + env: NodeJS.ProcessEnv, + requestedTenant?: string, + run: RunCommand = runCommand, +): Promise { const proxyUrl = env.AUTH0_CLI_PROXY_URL; const proxyToken = env.AUTH0_CLI_PROXY_TOKEN; - const tenantDomain = env.AUTH0_CLI_TENANT_DOMAIN; - - const missing = [ - ["AUTH0_CLI_PROXY_URL", proxyUrl], - ["AUTH0_CLI_PROXY_TOKEN", proxyToken], - ["AUTH0_CLI_TENANT_DOMAIN", tenantDomain], - ] - .filter(([, value]) => !value) - .map(([name]) => name); - - if (missing.length > 0 || !proxyUrl || !proxyToken || !tenantDomain) { - throw new Error( - `Run this plugin through the Auth0 CLI (auth0 universal-portals). Missing: ${missing.join(", ")}`, - ); + + if (proxyUrl || proxyToken) { + const tenantDomain = env.AUTH0_CLI_TENANT_DOMAIN; + const missing = [ + ["AUTH0_CLI_PROXY_URL", proxyUrl], + ["AUTH0_CLI_PROXY_TOKEN", proxyToken], + ["AUTH0_CLI_TENANT_DOMAIN", tenantDomain], + ] + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length > 0 || !proxyUrl || !proxyToken || !tenantDomain) { + throw new Error( + `The Auth0 CLI started this command with an incomplete environment. Missing: ${missing.join(", ")}`, + ); + } + return { tenantDomain, transport: { kind: "proxy", url: proxyUrl, token: proxyToken } }; } - return { proxyUrl, proxyToken, tenantDomain }; + const bin = env.AUTH0_CLI_BIN || "auth0"; + const tenantDomain = requestedTenant || env.AUTH0_CLI_TENANT_DOMAIN || (await findActiveTenant(bin, run)); + return { tenantDomain, transport: { kind: "cli", bin } }; +} + +// Builds the API client for the context, in one place for every command. +export function apiFor(context: PluginContext, options: { fetchImpl?: FetchLike; run?: RunCommand } = {}): ApiClient { + const { transport } = context; + if (transport.kind === "proxy") return createApiClient(transport.url, transport.token, options.fetchImpl); + return createCliApiClient({ bin: transport.bin, tenant: context.tenantDomain, run: options.run }); } diff --git a/internal/cli/data/universal-portals/_source/src/setup-command.ts b/internal/cli/data/universal-portals/_source/src/setup-command.ts index 74c8afc6a..901767a0b 100644 --- a/internal/cli/data/universal-portals/_source/src/setup-command.ts +++ b/internal/cli/data/universal-portals/_source/src/setup-command.ts @@ -1,6 +1,5 @@ -import { createApiClient } from "./api.js"; import { missingFlags, ParsedSetupArgs, UsageError } from "./args.js"; -import { checkTenantFlag, readContext } from "./context.js"; +import { apiFor, checkTenantFlag, readContext } from "./context.js"; import { openBrowser } from "./browser.js"; import { CancelledError, type Prompter } from "./prompt.js"; import type { TemplateId } from "./templates/types.js"; @@ -20,7 +19,7 @@ export async function runSetupCommand( prompter: Prompter | undefined, ): Promise { try { - const context = readContext(env); + const context = await readContext(env, args.tenant, io.run); checkTenantFlag(args.tenant, context, " Nothing was created."); if (!interactive) { const missing = missingFlags(args); @@ -28,7 +27,7 @@ export async function runSetupCommand( throw new UsageError(`Missing required flags for a non-interactive run: ${missing.join(", ")}`); } } - const tracked = trackWrites(createApiClient(context.proxyUrl, context.proxyToken, io.fetchImpl)); + const tracked = trackWrites(apiFor(context, { fetchImpl: io.fetchImpl, run: io.run })); ui.banner("Universal Portals setup", context.tenantDomain); try { diff --git a/internal/cli/data/universal-portals/_source/test/api.test.ts b/internal/cli/data/universal-portals/_source/test/api.test.ts index 1deecd780..2184f768f 100644 --- a/internal/cli/data/universal-portals/_source/test/api.test.ts +++ b/internal/cli/data/universal-portals/_source/test/api.test.ts @@ -2,7 +2,6 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; import { ApiClient, ApiError, createApiClient, listAll, listItems, type FetchLike } from "../src/api.js"; -import { readContext } from "../src/context.js"; function fakeFetch(status: number, body: unknown): { fetch: FetchLike; calls: { url: string; init: RequestInit }[] } { const calls: { url: string; init: RequestInit }[] = []; @@ -77,26 +76,3 @@ test("listItems reads both array responses and keyed responses", () => { assert.deepEqual(listItems({ portals: [{ id: 2 }] }, "portals"), [{ id: 2 }]); assert.deepEqual(listItems({ other: [] }, "portals"), []); }); - -test("readContext names every missing variable", () => { - assert.throws(() => readContext({}), (error: unknown) => { - assert.ok(error instanceof Error); - assert.match(error.message, /AUTH0_CLI_PROXY_URL/); - assert.match(error.message, /AUTH0_CLI_PROXY_TOKEN/); - assert.match(error.message, /AUTH0_CLI_TENANT_DOMAIN/); - return true; - }); -}); - -test("readContext returns the three values when all are set", () => { - const context = readContext({ - AUTH0_CLI_PROXY_URL: "https://127.0.0.1:1", - AUTH0_CLI_PROXY_TOKEN: "t", - AUTH0_CLI_TENANT_DOMAIN: "acme.us.auth0.com", - }); - assert.deepEqual(context, { - proxyUrl: "https://127.0.0.1:1", - proxyToken: "t", - tenantDomain: "acme.us.auth0.com", - }); -}); diff --git a/internal/cli/data/universal-portals/_source/test/cli.test.ts b/internal/cli/data/universal-portals/_source/test/cli.test.ts index b617af160..e3458a791 100644 --- a/internal/cli/data/universal-portals/_source/test/cli.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli.test.ts @@ -50,6 +50,11 @@ function captureIo(fetchImpl: FetchLike, isTTY = false): { io: IoStreams; stdout stdinStream: process.stdin, isTTY, fetchImpl, + // A test must never start the real auth0 CLI: it would call a real tenant. Tests that + // need the CLI pass their own runner. + run: async () => { + throw new Error("a test tried to start the real auth0 CLI"); + }, }; return { io, stdout: () => out, stderr: () => captureErr.text() }; } @@ -322,12 +327,28 @@ test("a non-interactive run without the required flags stops before any API call assert.equal(tenant.calls.length, 0); }); -test("a run outside the CLI names the variables it is missing", async () => { - const { io, stderr } = captureIo(fetchFor(new FakeTenant())); +test("a run without the Auth0 CLI installed says how to install it, and calls nothing", async () => { + const tenant = new FakeTenant(); + const { io, stderr } = captureIo(fetchFor(tenant)); + io.run = async () => { + throw Object.assign(new Error("spawn auth0 ENOENT"), { code: "ENOENT" }); + }; const code = await main(["setup", "--no-input", "--template", "consumer", "--name", "x"], {}, io); assert.equal(code, 1); - assert.match(stderr(), /AUTH0_CLI_PROXY_URL, AUTH0_CLI_PROXY_TOKEN, AUTH0_CLI_TENANT_DOMAIN/); + assert.match(stderr(), /The Auth0 CLI was not found/); + assert.match(stderr(), /AUTH0_CLI_BIN/); + assert.equal(tenant.calls.length, 0); +}); + +test("a half-set proxy environment stops the run before any API call", async () => { + const tenant = new FakeTenant(); + const { io, stderr } = captureIo(fetchFor(tenant)); + const code = await main(["setup", "--no-input", "--template", "consumer", "--name", "x"], { AUTH0_CLI_PROXY_TOKEN: "t" }, io); + + assert.equal(code, 1); + assert.match(stderr(), /Missing: AUTH0_CLI_PROXY_URL, AUTH0_CLI_TENANT_DOMAIN/); + assert.equal(tenant.calls.length, 0); }); test("a non-interactive consumer run prints the result as JSON and never the full secret", async () => { diff --git a/internal/cli/data/universal-portals/_source/test/context.test.ts b/internal/cli/data/universal-portals/_source/test/context.test.ts new file mode 100644 index 000000000..985cdb6ca --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/context.test.ts @@ -0,0 +1,125 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { RunCommand } from "../src/cli-api.js"; +import { apiFor, readContext } from "../src/context.js"; +import { PluginError } from "../src/plugin-error.js"; + +const PROXY_ENV = { + AUTH0_CLI_PROXY_URL: "https://127.0.0.1:1", + AUTH0_CLI_PROXY_TOKEN: "proxy-token", + AUTH0_CLI_TENANT_DOMAIN: "acme.us.auth0.com", +}; + +// `auth0 tenants list --json`, recorded from the CLI with the names replaced. +const TENANTS = JSON.stringify([ + { active: false, name: "other.eu.auth0.com" }, + { active: true, name: "acme.us.auth0.com" }, +]); + +function runner(result: { exitCode?: number; stdout?: string; stderr?: string } = {}) { + const calls: { bin: string; args: string[] }[] = []; + const run: RunCommand = async (bin, args) => { + calls.push({ bin, args }); + return { exitCode: 0, stdout: "", stderr: "", ...result }; + }; + return { run, calls }; +} + +test("started by the framework, the proxy variables are used as they are", async () => { + const { run, calls } = runner(); + + const context = await readContext(PROXY_ENV, undefined, run); + + assert.deepEqual(context, { + tenantDomain: "acme.us.auth0.com", + transport: { kind: "proxy", url: "https://127.0.0.1:1", token: "proxy-token" }, + }); + assert.equal(calls.length, 0, "nothing needs to be asked of the CLI"); +}); + +test("a half-set proxy environment names every variable that is missing", async () => { + await assert.rejects(readContext({ AUTH0_CLI_PROXY_TOKEN: "t" }), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /AUTH0_CLI_PROXY_URL/); + assert.match(error.message, /AUTH0_CLI_TENANT_DOMAIN/); + assert.doesNotMatch(error.message, /AUTH0_CLI_PROXY_TOKEN,/); + assert.match(error.message, /Auth0 CLI/); + return true; + }); +}); + +test("started by the CLI with its path and tenant, nothing is discovered", async () => { + const { run, calls } = runner(); + + const context = await readContext( + { AUTH0_CLI_BIN: "/opt/bin/auth0", AUTH0_CLI_TENANT_DOMAIN: "acme.us.auth0.com" }, + undefined, + run, + ); + + assert.deepEqual(context, { tenantDomain: "acme.us.auth0.com", transport: { kind: "cli", bin: "/opt/bin/auth0" } }); + assert.equal(calls.length, 0); +}); + +test("the --tenant flag wins over the tenant in the environment", async () => { + const context = await readContext( + { AUTH0_CLI_BIN: "auth0", AUTH0_CLI_TENANT_DOMAIN: "acme.us.auth0.com" }, + "other.eu.auth0.com", + runner().run, + ); + + assert.equal(context.tenantDomain, "other.eu.auth0.com"); +}); + +test("run by hand, the CLI's active tenant is used", async () => { + const { run, calls } = runner({ stdout: TENANTS }); + + const context = await readContext({}, undefined, run); + + assert.equal(context.tenantDomain, "acme.us.auth0.com"); + assert.deepEqual(context.transport, { kind: "cli", bin: "auth0" }); + assert.deepEqual(calls, [{ bin: "auth0", args: ["tenants", "list", "--json", "--no-color", "--no-input"] }]); +}); + +test("run by hand with --tenant, the CLI is not asked which tenant is active", async () => { + const { run, calls } = runner({ stdout: TENANTS }); + + const context = await readContext({}, "other.eu.auth0.com", run); + + assert.equal(context.tenantDomain, "other.eu.auth0.com"); + assert.equal(calls.length, 0); +}); + +test("with no active tenant, or no login at all, the user is asked to log in", async () => { + const cases = [ + runner({ stdout: JSON.stringify([{ active: false, name: "acme.us.auth0.com" }]) }), + runner({ stdout: "[]" }), + runner({ exitCode: 1, stderr: "\n=== error\n\n ▸ config.json file is missing.\n\n" }), + ]; + + for (const { run } of cases) { + await assert.rejects(readContext({}, undefined, run), (error: unknown) => { + assert.ok(error instanceof PluginError); + assert.equal(error.code, "auth0_login_required"); + assert.deepEqual(error.fixes, ["auth0 login"]); + return true; + }); + } +}); + +test("the API client follows the transport: proxy with the token, CLI with the tenant", async () => { + const requests: string[] = []; + const fetchImpl = async (url: string, init: RequestInit) => { + requests.push(`${url} ${(init.headers as Record).authorization}`); + return new Response("{}", { status: 200 }); + }; + const proxy = apiFor(await readContext(PROXY_ENV), { fetchImpl }); + await proxy.request("GET", "clients"); + assert.deepEqual(requests, ["https://127.0.0.1:1/api/v2/clients Bearer proxy-token"]); + + const { run, calls } = runner({ stdout: "{}" }); + const cli = apiFor(await readContext({ AUTH0_CLI_BIN: "auth0", AUTH0_CLI_TENANT_DOMAIN: "acme.us.auth0.com" }), { run }); + await cli.request("GET", "clients"); + assert.deepEqual(calls[0].args.slice(-2), ["--tenant", "acme.us.auth0.com"]); +}); From 21ed91704ed0b9f9c6265648c1a53dcd78688605 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 16:46:44 +0100 Subject: [PATCH 03/11] fix(portals): say how to get a missing scope in any command Only setup checked scopes, and it printed the login command twice. Audit and every other call showed a bare "Insufficient scope" error. A missing scope is now explained the same way everywhere, and the advice covers both ways the Auth0 CLI can be signed in. A user login can ask for more scopes with `auth0 login --scopes`. A client-credentials (machine to machine) login cannot, so the scope has to be added to that application's Management API grant. When any one of several scopes is enough, the first one is suggested. Co-Authored-By: Claude --- .../data/universal-portals/_source/src/errors.ts | 9 ++++++++- .../universal-portals/_source/src/scope-fixes.ts | 9 +++++++++ .../universal-portals/_source/src/setup/run.ts | 6 ++---- .../universal-portals/_source/test/cli.test.ts | 15 ++++++++++++++- .../_source/test/errors.test.ts | 16 +++++++++++++++- .../_source/test/scope-fixes.test.ts | 11 +++++++++++ 6 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 internal/cli/data/universal-portals/_source/src/scope-fixes.ts create mode 100644 internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts diff --git a/internal/cli/data/universal-portals/_source/src/errors.ts b/internal/cli/data/universal-portals/_source/src/errors.ts index 6cf419951..4941dccb5 100644 --- a/internal/cli/data/universal-portals/_source/src/errors.ts +++ b/internal/cli/data/universal-portals/_source/src/errors.ts @@ -2,6 +2,8 @@ import { ApiError } from "./api.js"; import { UsageError } from "./args.js"; import type { IoStreams } from "./cli.js"; import { PluginError } from "./plugin-error.js"; +import { scopeFixes } from "./scope-fixes.js"; +import { parseScopeGap } from "./setup/preflight.js"; import type { Ui } from "./ui.js"; export function reportError(error: unknown, json: boolean, io: IoStreams, ui: Ui): number { @@ -20,7 +22,12 @@ export function describeError(error: unknown): { code: string; message: string; return { code: error.code, message: error.message, details: error.fixes ? { fixes: error.fixes } : {} }; } if (error instanceof ApiError) { - return { code: error.errorCode ?? "api_error", message: error.message, details: { status: error.status } }; + // A missing scope is fixed the same way whichever command hit it. When any one of several + // scopes is enough, the first is suggested. + const gap = parseScopeGap("", error); + const scopes = gap && gap.requirement === "any" ? gap.scopes.slice(0, 1) : (gap?.scopes ?? []); + const fixes = scopes.length > 0 ? { fixes: scopeFixes(scopes) } : {}; + return { code: error.errorCode ?? "api_error", message: error.message, details: { status: error.status, ...fixes } }; } if (error instanceof Error) return { code: "setup_failed", message: error.message, details: {} }; return { code: "setup_failed", message: String(error), details: {} }; diff --git a/internal/cli/data/universal-portals/_source/src/scope-fixes.ts b/internal/cli/data/universal-portals/_source/src/scope-fixes.ts new file mode 100644 index 000000000..2323f023c --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/scope-fixes.ts @@ -0,0 +1,9 @@ +// What to do when the session lacks a scope. The advice depends on how the Auth0 CLI is signed in: +// a user login can ask for more scopes, while a client-credentials login (machine to machine) has +// only the scopes granted to its application, so they must be added there. +export function scopeFixes(scopes: string[]): string[] { + return [ + `Signed in as a user: auth0 login --scopes ${scopes.join(",")}`, + `Signed in with client credentials: add ${scopes.join(", ")} to the application's Management API grant`, + ]; +} diff --git a/internal/cli/data/universal-portals/_source/src/setup/run.ts b/internal/cli/data/universal-portals/_source/src/setup/run.ts index e086b87a3..bf04f720a 100644 --- a/internal/cli/data/universal-portals/_source/src/setup/run.ts +++ b/internal/cli/data/universal-portals/_source/src/setup/run.ts @@ -1,5 +1,6 @@ import { ApiClient } from "../api.js"; import { PluginError } from "../plugin-error.js"; +import { scopeFixes } from "../scope-fixes.js"; import { applicationUrl, flowEditUrl, formEditUrl, portalEditUrl } from "../dashboard.js"; import { buildPortalForms, FORM_NAMES, FormKey } from "../forms.js"; import { isValidPortalSlug } from "../portal-model.js"; @@ -56,7 +57,7 @@ export class MissingScopesError extends PluginError { ? `The CLI session is missing ${scopes.length} scope(s): ${scopes.join(", ")}.` : "The CLI session is missing a scope it needs.", "missing_scopes", - scopes.length > 0 ? [`auth0 login --scopes ${scopes.join(",")}`] : undefined, + scopes.length > 0 ? scopeFixes(scopes) : undefined, ); } } @@ -229,9 +230,6 @@ async function checkPermissions(probes: Probe[], deps: SetupDeps): Promise deps.ui.error("The CLI session is missing permissions setup needs"); for (const scope of required) deps.ui.detail(scope); for (const choice of choices) deps.ui.detail(`one of: ${choice.scopes.join(", ")}`); - if (required.length > 0) { - deps.ui.detail(`Run: auth0 login --scopes ${required.join(",")}`); - } throw new MissingScopesError(required, choices); } diff --git a/internal/cli/data/universal-portals/_source/test/cli.test.ts b/internal/cli/data/universal-portals/_source/test/cli.test.ts index e3458a791..d5a58f415 100644 --- a/internal/cli/data/universal-portals/_source/test/cli.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli.test.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { ApiError, FetchLike } from "../src/api.js"; import { main, IoStreams } from "../src/cli.js"; import { missingFlags, parseSetupArgs, UsageError } from "../src/args.js"; +import { scopeFixes } from "../src/scope-fixes.js"; import { toPortalSlug } from "../src/slug.js"; import type { Prompter } from "../src/prompt.js"; import { captureOutput } from "./capture-output.js"; @@ -235,6 +236,18 @@ async function auditInTerminal(tenant: FakeTenant, portalId: string, prompter: P return { code, stderr: stderr() }; } +test("audit tells the user how to get a missing scope, for a user login and a client-credentials login", async () => { + const { tenant, portalId } = await tenantWithPortal(); + tenant.deny(`GET portals/${portalId}`, "Insufficient scope, expected any of: read:portals"); + const { io, stderr } = captureIo(fetchFor(tenant)); + + const code = await main(["audit", portalId], ENV, io); + + assert.equal(code, 1); + assert.match(stderr(), /Signed in as a user: auth0 login --scopes read:portals/); + assert.match(stderr(), /Signed in with client credentials: add read:portals to the application's Management API grant/); +}); + test("the fix menu's 'fix all' applies every error and the portal passes the audit", async () => { const { tenant, portalId } = await tenantWithPortal(); tenant.portals.get(portalId)!.navigation.sidebar.components = [LEGACY_LINK]; @@ -382,7 +395,7 @@ test("in agent mode a missing scope is reported as a JSON error on stderr", asyn assert.equal(stdout(), ""); const envelope = JSON.parse(stderr()); assert.equal(envelope.error, "missing_scopes"); - assert.deepEqual(envelope.fixes, ["auth0 login --scopes create:portals"]); + assert.deepEqual(envelope.fixes, scopeFixes(["create:portals"])); }); test("a Business run that is missing its user stops with a usage error", async () => { diff --git a/internal/cli/data/universal-portals/_source/test/errors.test.ts b/internal/cli/data/universal-portals/_source/test/errors.test.ts index 057d2c6ed..b81a58fed 100644 --- a/internal/cli/data/universal-portals/_source/test/errors.test.ts +++ b/internal/cli/data/universal-portals/_source/test/errors.test.ts @@ -6,6 +6,7 @@ import { UsageError } from "../src/args.js"; import { describeError } from "../src/errors.js"; import { BusinessAccessError } from "../src/setup/business-types.js"; import { FeatureNotEnabledError } from "../src/setup/preflight.js"; +import { scopeFixes } from "../src/scope-fixes.js"; import { MissingScopesError, SlugRaceError } from "../src/setup/run.js"; test("each error reports its own code, and the fixes it names", () => { @@ -16,7 +17,10 @@ test("each error reports its own code, and the fixes it names", () => { fixes: ["Rerun with --user."], }); assert.deepEqual(describeError(new MissingScopesError(["read:clients"], [])).details, { - fixes: ["auth0 login --scopes read:clients"], + fixes: [ + "Signed in as a user: auth0 login --scopes read:clients", + "Signed in with client credentials: add read:clients to the application's Management API grant", + ], }); }); @@ -25,6 +29,16 @@ test("an error with no fixes leaves fixes out of the JSON", () => { assert.equal("fixes" in details, false); }); +test("an API error about a missing scope carries the fixes, whichever command hit it", () => { + const anyOf = new ApiError(403, "insufficient_scope", "Insufficient scope, expected any of: read:portals,update:portals", {}); + const allOf = new ApiError(403, "insufficient_scope", "Insufficient scope, expected all of: read:portals,update:portals", {}); + const other = new ApiError(403, "forbidden", "Forbidden", {}); + + assert.deepEqual(describeError(anyOf).details.fixes, scopeFixes(["read:portals"]), "any one of them is enough"); + assert.deepEqual(describeError(allOf).details.fixes, scopeFixes(["read:portals", "update:portals"])); + assert.equal("fixes" in describeError(other).details, false); +}); + test("an API error keeps the code the API sent", () => { const error = new ApiError(403, "insufficient_scope", "Insufficient scope", {}); assert.deepEqual(describeError(error), { code: "insufficient_scope", message: "Insufficient scope", details: { status: 403 } }); diff --git a/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts b/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts new file mode 100644 index 000000000..56f240e3a --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/scope-fixes.test.ts @@ -0,0 +1,11 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { scopeFixes } from "../src/scope-fixes.js"; + +test("a missing scope has one fix for a user login and one for a client-credentials login", () => { + assert.deepEqual(scopeFixes(["read:portals", "create:portals"]), [ + "Signed in as a user: auth0 login --scopes read:portals,create:portals", + "Signed in with client credentials: add read:portals, create:portals to the application's Management API grant", + ]); +}); From 45897daacd2103bc151c8f5710de3b12dcc1a916 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 16:47:57 +0100 Subject: [PATCH 04/11] perf(portals): read independent resources in parallel Each call through the Auth0 CLI takes about a second and a half, mostly the CLI starting up, so reading one thing after another made the permission check and the audit slow. The permission probes are now sent together, and their answers are read in the order of the probes, so the report and the first error do not depend on which answer came back first. The audit reads the application with its grants and connections, the resource servers, the forms, the custom domain and the organization together, once the portal itself is read. Writes stay one at a time. Co-Authored-By: Claude --- .../_source/src/audit/snapshot.ts | 52 ++++++++----- .../_source/src/setup/preflight.ts | 35 ++++++--- .../_source/test/parallel.test.ts | 73 +++++++++++++++++++ 3 files changed, 129 insertions(+), 31 deletions(-) create mode 100644 internal/cli/data/universal-portals/_source/test/parallel.test.ts diff --git a/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts b/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts index d41c25f5c..10719ede0 100644 --- a/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts +++ b/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts @@ -74,25 +74,18 @@ export async function fetchPortalSnapshot( ): Promise { const portal = await api.request("GET", `portals/${encodeURIComponent(portalId)}`); - let application: ApplicationShape | undefined; - let grants: PortalSnapshot["grants"] = []; - let applicationConnections: PortalSnapshot["applicationConnections"] = []; - const clientId = portal.client?.client_id; - if (clientId) application = await readApplication(api, clientId); - if (application && clientId) { - grants = await listAll(api, `client-grants?client_id=${encodeURIComponent(clientId)}`, "client_grants"); - applicationConnections = await listAll<{ id: string; name?: string }>(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections"); - } - - const resourceServers = (await listAll<{ identifier: string }>(api, "resource-servers", "resource_servers")).map((server) => server.identifier); - - const forms: Record = {}; - for (const formId of formIdsOf(portal)) { - forms[formId] = await formExists(api, formId); - } - - const customDomain = await readCustomDomain(api); - const organization = options.organizationId ? await readOrganization(api, options.organizationId) : undefined; + // Once the portal is read, what else the audit needs does not depend on each other, so it is + // read together. + const [applicationParts, resourceServers, forms, customDomain, organization] = await Promise.all([ + readApplicationParts(api, portal.client?.client_id), + listAll<{ identifier: string }>(api, "resource-servers", "resource_servers").then((servers) => + servers.map((server) => server.identifier), + ), + readForms(api, portal), + readCustomDomain(api), + options.organizationId ? readOrganization(api, options.organizationId) : undefined, + ]); + const { application, grants, applicationConnections } = applicationParts; return { tenantDomain, @@ -107,6 +100,27 @@ export async function fetchPortalSnapshot( }; } +// The application, and when it exists its grants and connections. +async function readApplicationParts( + api: ApiClient, + clientId: string | undefined, +): Promise> { + const application = clientId ? await readApplication(api, clientId) : undefined; + if (!application || !clientId) return { application, grants: [], applicationConnections: [] }; + + const [grants, applicationConnections] = await Promise.all([ + listAll(api, `client-grants?client_id=${encodeURIComponent(clientId)}`, "client_grants"), + listAll<{ id: string; name?: string }>(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections"), + ]); + return { application, grants, applicationConnections }; +} + +async function readForms(api: ApiClient, portal: PortalShape): Promise> { + const formIds = formIdsOf(portal); + const exist = await Promise.all(formIds.map((formId) => formExists(api, formId))); + return Object.fromEntries(formIds.map((formId, index) => [formId, exist[index]])); +} + export function formIdsOf(portal: PortalShape): string[] { const ids: string[] = []; const visit = (components: ComponentShape[] = []) => { diff --git a/internal/cli/data/universal-portals/_source/src/setup/preflight.ts b/internal/cli/data/universal-portals/_source/src/setup/preflight.ts index 13d9a558d..f456d702a 100644 --- a/internal/cli/data/universal-portals/_source/src/setup/preflight.ts +++ b/internal/cli/data/universal-portals/_source/src/setup/preflight.ts @@ -65,19 +65,30 @@ export async function runPreflight( const gaps: ScopeGap[] = []; const unexpected: PreflightResult["unexpected"] = []; - for (const probe of probes) { - try { - await api.request(probe.method, probe.path, probe.body); - } catch (error) { - if (!(error instanceof ApiError)) throw error; - if (error.status === 400) continue; - if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); - const gap = parseScopeGap(probe.name, error); - if (gap) { - gaps.push(gap); - } else { - unexpected.push({ probe: probe.name, status: error.status, message: error.message }); + // The probes do not depend on each other, so they are sent together. The answers are read in + // the order of the probes, so the report and the first error do not depend on which came first. + const failures = await Promise.all( + probes.map(async (probe) => { + try { + await api.request(probe.method, probe.path, probe.body); + return undefined; + } catch (error) { + return { probe, error }; } + }), + ); + + for (const failure of failures) { + if (!failure) continue; + const { probe, error } = failure; + if (!(error instanceof ApiError)) throw error; + if (error.status === 400) continue; + if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); + const gap = parseScopeGap(probe.name, error); + if (gap) { + gaps.push(gap); + } else { + unexpected.push({ probe: probe.name, status: error.status, message: error.message }); } } diff --git a/internal/cli/data/universal-portals/_source/test/parallel.test.ts b/internal/cli/data/universal-portals/_source/test/parallel.test.ts new file mode 100644 index 000000000..4cb10e92f --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/parallel.test.ts @@ -0,0 +1,73 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { ApiClient, HttpMethod } from "../src/api.js"; +import { fetchPortalSnapshot } from "../src/audit/snapshot.js"; +import { runPreflight, SETUP_PROBES } from "../src/setup/preflight.js"; +import { runSetup } from "../src/setup/run.js"; +import { Ui } from "../src/ui.js"; +import { captureOutput } from "./capture-output.js"; +import { FakeTenant } from "./fake-tenant.js"; + +const DOMAIN = "acme.us.auth0.com"; + +// Answers like a real network: each request takes a while, and the delay can differ per request, so +// answers arrive out of order. It records the most requests that were in flight at once. +function slowNetwork(tenant: FakeTenant, delayFor: (method: HttpMethod, path: string) => number) { + let active = 0; + let peak = 0; + const api: ApiClient = { + async request(method: HttpMethod, path: string, body?: unknown): Promise { + active += 1; + peak = Math.max(peak, active); + try { + await new Promise((resolve) => setTimeout(resolve, delayFor(method, path))); + return await tenant.request(method, path, body); + } finally { + active -= 1; + } + }, + }; + return { api, peak: () => peak }; +} + +test("the permission probes run together, and the gaps keep the order of the probes", async () => { + const tenant = new FakeTenant(); + tenant.deny("POST clients", "Insufficient scope, expected any of: create:clients"); + tenant.deny("POST portals", "Insufficient scope, expected any of: create:portals"); + // The first gap answers last, the second answers first. + const network = slowNetwork(tenant, (_method, path) => (path === "clients" ? 40 : 2)); + + const result = await runPreflight(network.api, SETUP_PROBES, DOMAIN); + + assert.deepEqual(result.gaps.map((gap) => gap.scopes), [["create:clients"], ["create:portals"]]); + assert.ok(network.peak() > 1, "the probes were sent one after another"); +}); + +test("the audit reads the independent parts of a portal together and gets the same snapshot", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const quiet = new Ui(captureOutput().stream, false, true); + const setup = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { + api: tenant, + ui: quiet, + prompt: { + ask: async (_q, d) => d ?? "", + confirm: async (_q, d) => d, + choose: async (_q, o) => o[0].value, + checklist: async () => [], + }, + interactive: false, + tenantDomain: DOMAIN, + }, + ); + const sequential = await fetchPortalSnapshot(tenant, setup.portal.id, DOMAIN); + + const network = slowNetwork(tenant, () => 10); + const together = await fetchPortalSnapshot(network.api, setup.portal.id, DOMAIN); + + assert.deepEqual(together, sequential); + assert.ok(network.peak() > 1, "the reads were sent one after another"); +}); From a43d12d10a2a22fd66e60dac49f55a76eb1ed9cf Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 16:59:26 +0100 Subject: [PATCH 05/11] build(portals): add a single-file bundle script The Auth0 CLI will embed the setup and audit tool as one file that `node` can run. `npm run bundle` builds that file with esbuild: one ES module with the dependencies included, no sourcemap, and the same bytes every time it runs. The first lines of the file record a hash of everything it is built from: the sources, the lockfile and the build script. A test can recompute the hash, so a bundle that is older than its sources is caught. The hash is of text with LF line endings so a Windows checkout gives the same value, and its definition is pinned by a test. The unused `auth0` package is removed. It was the source of the two moderate audit findings, and nothing imported it. esbuild is added as a development dependency at an exact version, and the lockfile keeps public registry URLs. Co-Authored-By: Claude --- .../_source/package-lock.json | 516 ++++++++++++++++-- .../universal-portals/_source/package.json | 5 +- .../_source/scripts/bundle.mjs | 31 ++ .../_source/src/source-hash.ts | 31 ++ .../_source/test/source-hash.test.ts | 43 ++ 5 files changed, 590 insertions(+), 36 deletions(-) create mode 100644 internal/cli/data/universal-portals/_source/scripts/bundle.mjs create mode 100644 internal/cli/data/universal-portals/_source/src/source-hash.ts create mode 100644 internal/cli/data/universal-portals/_source/test/source-hash.test.ts diff --git a/internal/cli/data/universal-portals/_source/package-lock.json b/internal/cli/data/universal-portals/_source/package-lock.json index 7b811c5bd..29f0e3986 100644 --- a/internal/cli/data/universal-portals/_source/package-lock.json +++ b/internal/cli/data/universal-portals/_source/package-lock.json @@ -8,18 +8,18 @@ "name": "universal-portals", "version": "0.0.0-dev", "dependencies": { - "@clack/prompts": "1.8.1", - "auth0": "4.29.0" + "@clack/prompts": "1.8.1" }, "bin": { "universal-portals": "dist/src/cli.js" }, "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "0.28.2", "typescript": "^5.0.0" }, "engines": { - "node": ">=20" + "node": ">=20.12" } }, "node_modules/@clack/core": { @@ -50,6 +50,448 @@ "node": ">= 20.12.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "22.20.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", @@ -60,18 +502,46 @@ "undici-types": "~6.21.0" } }, - "node_modules/auth0": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.29.0.tgz", - "integrity": "sha512-sTwC+579zoHthOFJLspVkghpaS9iAbA8U4ttirvZot4zvIG6EP0tpWUukVr9gzGXHUZcA+UdlKbHQwRSF8KCjg==", + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "jose": "^4.13.2", - "undici-types": "^6.15.0", - "uuid": "^9.0.0" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/fast-string-truncated-width": { @@ -98,15 +568,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -131,21 +592,8 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } } } } diff --git a/internal/cli/data/universal-portals/_source/package.json b/internal/cli/data/universal-portals/_source/package.json index 6097933fe..fec709ee5 100644 --- a/internal/cli/data/universal-portals/_source/package.json +++ b/internal/cli/data/universal-portals/_source/package.json @@ -11,6 +11,7 @@ ], "scripts": { "build": "tsc -p tsconfig.json && chmod +x dist/src/cli.js", + "bundle": "npm run build && node scripts/bundle.mjs", "test": "rm -rf dist && npm run build && node --test dist/test/*.test.js", "preview": "npm run preview:setup", "preview:setup": "npm run build >/dev/null && node scripts/preview-setup.mjs", @@ -20,11 +21,11 @@ "node": ">=20.12" }, "dependencies": { - "@clack/prompts": "1.8.1", - "auth0": "4.29.0" + "@clack/prompts": "1.8.1" }, "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "0.28.2", "typescript": "^5.0.0" } } diff --git a/internal/cli/data/universal-portals/_source/scripts/bundle.mjs b/internal/cli/data/universal-portals/_source/scripts/bundle.mjs new file mode 100644 index 000000000..42dd889d6 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/scripts/bundle.mjs @@ -0,0 +1,31 @@ +// Builds the single file the Auth0 CLI embeds: the setup and audit tool and its dependencies in one +// ES module that `node` can run. Run it with `npm run bundle`, which compiles first so the hash +// code is current. The output is generated: change the sources and run this again. +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; + +import { sourceHash } from "../dist/src/source-hash.js"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const outfile = fileURLToPath(new URL("../../universal-portals.mjs", import.meta.url)); + +await build({ + absWorkingDir: root, + entryPoints: ["src/cli.ts"], + bundle: true, + platform: "node", + format: "esm", + target: "node20", + outfile, + legalComments: "none", + logLevel: "warning", + banner: { + js: [ + '// Generated by "npm run bundle" in the _source folder. Do not edit.', + `// source-sha256: ${sourceHash(root)}`, + // Some dependencies still call require(), which an ES module does not have. + 'import { createRequire } from "node:module";', + "const require = createRequire(import.meta.url);", + ].join("\n"), + }, +}); diff --git a/internal/cli/data/universal-portals/_source/src/source-hash.ts b/internal/cli/data/universal-portals/_source/src/source-hash.ts new file mode 100644 index 000000000..ed8c22599 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/src/source-hash.ts @@ -0,0 +1,31 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Everything the single-file bundle is built from: the sources, the script that builds it, and the +// lockfile that fixes the dependencies. Paths use "/" and are sorted, so the list is the same on +// every machine. +export function sourceFiles(root: string): string[] { + const files = ["package-lock.json", "scripts/bundle.mjs"]; + const walk = (directory: string): void => { + for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) walk(path); + else files.push(path); + } + }; + walk("src"); + return files.sort(); +} + +// A hash of those files, written into the bundle's first lines. A test recomputes it, so a bundle +// that is older than its sources is caught. Line endings are normalized so a Windows checkout +// gives the same value. +export function sourceHash(root: string): string { + const hash = createHash("sha256"); + for (const file of sourceFiles(root)) { + const text = readFileSync(join(root, file), "utf8").replace(/\r\n/g, "\n"); + hash.update(`${file}\0${text}\0`); + } + return hash.digest("hex"); +} diff --git a/internal/cli/data/universal-portals/_source/test/source-hash.test.ts b/internal/cli/data/universal-portals/_source/test/source-hash.test.ts new file mode 100644 index 000000000..5f8073fd2 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/source-hash.test.ts @@ -0,0 +1,43 @@ +import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; + +import { sourceHash } from "../src/source-hash.js"; + +function tree(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "up-source-hash-")); + for (const [name, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, name)), { recursive: true }); + writeFileSync(join(root, name), text); + } + return root; +} + +const FILES = { "package-lock.json": "{}", "scripts/bundle.mjs": "x", "src/a.ts": "a", "src/deep/b.ts": "b" }; + +// The Go test that guards the committed bundle computes the same value, so this pins the definition: +// sorted paths, each followed by a NUL, its text with LF line endings, and a NUL. +test("the hash covers the lockfile, the bundle script and every file under src, in sorted order", () => { + const expected = createHash("sha256") + .update("package-lock.json\0{}\0scripts/bundle.mjs\0x\0src/a.ts\0a\0src/deep/b.ts\0b\0") + .digest("hex"); + + assert.equal(sourceHash(tree(FILES)), expected); +}); + +test("a change under src changes the hash, a change outside it does not", () => { + const before = sourceHash(tree(FILES)); + + assert.notEqual(sourceHash(tree({ ...FILES, "src/a.ts": "changed" })), before); + assert.equal(sourceHash(tree({ ...FILES, "test/x.test.ts": "new", "dist/y.js": "z" })), before); +}); + +test("Windows line endings give the same hash as Unix ones", () => { + const unix = tree({ ...FILES, "src/a.ts": "line one\nline two\n" }); + const windows = tree({ ...FILES, "src/a.ts": "line one\r\nline two\r\n" }); + + assert.equal(sourceHash(windows), sourceHash(unix)); +}); From 2a75c58840cef43aec67809ce10d99ac6943cf04 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 16:59:54 +0100 Subject: [PATCH 06/11] chore(portals): generate the bundle Add the file the Auth0 CLI will embed, built with `npm run bundle` from the sources in the _source folder. It is generated, so it is not meant to be reviewed line by line: `.gitattributes` marks it as generated so GitHub folds it, and a test fails when the sources change without the bundle being rebuilt. Co-Authored-By: Claude --- .gitattributes | 1 + .../_source/test/bundle.test.ts | 21 + .../universal-portals/universal-portals.mjs | 4364 +++++++++++++++++ 3 files changed, 4386 insertions(+) create mode 100644 internal/cli/data/universal-portals/_source/test/bundle.test.ts create mode 100755 internal/cli/data/universal-portals/universal-portals.mjs diff --git a/.gitattributes b/.gitattributes index 7c486a5e6..32cd82512 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ vendor/* linguist-generated=true +internal/cli/data/universal-portals/universal-portals.mjs linguist-generated=true diff --git a/internal/cli/data/universal-portals/_source/test/bundle.test.ts b/internal/cli/data/universal-portals/_source/test/bundle.test.ts new file mode 100644 index 000000000..e9594c3e0 --- /dev/null +++ b/internal/cli/data/universal-portals/_source/test/bundle.test.ts @@ -0,0 +1,21 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { sourceHash } from "../src/source-hash.js"; + +// The Auth0 CLI embeds this file, so a bundle that is older than its sources would ship old code. +test("the committed bundle was built from the current sources", () => { + const root = fileURLToPath(new URL("../../", import.meta.url)); + const bundle = readFileSync(new URL("../../../universal-portals.mjs", import.meta.url), "utf8"); + + const recorded = /^\/\/ source-sha256: ([0-9a-f]{64})$/m.exec(bundle.split("\n").slice(0, 5).join("\n"))?.[1]; + + assert.ok(recorded, "the bundle does not start with its source hash"); + assert.equal( + recorded, + sourceHash(root), + 'the sources changed after the bundle was built: run "npm run bundle" in the _source folder and commit the result', + ); +}); diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs new file mode 100755 index 000000000..e2be272af --- /dev/null +++ b/internal/cli/data/universal-portals/universal-portals.mjs @@ -0,0 +1,4364 @@ +#!/usr/bin/env node +// Generated by "npm run bundle" in the _source folder. Do not edit. +// source-sha256: 57aff7535a99816d4543800ab198dbd3864558993de3aa4cec3dca202532cd90 +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + try { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + } catch (e) { + throw mod = 0, e; + } +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// node_modules/sisteransi/src/index.js +var require_src = __commonJS({ + "node_modules/sisteransi/src/index.js"(exports, module) { + "use strict"; + var ESC2 = "\x1B"; + var CSI2 = `${ESC2}[`; + var beep = "\x07"; + var cursor3 = { + to(x, y2) { + if (!y2) return `${CSI2}${x + 1}G`; + return `${CSI2}${y2 + 1};${x + 1}H`; + }, + move(x, y2) { + let ret = ""; + if (x < 0) ret += `${CSI2}${-x}D`; + else if (x > 0) ret += `${CSI2}${x}C`; + if (y2 < 0) ret += `${CSI2}${-y2}A`; + else if (y2 > 0) ret += `${CSI2}${y2}B`; + return ret; + }, + up: (count = 1) => `${CSI2}${count}A`, + down: (count = 1) => `${CSI2}${count}B`, + forward: (count = 1) => `${CSI2}${count}C`, + backward: (count = 1) => `${CSI2}${count}D`, + nextLine: (count = 1) => `${CSI2}E`.repeat(count), + prevLine: (count = 1) => `${CSI2}F`.repeat(count), + left: `${CSI2}G`, + hide: `${CSI2}?25l`, + show: `${CSI2}?25h`, + save: `${ESC2}7`, + restore: `${ESC2}8` + }; + var scroll = { + up: (count = 1) => `${CSI2}S`.repeat(count), + down: (count = 1) => `${CSI2}T`.repeat(count) + }; + var erase3 = { + screen: `${CSI2}2J`, + up: (count = 1) => `${CSI2}1J`.repeat(count), + down: (count = 1) => `${CSI2}J`.repeat(count), + line: `${CSI2}2K`, + lineEnd: `${CSI2}K`, + lineStart: `${CSI2}1K`, + lines(count) { + let clear = ""; + for (let i2 = 0; i2 < count; i2++) + clear += this.line + (i2 < count - 1 ? cursor3.up() : ""); + if (count) + clear += cursor3.left; + return clear; + } + }; + module.exports = { cursor: cursor3, scroll, erase: erase3, beep }; + } +}); + +// src/cli.ts +import { realpathSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +// src/args.ts +import { parseArgs } from "node:util"; + +// src/plugin-error.ts +var PluginError = class extends Error { + constructor(message, code, fixes) { + super(message); + this.code = code; + this.fixes = fixes; + this.name = new.target.name; + } + code; + fixes; +}; + +// src/args.ts +var UsageError = class extends PluginError { + constructor(message) { + super(message, "usage"); + } +}; +function parseSetupArgs(argv) { + const { globals, rest } = splitGlobalFlags(argv); + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + strict: true, + options: { + template: { type: "string" }, + name: { type: "string", short: "n" }, + slug: { type: "string", short: "s" }, + user: { type: "string", short: "u" }, + organization: { type: "string", short: "o" }, + json: { type: "boolean" }, + "no-input": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + strict: { type: "boolean" }, + fix: { type: "boolean" } + } + }); + const [first] = positionals; + if (values.help || first === void 0) { + return { + command: "help", + strict: false, + tenant: globals.tenant, + noColor: globals.noColor, + json: Boolean(values.json), + noInput: Boolean(values["no-input"]) + }; + } + if (first === "audit") { + if (positionals.length !== 2) { + throw new UsageError("Usage: auth0 universal-portals audit [--organization ] [--strict]"); + } + return { + command: "audit", + portalId: positionals[1], + tenant: globals.tenant, + noColor: globals.noColor, + organization: values.organization, + strict: Boolean(values.strict), + fix: Boolean(values.fix), + json: Boolean(values.json), + noInput: Boolean(values["no-input"]) + }; + } + if (first !== "setup") { + throw new UsageError(`Unknown command "${first}". Run: auth0 universal-portals --help`); + } + if (positionals.length > 1) { + throw new UsageError(`Unexpected arguments: ${positionals.slice(1).join(" ")}`); + } + const template = values.template; + if (template !== void 0 && template !== "consumer" && template !== "business") { + throw new UsageError(`--template must be "consumer" or "business", not "${template}".`); + } + return { + command: "setup", + strict: false, + tenant: globals.tenant, + noColor: globals.noColor, + template, + name: values.name, + slug: values.slug, + user: values.user, + organization: values.organization, + json: Boolean(values.json), + noInput: Boolean(values["no-input"]) + }; +} +function missingFlags(args) { + const missing = []; + if (!args.template) missing.push("--template"); + if (!args.name) missing.push("--name"); + if (args.template === "business" && !args.user) missing.push("--user"); + return missing; +} +function usage() { + return [ + "Usage: auth0 universal-portals setup [flags]", + "", + "Creates an Auth0 Portal with its application, forms and pages.", + "", + "Flags:", + " --template Portal template. Asked when omitted.", + " -n, --name Portal name. Asked when omitted.", + " -s, --slug URL slug. Defaults to a slug of the name.", + " -u, --user Business only: the user who will test the portal.", + " -o, --organization Business only: the organization, when the user has several.", + " --no-input Never prompt; fail on anything missing.", + " --json Print the result as JSON on stdout.", + " -h, --help Show this help.", + "", + "Usage: auth0 universal-portals audit [flags]", + "", + "Checks a portal and its application against the setup defaults.", + "", + "Flags:", + " -o, --organization Business only: the organization the portal opens in.", + " --fix Apply the errors the CLI can fix, without asking.", + " --strict Also exit 1 when there are warnings.", + " --json Print the result as JSON on stdout.", + " -h, --help Show this help." + ].join("\n"); +} +function splitGlobalFlags(argv) { + const globals = { noColor: false }; + const rest = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--no-color") { + globals.noColor = true; + continue; + } + if (arg === "--debug") continue; + if (arg === "--tenant") { + globals.tenant = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--tenant=")) { + globals.tenant = arg.slice("--tenant=".length); + continue; + } + rest.push(arg); + } + return { globals, rest }; +} + +// node_modules/@clack/core/dist/index.mjs +import { styleText } from "node:util"; +import { stdout, stdin } from "node:process"; +import * as l from "node:readline"; +import l__default from "node:readline"; + +// node_modules/fast-string-truncated-width/dist/utils.js +var getCodePointsLength = /* @__PURE__ */ (() => { + const SURROGATE_PAIR_RE = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; + return (input) => { + let surrogatePairsNr = 0; + SURROGATE_PAIR_RE.lastIndex = 0; + while (SURROGATE_PAIR_RE.test(input)) { + surrogatePairsNr += 1; + } + return input.length - surrogatePairsNr; + }; +})(); +var isFullWidth = (x) => { + return x === 12288 || x >= 65281 && x <= 65376 || x >= 65504 && x <= 65510; +}; +var isWideNotCJKTNotEmoji = (x) => { + return x === 8987 || x === 9001 || x >= 12272 && x <= 12287 || x >= 12289 && x <= 12350 || x >= 12441 && x <= 12543 || x >= 12549 && x <= 12591 || x >= 12593 && x <= 12686 || x >= 12688 && x <= 12771 || x >= 12783 && x <= 12830 || x >= 12832 && x <= 12871 || x >= 12880 && x <= 19903 || x >= 65040 && x <= 65049 || x >= 65072 && x <= 65106 || x >= 65108 && x <= 65126 || x >= 65128 && x <= 65131 || x >= 127488 && x <= 127490 || x >= 127504 && x <= 127547 || x >= 127552 && x <= 127560 || x >= 131072 && x <= 196605 || x >= 196608 && x <= 262141; +}; + +// node_modules/fast-string-truncated-width/dist/index.js +var ANSI_RE = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]|\u001b\]8;[^;]*;.*?(?:\u0007|\u001b\u005c)/y; +var CONTROL_RE = /[\x00-\x08\x0A-\x1F\x7F-\x9F]{1,1000}/y; +var CJKT_WIDE_RE = /(?:(?![\uFF61-\uFF9F\uFF00-\uFFEF])[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Tangut}]){1,1000}/yu; +var TAB_RE = /\t{1,1000}/y; +var EMOJI_RE = new RegExp("[\\u{1F1E6}-\\u{1F1FF}]{2}|\\u{1F3F4}[\\u{E0061}-\\u{E007A}]{2}[\\u{E0030}-\\u{E0039}\\u{E0061}-\\u{E007A}]{1,3}\\u{E007F}|(?:\\p{Emoji}\\uFE0F\\u20E3?|\\p{Emoji_Modifier_Base}\\p{Emoji_Modifier}?|\\p{Emoji_Presentation})(?:\\u200D(?:\\p{Emoji_Modifier_Base}\\p{Emoji_Modifier}?|\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F\\u20E3?))*", "yu"); +var LATIN_RE = /(?:[\x20-\x7E\xA0-\xFF](?!\uFE0F)){1,1000}/y; +var MODIFIER_RE = new RegExp("\\p{M}+", "gu"); +var NO_TRUNCATION = { limit: Infinity, ellipsis: "" }; +var getStringTruncatedWidth = (input, truncationOptions = {}, widthOptions = {}) => { + const LIMIT = truncationOptions.limit ?? Infinity; + const ELLIPSIS = truncationOptions.ellipsis ?? ""; + const ELLIPSIS_WIDTH = truncationOptions?.ellipsisWidth ?? (ELLIPSIS ? getStringTruncatedWidth(ELLIPSIS, NO_TRUNCATION, widthOptions).width : 0); + const ANSI_WIDTH = 0; + const CONTROL_WIDTH = widthOptions.controlWidth ?? 0; + const TAB_WIDTH = widthOptions.tabWidth ?? 8; + const EMOJI_WIDTH = widthOptions.emojiWidth ?? 2; + const FULL_WIDTH_WIDTH = 2; + const REGULAR_WIDTH = widthOptions.regularWidth ?? 1; + const WIDE_WIDTH = widthOptions.wideWidth ?? FULL_WIDTH_WIDTH; + const PARSE_BLOCKS = [ + [LATIN_RE, REGULAR_WIDTH], + [ANSI_RE, ANSI_WIDTH], + [CONTROL_RE, CONTROL_WIDTH], + [TAB_RE, TAB_WIDTH], + [EMOJI_RE, EMOJI_WIDTH], + [CJKT_WIDE_RE, WIDE_WIDTH] + ]; + let indexPrev = 0; + let index = 0; + let length = input.length; + let lengthExtra = 0; + let truncationEnabled = false; + let truncationIndex = length; + let truncationLimit = Math.max(0, LIMIT - ELLIPSIS_WIDTH); + let unmatchedStart = 0; + let unmatchedEnd = 0; + let width = 0; + let widthExtra = 0; + outer: while (true) { + if (unmatchedEnd > unmatchedStart || index >= length && index > indexPrev) { + const unmatched = input.slice(unmatchedStart, unmatchedEnd) || input.slice(indexPrev, index); + lengthExtra = 0; + for (const char of unmatched.replaceAll(MODIFIER_RE, "")) { + const codePoint = char.codePointAt(0) || 0; + if (isFullWidth(codePoint)) { + widthExtra = FULL_WIDTH_WIDTH; + } else if (isWideNotCJKTNotEmoji(codePoint)) { + widthExtra = WIDE_WIDTH; + } else { + widthExtra = REGULAR_WIDTH; + } + if (width + widthExtra > truncationLimit) { + truncationIndex = Math.min(truncationIndex, Math.max(unmatchedStart, indexPrev) + lengthExtra); + } + if (width + widthExtra > LIMIT) { + truncationEnabled = true; + break outer; + } + lengthExtra += char.length; + width += widthExtra; + } + unmatchedStart = unmatchedEnd = 0; + } + if (index >= length) { + break outer; + } + for (let i2 = 0, l2 = PARSE_BLOCKS.length; i2 < l2; i2++) { + const [BLOCK_RE, BLOCK_WIDTH] = PARSE_BLOCKS[i2]; + BLOCK_RE.lastIndex = index; + if (BLOCK_RE.test(input)) { + lengthExtra = BLOCK_RE === CJKT_WIDE_RE ? getCodePointsLength(input.slice(index, BLOCK_RE.lastIndex)) : BLOCK_RE === EMOJI_RE ? 1 : BLOCK_RE.lastIndex - index; + widthExtra = lengthExtra * BLOCK_WIDTH; + if (width + widthExtra > truncationLimit) { + truncationIndex = Math.min(truncationIndex, index + Math.floor((truncationLimit - width) / BLOCK_WIDTH)); + } + if (width + widthExtra > LIMIT) { + truncationEnabled = true; + break outer; + } + width += widthExtra; + unmatchedStart = indexPrev; + unmatchedEnd = index; + index = indexPrev = BLOCK_RE.lastIndex; + continue outer; + } + } + index += 1; + } + return { + width: truncationEnabled ? truncationLimit : width, + index: truncationEnabled ? truncationIndex : length, + truncated: truncationEnabled, + ellipsed: truncationEnabled && LIMIT >= ELLIPSIS_WIDTH + }; +}; +var dist_default = getStringTruncatedWidth; + +// node_modules/fast-string-width/dist/index.js +var NO_TRUNCATION2 = { + limit: Infinity, + ellipsis: "", + ellipsisWidth: 0 +}; +var fastStringWidth = (input, options = {}) => { + return dist_default(input, NO_TRUNCATION2, options).width; +}; +var dist_default2 = fastStringWidth; + +// node_modules/fast-wrap-ansi/lib/main.js +var ESC = "\x1B"; +var CSI = "\x9B"; +var END_CODE = 39; +var ANSI_ESCAPE_BELL = "\x07"; +var ANSI_CSI = "["; +var ANSI_OSC = "]"; +var ANSI_SGR_TERMINATOR = "m"; +var ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`; +var GROUP_REGEX = new RegExp(`(?:\\${ANSI_CSI}(?\\d+)m|\\${ANSI_ESCAPE_LINK}(?.*)${ANSI_ESCAPE_BELL})`, "y"); +var getClosingCode = (openingCode) => { + if (openingCode >= 30 && openingCode <= 37) + return 39; + if (openingCode >= 90 && openingCode <= 97) + return 39; + if (openingCode >= 40 && openingCode <= 47) + return 49; + if (openingCode >= 100 && openingCode <= 107) + return 49; + if (openingCode === 1 || openingCode === 2) + return 22; + if (openingCode === 3) + return 23; + if (openingCode === 4) + return 24; + if (openingCode === 7) + return 27; + if (openingCode === 8) + return 28; + if (openingCode === 9) + return 29; + if (openingCode === 0) + return 0; + return void 0; +}; +var wrapAnsiCode = (code) => `${ESC}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`; +var wrapAnsiHyperlink = (url) => `${ESC}${ANSI_ESCAPE_LINK}${url}${ANSI_ESCAPE_BELL}`; +var wrapWord = (rows, word, columns) => { + const characters = word[Symbol.iterator](); + let isInsideEscape = false; + let isInsideLinkEscape = false; + let lastRow = rows.at(-1); + let visible = lastRow === void 0 ? 0 : dist_default2(lastRow); + let currentCharacter = characters.next(); + let nextCharacter = characters.next(); + let rawCharacterIndex = 0; + while (!currentCharacter.done) { + const character = currentCharacter.value; + const characterLength = dist_default2(character); + if (visible + characterLength <= columns) { + rows[rows.length - 1] += character; + } else { + rows.push(character); + visible = 0; + } + if (character === ESC || character === CSI) { + isInsideEscape = true; + isInsideLinkEscape = word.startsWith(ANSI_ESCAPE_LINK, rawCharacterIndex + 1); + } + if (isInsideEscape) { + if (isInsideLinkEscape) { + if (character === ANSI_ESCAPE_BELL) { + isInsideEscape = false; + isInsideLinkEscape = false; + } + } else if (character === ANSI_SGR_TERMINATOR) { + isInsideEscape = false; + } + } else { + visible += characterLength; + if (visible === columns && !nextCharacter.done) { + rows.push(""); + visible = 0; + } + } + currentCharacter = nextCharacter; + nextCharacter = characters.next(); + rawCharacterIndex += character.length; + } + lastRow = rows.at(-1); + if (!visible && lastRow !== void 0 && lastRow.length && rows.length > 1) { + rows[rows.length - 2] += rows.pop(); + } +}; +var stringVisibleTrimSpacesRight = (string) => { + const words = string.split(" "); + let last = words.length; + while (last) { + if (dist_default2(words[last - 1])) { + break; + } + last--; + } + if (last === words.length) { + return string; + } + return words.slice(0, last).join(" ") + words.slice(last).join(""); +}; +var exec = (string, columns, options = {}) => { + if (options.trim !== false && string.trim() === "") { + return ""; + } + let returnValue = ""; + let escapeCode; + let escapeUrl; + const words = string.split(" "); + let rows = [""]; + let rowLength = 0; + for (let index = 0; index < words.length; index++) { + const word = words[index]; + if (options.trim !== false) { + const row = rows.at(-1) ?? ""; + const trimmed = row.trimStart(); + if (row.length !== trimmed.length) { + rows[rows.length - 1] = trimmed; + rowLength = dist_default2(trimmed); + } + } + if (index !== 0) { + if (rowLength >= columns && (options.wordWrap === false || options.trim === false)) { + rows.push(""); + rowLength = 0; + } + if (rowLength || options.trim === false) { + rows[rows.length - 1] += " "; + rowLength++; + } + } + const wordLength = dist_default2(word); + if (options.hard && wordLength > columns) { + const remainingColumns = columns - rowLength; + const breaksStartingThisLine = 1 + Math.floor((wordLength - remainingColumns - 1) / columns); + const breaksStartingNextLine = Math.floor((wordLength - 1) / columns); + if (breaksStartingNextLine < breaksStartingThisLine) { + rows.push(""); + } + wrapWord(rows, word, columns); + rowLength = dist_default2(rows.at(-1) ?? ""); + continue; + } + if (rowLength + wordLength > columns && rowLength && wordLength) { + if (options.wordWrap === false && rowLength < columns) { + wrapWord(rows, word, columns); + rowLength = dist_default2(rows.at(-1) ?? ""); + continue; + } + rows.push(""); + rowLength = 0; + } + if (rowLength + wordLength > columns && options.wordWrap === false) { + wrapWord(rows, word, columns); + rowLength = dist_default2(rows.at(-1) ?? ""); + continue; + } + rows[rows.length - 1] += word; + rowLength += wordLength; + } + if (options.trim !== false) { + rows = rows.map((row) => stringVisibleTrimSpacesRight(row)); + } + const preString = rows.join("\n"); + let inSurrogate = false; + for (let i2 = 0; i2 < preString.length; i2++) { + const character = preString[i2]; + returnValue += character; + if (!inSurrogate) { + inSurrogate = character >= "\uD800" && character <= "\uDBFF"; + if (inSurrogate) { + continue; + } + } else { + inSurrogate = false; + } + if (character === ESC || character === CSI) { + GROUP_REGEX.lastIndex = i2 + 1; + const groupsResult = GROUP_REGEX.exec(preString); + const groups = groupsResult?.groups; + if (groups?.code !== void 0) { + const code = Number.parseFloat(groups.code); + escapeCode = code === END_CODE ? void 0 : code; + } else if (groups?.uri !== void 0) { + escapeUrl = groups.uri.length === 0 ? void 0 : groups.uri; + } + } + if (preString[i2 + 1] === "\n") { + if (escapeUrl) { + returnValue += wrapAnsiHyperlink(""); + } + const closingCode = escapeCode ? getClosingCode(escapeCode) : void 0; + if (escapeCode && closingCode) { + returnValue += wrapAnsiCode(closingCode); + } + } else if (character === "\n") { + if (escapeCode && getClosingCode(escapeCode)) { + returnValue += wrapAnsiCode(escapeCode); + } + if (escapeUrl) { + returnValue += wrapAnsiHyperlink(escapeUrl); + } + } + } + return returnValue; +}; +var CRLF_OR_LF = /\r?\n/; +function wrapAnsi(string, columns, options) { + return String(string).normalize().split(CRLF_OR_LF).map((line) => exec(line, columns, options)).join("\n"); +} + +// node_modules/@clack/core/dist/index.mjs +var import_sisteransi = __toESM(require_src(), 1); +import { ReadStream } from "node:tty"; +function findCursor(s, o, l2) { + if (!l2.some((r2) => !r2.disabled)) + return s; + const t2 = s + o, n3 = Math.max(l2.length - 1, 0), e = t2 < 0 ? n3 : t2 > n3 ? 0 : t2; + return l2[e]?.disabled ? findCursor(e, o < 0 ? -1 : 1, l2) : e; +} +var a$1 = ["up", "down", "left", "right", "space", "enter", "cancel"]; +var t = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" +]; +var settings = { + actions: new Set(a$1), + aliases: /* @__PURE__ */ new Map([ + // vim support + ["k", "up"], + ["j", "down"], + ["h", "left"], + ["l", "right"], + ["", "cancel"], + // opinionated defaults! + ["escape", "cancel"] + ]), + messages: { + cancel: "Canceled", + error: "Something went wrong" + }, + withGuide: true, + accessible: void 0, + date: { + monthNames: [...t], + messages: { + required: "Please enter a valid date", + invalidMonth: "There are only 12 months in a year", + invalidDay: (n3, e) => `There are only ${n3} days in ${e}`, + afterMin: (n3) => `Date must be on or after ${n3.toISOString().slice(0, 10)}`, + beforeMax: (n3) => `Date must be on or before ${n3.toISOString().slice(0, 10)}` + } + } +}; +function isAccessible(n3) { + if (n3 !== void 0) return n3; + if (settings.accessible !== void 0) return settings.accessible; + const e = process.env.ACCESSIBLE; + return e !== void 0 && e !== "" && e !== "0" && e !== "false"; +} +function isActionKey(n3, e) { + if (typeof n3 == "string") + return settings.aliases.get(n3) === e; + for (const s of n3) + if (s !== void 0 && isActionKey(s, e)) + return true; + return false; +} +function diffLines(i2, s) { + if (i2 === s) return; + const e = i2.split(` +`), t2 = s.split(` +`), r2 = Math.max(e.length, t2.length), f = []; + for (let n3 = 0; n3 < r2; n3++) + e[n3] !== t2[n3] && f.push(n3); + return { + lines: f, + numLinesBefore: e.length, + numLinesAfter: t2.length, + numLines: r2 + }; +} +var R = globalThis.process.platform.startsWith("win"); +var CANCEL_SYMBOL = /* @__PURE__ */ Symbol("clack:cancel"); +function isCancel(e) { + return e === CANCEL_SYMBOL; +} +function setRawMode(e, r2) { + const o = e; + o.isTTY && o.setRawMode(r2); +} +function block({ + input: e = stdin, + output: r2 = stdout, + overwrite: o = true, + hideCursor: n3 = true +} = {}) { + const s = l.createInterface({ + input: e, + output: r2, + prompt: "", + tabSize: 1 + }); + l.emitKeypressEvents(e, s), e instanceof ReadStream && e.isTTY && e.setRawMode(true); + const t2 = (f, { name: a2, sequence: w }) => { + const c2 = String(f); + if (isActionKey([c2, a2, w], "cancel")) { + n3 && r2.write(import_sisteransi.cursor.show), process.exit(0); + return; + } + if (!o) return; + const i2 = a2 === "return" ? 0 : -1, m2 = a2 === "return" ? -1 : 0; + l.moveCursor(r2, i2, m2, () => { + l.clearLine(r2, 1, () => { + e.once("keypress", t2); + }); + }); + }; + return n3 && r2.write(import_sisteransi.cursor.hide), e.once("keypress", t2), () => { + e.off("keypress", t2), n3 && r2.write(import_sisteransi.cursor.show), e instanceof ReadStream && e.isTTY && !R && e.setRawMode(false), s.terminal = false, s.close(); + }; +} +var getColumns = (e) => "columns" in e && typeof e.columns == "number" ? e.columns : 80; +var getRows = (e) => "rows" in e && typeof e.rows == "number" ? e.rows : 20; +function wrapTextWithPrefix(e, r2, o, n3 = o, s = o, t2) { + const f = getColumns(e ?? stdout); + return wrapAnsi(r2, f - o.length, { + hard: true, + trim: false + }).split(` +`).map((c2, i2, m2) => { + const d = t2 ? t2(c2, i2) : c2; + return i2 === 0 ? `${n3}${d}` : i2 === m2.length - 1 ? `${s}${d}` : `${o}${d}`; + }).join(` +`); +} +function runValidation(e, a2) { + if ("~standard" in e) { + const n3 = e["~standard"].validate(a2); + return n3 instanceof Promise ? n3.then((r2) => r2.issues?.at(0)?.message) : n3.issues?.at(0)?.message; + } + return e(a2); +} +var y = class { + input; + output; + _abortSignal; + rl; + opts; + _render; + _track = false; + _prevFrame = ""; + _subscribers = /* @__PURE__ */ new Map(); + _cursor = 0; + state = "initial"; + error = ""; + value; + userInput = ""; + /** + * Whether accessible (static, screen-reader friendly) output is enabled for + * this prompt, resolved from the `accessible` option, the global setting, + * and the `ACCESSIBLE` env var. + */ + get accessible() { + return isAccessible(this.opts.accessible); + } + constructor(t2, e = true) { + const { input: i2 = stdin, output: s = stdout, render: r2, signal: n3, ...o } = t2; + this.opts = o, this.onKeypress = this.onKeypress.bind(this), this.close = this.close.bind(this), this.render = this.render.bind(this), this._render = r2.bind(this), this._track = e, this._abortSignal = n3, this.input = i2, this.output = s; + } + /** + * Unsubscribe all listeners + */ + unsubscribe() { + this._subscribers.clear(); + } + /** + * Set a subscriber with opts + * @param event - The event name + */ + setSubscriber(t2, e) { + const i2 = this._subscribers.get(t2) ?? []; + i2.push(e), this._subscribers.set(t2, i2); + } + /** + * Subscribe to an event + * @param event - The event name + * @param cb - The callback + */ + on(t2, e) { + this.setSubscriber(t2, { cb: e }); + } + /** + * Subscribe to an event once + * @param event - The event name + * @param cb - The callback + */ + once(t2, e) { + this.setSubscriber(t2, { cb: e, once: true }); + } + /** + * Emit an event with data + * @param event - The event name + * @param data - The data to pass to the callback + */ + emit(t2, ...e) { + const i2 = this._subscribers.get(t2) ?? [], s = []; + for (const r2 of i2) + r2.cb(...e), r2.once && s.push(() => i2.splice(i2.indexOf(r2), 1)); + for (const r2 of s) + r2(); + } + prompt() { + return new Promise((t2) => { + if (this._abortSignal) { + if (this._abortSignal.aborted) + return this.state = "cancel", this.close(), t2(CANCEL_SYMBOL); + this._abortSignal.addEventListener( + "abort", + () => { + this.state = "cancel", this.close(); + }, + { once: true } + ); + } + this.rl = l__default.createInterface({ + input: this.input, + tabSize: 2, + prompt: "", + escapeCodeTimeout: 50, + terminal: true + }), this.rl.prompt(), this.opts.initialUserInput !== void 0 && this._setUserInput(this.opts.initialUserInput, true), this.input.on("keypress", this.onKeypress), setRawMode(this.input, true), this.output.on("resize", this.render), this.render(), this.once("submit", () => { + this.output.write(import_sisteransi.cursor.show), this.output.off("resize", this.render), setRawMode(this.input, false), t2(this.value); + }), this.once("cancel", () => { + this.output.write(import_sisteransi.cursor.show), this.output.off("resize", this.render), setRawMode(this.input, false), t2(CANCEL_SYMBOL); + }); + }); + } + _isActionKey(t2, e) { + return t2 === " "; + } + _shouldSubmit(t2, e) { + return true; + } + _setValue(t2) { + this.value = t2, this.emit("value", this.value); + } + _setUserInput(t2, e) { + this.userInput = t2 ?? "", this.emit("userInput", this.userInput), e && this._track && this.rl && (this.rl.write(this.userInput), this._cursor = this.rl.cursor); + } + _clearUserInput() { + this.rl?.write(null, { ctrl: true, name: "u" }), this._setUserInput(""); + } + async onKeypress(t2, e) { + if (this.state !== "validating") { + if (this._track && e.name !== "return" && (e.name && this._isActionKey(t2, e) && this.rl?.write(null, { ctrl: true, name: "h" }), this._cursor = this.rl?.cursor ?? 0, this._setUserInput(this.rl?.line)), this.state === "error" && (this.state = "active"), e?.name && (!this._track && settings.aliases.has(e.name) && this.emit("cursor", settings.aliases.get(e.name)), settings.actions.has(e.name) && this.emit("cursor", e.name)), t2 && (t2.toLowerCase() === "y" || t2.toLowerCase() === "n") && this.emit("confirm", t2.toLowerCase() === "y"), this.emit("key", t2, e), e?.name === "return" && this._shouldSubmit(t2, e)) { + if (this.opts.validate) { + const i2 = runValidation(this.opts.validate, this.value); + let s; + i2 instanceof Promise ? (this.state = "validating", this.render(), s = await i2) : s = i2, s && (this.error = s instanceof Error ? s.message : s, this.state = "error", this.rl?.write(this.userInput)); + } + this.state !== "error" && (this.state = "submit"); + } + isActionKey([t2, e?.name, e?.sequence], "cancel") && (this.state = "cancel"), (this.state === "submit" || this.state === "cancel") && this.emit("finalize"), this.render(), (this.state === "submit" || this.state === "cancel") && this.close(); + } + } + close() { + this.input.unpipe(), this.input.removeListener("keypress", this.onKeypress), this.output.write(` +`), setRawMode(this.input, false), this.rl?.close(), this.rl = void 0, this.emit(`${this.state}`, this.value), this.unsubscribe(); + } + restoreCursor() { + const t2 = wrapAnsi(this._prevFrame, process.stdout.columns, { hard: true, trim: false }).split(` +`).length - 1; + this.output.write(import_sisteransi.cursor.move(-999, t2 * -1)); + } + render() { + const t2 = wrapAnsi(this._render(this) ?? "", process.stdout.columns, { + hard: true, + trim: false + }); + if (t2 !== this._prevFrame) { + if (this.state === "initial") + this.output.write(import_sisteransi.cursor.hide); + else { + const e = diffLines(this._prevFrame, t2), i2 = getRows(this.output); + if (this.restoreCursor(), e) { + const s = Math.max(0, e.numLinesAfter - i2), r2 = Math.max(0, e.numLinesBefore - i2); + let n3 = e.lines.find((o) => o >= s); + if (n3 === void 0) { + this._prevFrame = t2; + return; + } + if (e.lines.length === 1) { + this.output.write(import_sisteransi.cursor.move(0, n3 - r2)), this.output.write(import_sisteransi.erase.lines(1)); + const o = t2.split(` +`); + this.output.write(o[n3]), this._prevFrame = t2, this.output.write(import_sisteransi.cursor.move(0, o.length - n3 - 1)); + return; + } else if (e.lines.length > 1) { + if (s < r2) + n3 = s; + else { + const h2 = n3 - r2; + h2 > 0 && this.output.write(import_sisteransi.cursor.move(0, h2)); + } + this.output.write(import_sisteransi.erase.down()); + const f = t2.split(` +`).slice(n3); + this.output.write(f.join(` +`)), this._prevFrame = t2; + return; + } + } + this.output.write(import_sisteransi.erase.down()); + } + this.output.write(t2), this.state === "initial" && (this.state = "active"), this._prevFrame = t2; + } + } +}; +var r = class extends y { + get cursor() { + return this.value ? 0 : 1; + } + get _value() { + return this.cursor === 0; + } + constructor(t2) { + super(t2, false), this.value = !!t2.initialValue, this.on("userInput", () => { + this.value = this._value; + }), this.on("confirm", (i2) => { + this.output.write(import_sisteransi.cursor.move(0, -1)), this.value = i2, this.state = "submit", this.close(); + }), this.on("cursor", () => { + this.value = !this.value; + }); + } +}; +var a = class extends y { + options; + cursor = 0; + get _value() { + return this.options[this.cursor]?.value; + } + get _enabledOptions() { + return this.options.filter((e) => e.disabled !== true); + } + toggleAll() { + const e = this._enabledOptions, i2 = this.value !== void 0 && this.value.length === e.length; + this.value = i2 ? [] : e.map((t2) => t2.value); + } + toggleInvert() { + const e = this.value; + if (!e) + return; + const i2 = this._enabledOptions.filter((t2) => !e.includes(t2.value)); + this.value = i2.map((t2) => t2.value); + } + toggleValue() { + this.value === void 0 && (this.value = []); + const e = this.value.includes(this._value); + this.value = e ? this.value.filter((i2) => i2 !== this._value) : [...this.value, this._value]; + } + constructor(e) { + super(e, false), this.options = e.options, this.value = [...e.initialValues ?? []]; + const i2 = Math.max( + this.options.findIndex(({ value: t2 }) => t2 === e.cursorAt), + 0 + ); + this.cursor = this.options[i2]?.disabled ? findCursor(i2, 1, this.options) : i2, this.on("key", (t2, l2) => { + l2.name === "a" && this.toggleAll(), l2.name === "i" && this.toggleInvert(); + }), this.on("cursor", (t2) => { + switch (t2) { + case "left": + case "up": + this.cursor = findCursor(this.cursor, -1, this.options); + break; + case "down": + case "right": + this.cursor = findCursor(this.cursor, 1, this.options); + break; + case "space": + this.toggleValue(); + break; + } + }); + } +}; +var n$1 = class n extends y { + options; + cursor = 0; + get _selectedValue() { + return this.options[this.cursor]; + } + changeValue() { + const e = this._selectedValue; + this.value = e === void 0 ? void 0 : e.value; + } + constructor(e) { + super(e, false), this.options = e.options; + const o = this.options.findIndex(({ value: s }) => s === e.initialValue), t2 = o === -1 ? 0 : o; + this.cursor = this.options[t2]?.disabled ? findCursor(t2, 1, this.options) : t2, this.changeValue(), this.on("cursor", (s) => { + switch (s) { + case "left": + case "up": + this.cursor = findCursor(this.cursor, -1, this.options); + break; + case "down": + case "right": + this.cursor = findCursor(this.cursor, 1, this.options); + break; + } + this.changeValue(); + }); + } +}; +var n2 = class extends y { + get userInputWithCursor() { + if (this.state === "submit") + return this.userInput; + const t2 = this.userInput; + if (this.cursor >= t2.length) + return `${this.userInput}\u2588`; + const r2 = t2.slice(0, this.cursor), s = t2.slice(this.cursor, this.cursor + 1), e = t2.slice(this.cursor + 1); + return `${r2}${styleText("inverse", s)}${e}`; + } + get cursor() { + return this._cursor; + } + constructor(t2) { + super({ + ...t2, + initialUserInput: t2.initialUserInput ?? t2.initialValue + }), this.on("userInput", (r2) => { + this._setValue(r2); + }), this.on("finalize", () => { + this.value || (this.value = t2.defaultValue), this.value === void 0 && (this.value = ""); + }); + } +}; + +// node_modules/@clack/prompts/dist/index.mjs +import { styleText as styleText2, stripVTControlCharacters } from "node:util"; +import process$1 from "node:process"; +var import_sisteransi2 = __toESM(require_src(), 1); +function isUnicodeSupported() { + if (process$1.platform !== "win32") { + return process$1.env.TERM !== "linux"; + } + return Boolean(process$1.env.CI) || Boolean(process$1.env.WT_SESSION) || Boolean(process$1.env.TERMINUS_SUBLIME) || process$1.env.ConEmuTask === "{cmd::Cmder}" || process$1.env.TERM_PROGRAM === "Terminus-Sublime" || process$1.env.TERM_PROGRAM === "vscode" || process$1.env.TERM === "xterm-256color" || process$1.env.TERM === "alacritty" || process$1.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"; +} +var unicode = isUnicodeSupported(); +var isCI = () => process.env.CI === "true"; +var unicodeOr = (o, e) => unicode ? o : e; +var S_STEP_ACTIVE = unicodeOr("\u25C6", "*"); +var S_STEP_CANCEL = unicodeOr("\u25A0", "x"); +var S_STEP_ERROR = unicodeOr("\u25B2", "x"); +var S_STEP_SUBMIT = unicodeOr("\u25C7", "o"); +var S_BAR_START = unicodeOr("\u250C", "T"); +var S_BAR = unicodeOr("\u2502", "|"); +var S_BAR_END = unicodeOr("\u2514", "\u2014"); +var S_BAR_START_RIGHT = unicodeOr("\u2510", "T"); +var S_BAR_END_RIGHT = unicodeOr("\u2518", "\u2014"); +var S_RADIO_ACTIVE = unicodeOr("\u25CF", ">"); +var S_RADIO_INACTIVE = unicodeOr("\u25CB", " "); +var S_CHECKBOX_ACTIVE = unicodeOr("\u25FB", "[\u2022]"); +var S_CHECKBOX_SELECTED = unicodeOr("\u25FC", "[+]"); +var S_CHECKBOX_INACTIVE = unicodeOr("\u25FB", "[ ]"); +var S_PASSWORD_MASK = unicodeOr("\u25AA", "\u2022"); +var S_BAR_H = unicodeOr("\u2500", "-"); +var S_CORNER_TOP_RIGHT = unicodeOr("\u256E", "+"); +var S_CONNECT_LEFT = unicodeOr("\u251C", "+"); +var S_CORNER_BOTTOM_RIGHT = unicodeOr("\u256F", "+"); +var S_CORNER_BOTTOM_LEFT = unicodeOr("\u2570", "+"); +var S_CORNER_TOP_LEFT = unicodeOr("\u256D", "+"); +var S_INFO = unicodeOr("\u25CF", "\u2022"); +var S_SUCCESS = unicodeOr("\u25C6", "*"); +var S_WARN = unicodeOr("\u25B2", "!"); +var S_ERROR = unicodeOr("\u25A0", "x"); +var symbol = (o) => { + switch (o) { + case "initial": + case "active": + return styleText2("cyan", S_STEP_ACTIVE); + case "cancel": + return styleText2("red", S_STEP_CANCEL); + case "error": + return styleText2("yellow", S_STEP_ERROR); + case "submit": + return styleText2("green", S_STEP_SUBMIT); + case "validating": + return styleText2("dim", S_STEP_ACTIVE); + } +}; +var symbolBar = (o) => { + switch (o) { + case "initial": + case "active": + return styleText2("cyan", S_BAR); + case "cancel": + return styleText2("red", S_BAR); + case "error": + return styleText2("yellow", S_BAR); + case "submit": + return styleText2("green", S_BAR); + } +}; +function formatInstructionFooter(o, e) { + const r2 = [`${e ? `${styleText2("cyan", S_BAR)} ` : ""}${o.join(" \u2022 ")}`]; + return e && r2.push(styleText2("cyan", S_BAR_END)), r2; +} +var I = (l2, e, w, p, b, C2 = false) => { + let r2 = e, O = 0; + if (C2) + for (let i2 = p - 1; i2 >= w; i2--) { + const m2 = l2[i2]; + if (m2 && (r2 -= m2.length), O++, r2 <= b) break; + } + else + for (let i2 = w; i2 < p; i2++) { + const m2 = l2[i2]; + if (m2 && (r2 -= m2.length), O++, r2 <= b) break; + } + return { lineCount: r2, removals: O }; +}; +var limitOptions = ({ + cursor: l2, + options: e, + style: w, + output: p = process.stdout, + maxItems: b = Number.POSITIVE_INFINITY, + columnPadding: C2 = 0, + rowPadding: r2 = 4 +}) => { + const i2 = getColumns(p) - C2, m2 = getRows(p), M = styleText2("dim", "..."), v = Math.max(m2 - r2, 0), a2 = Math.max(Math.min(b, v), 5); + let f = 0; + l2 >= a2 - 3 && (f = Math.max( + Math.min(l2 - a2 + 3, e.length - a2), + 0 + )); + let d = a2 < e.length && f > 0, c2 = a2 < e.length && f + a2 < e.length; + const W2 = Math.min( + f + a2, + e.length + ), s = []; + let g = 0; + d && g++, c2 && g++; + const T = f + (d ? 1 : 0), y2 = W2 - (c2 ? 1 : 0); + for (let t2 = T; t2 < y2; t2++) { + const n3 = e[t2], o = n3 ? w(n3, t2 === l2) : "", h2 = wrapAnsi(o, i2, { + hard: true, + trim: false + }).split(` +`); + s.push(h2), g += h2.length; + } + if (g > v) { + let t2 = 0, n3 = 0, o = g; + const h2 = l2 - T; + let u3 = v; + const L = () => I(s, o, 0, h2, u3), E = () => I( + s, + o, + h2 + 1, + s.length, + u3, + true + ); + d ? ({ lineCount: o, removals: t2 } = L(), o > u3 && (c2 || (u3 -= 1), { lineCount: o, removals: n3 } = E())) : (c2 || (u3 -= 1), { lineCount: o, removals: n3 } = E(), o > u3 && (u3 -= 1, { lineCount: o, removals: t2 } = L())), t2 > 0 && (d = true, s.splice(0, t2)), n3 > 0 && (c2 = true, s.splice(s.length - n3, n3)); + } + const x = []; + d && x.push(M); + for (const t2 of s) + for (const n3 of t2) + x.push(n3); + return c2 && x.push(M), x; +}; +var K = [ + S_CORNER_TOP_LEFT, + S_CORNER_TOP_RIGHT, + S_CORNER_BOTTOM_LEFT, + S_CORNER_BOTTOM_RIGHT +]; +var Q = [S_BAR_START, S_BAR_START_RIGHT, S_BAR_END, S_BAR_END_RIGHT]; +function A$1(n3, e, t2, o) { + let i2 = t2, f = t2; + return o === "center" ? i2 = Math.floor((e - n3) / 2) : o === "right" && (i2 = e - n3 - t2), f = e - i2 - n3, [i2, f]; +} +var U2 = (n3) => n3; +var box = (n3 = "", e = "", t2) => { + const o = t2?.output ?? process.stdout, i2 = getColumns(o), R2 = 1 * 2, u3 = t2?.titlePadding ?? 1, h2 = t2?.contentPadding ?? 2, w = t2?.width === void 0 || t2.width === "auto" ? 1 : Math.min(1, t2.width), m2 = t2?.withGuide ?? settings.withGuide ? `${styleText2("gray", S_BAR)} ` : "", b = t2?.formatBorder ?? U2, a2 = (t2?.rounded ? K : Q).map(b), _ = b(S_BAR_H), p = b(S_BAR), x = dist_default2(m2), B = dist_default2(e), y2 = i2 - x; + let r2 = Math.floor(i2 * w) - x; + if (t2?.width === "auto") { + const c2 = n3.split(` +`); + let s = B + u3 * 2; + for (const G of c2) { + const P = dist_default2(G) + h2 * 2; + P > s && (s = P); + } + const g = s + R2; + g < r2 && (r2 = g); + } + r2 % 2 !== 0 && (r2 < y2 ? r2++ : r2--); + const d = r2 - R2, T = d - u3 * 2, S = B > T ? `${e.slice(0, T - 3)}...` : e, [O, W2] = A$1( + dist_default2(S), + d, + u3, + t2?.titleAlign + ), L = wrapAnsi(n3, d - h2 * 2, { + hard: true, + trim: false + }); + o.write( + `${m2}${a2[0]}${_.repeat(O)}${S}${_.repeat(W2)}${a2[1]} +` + ); + const E = L.split(` +`); + for (const c2 of E) { + const [s, g] = A$1( + dist_default2(c2), + d, + h2, + t2?.contentAlign + ); + o.write( + `${m2}${p}${" ".repeat(s)}${c2}${" ".repeat(g)}${p} +` + ); + } + o.write(`${m2}${a2[2]}${_.repeat(d)}${a2[3]} +`); +}; +var confirm = (e) => { + const a2 = e.active ?? "Yes", o = e.inactive ?? "No"; + return new r({ + active: a2, + inactive: o, + signal: e.signal, + input: e.input, + output: e.output, + initialValue: e.initialValue ?? true, + render() { + const i2 = e.withGuide ?? settings.withGuide, u3 = `${symbol(this.state)} `, l2 = i2 ? `${styleText2("gray", S_BAR)} ` : "", f = wrapTextWithPrefix( + e.output, + e.message, + l2, + u3 + ), s = `${i2 ? `${styleText2("gray", S_BAR)} +` : ""}${f} +`, c2 = this.value ? a2 : o; + switch (this.state) { + case "submit": { + const r2 = i2 ? `${styleText2("gray", S_BAR)} ` : ""; + return `${s}${r2}${styleText2("dim", c2)}`; + } + case "cancel": { + const r2 = i2 ? `${styleText2("gray", S_BAR)} ` : ""; + return `${s}${r2}${styleText2(["strikethrough", "dim"], c2)}${i2 ? ` +${styleText2("gray", S_BAR)}` : ""}`; + } + default: { + const r2 = i2 ? `${styleText2("cyan", S_BAR)} ` : "", g = i2 ? styleText2("cyan", S_BAR_END) : ""; + return `${s}${r2}${this.value ? `${styleText2("green", S_RADIO_ACTIVE)} ${a2}` : `${styleText2("dim", S_RADIO_INACTIVE)} ${styleText2("dim", a2)}`}${e.vertical ? i2 ? ` +${styleText2("cyan", S_BAR)} ` : ` +` : ` ${styleText2("dim", "/")} `}${this.value ? `${styleText2("dim", S_RADIO_INACTIVE)} ${styleText2("dim", o)}` : `${styleText2("green", S_RADIO_ACTIVE)} ${o}`} +${g} +`; + } + } + } + }).prompt(); +}; +var MULTISELECT_INSTRUCTIONS = [ + `${styleText2("dim", "\u2191/\u2193")} to navigate`, + `${styleText2("dim", "Space:")} select`, + `${styleText2("dim", "Enter:")} confirm` +]; +var m = (i2, u3) => i2.split(` +`).map((d) => u3(d)).join(` +`); +var multiselect = (i2) => { + const u3 = (t2, a2) => { + const r2 = t2.label ?? String(t2.value); + return a2 === "disabled" ? `${styleText2("gray", S_CHECKBOX_INACTIVE)} ${m(r2, (o) => styleText2(["strikethrough", "gray"], o))}${t2.hint ? ` ${styleText2("dim", `(${t2.hint ?? "disabled"})`)}` : ""}` : a2 === "active" ? `${styleText2("cyan", S_CHECKBOX_ACTIVE)} ${r2}${t2.hint ? ` ${styleText2("dim", `(${t2.hint})`)}` : ""}` : a2 === "selected" ? `${styleText2("green", S_CHECKBOX_SELECTED)} ${m(r2, (o) => styleText2("dim", o))}${t2.hint ? ` ${styleText2("dim", `(${t2.hint})`)}` : ""}` : a2 === "cancelled" ? `${m(r2, (o) => styleText2(["strikethrough", "dim"], o))}` : a2 === "active-selected" ? `${styleText2("green", S_CHECKBOX_SELECTED)} ${r2}${t2.hint ? ` ${styleText2("dim", `(${t2.hint})`)}` : ""}` : a2 === "submitted" ? `${m(r2, (o) => styleText2("dim", o))}` : `${styleText2("dim", S_CHECKBOX_INACTIVE)} ${m(r2, (o) => styleText2("dim", o))}`; + }, d = i2.required ?? true, x = i2.showInstructions ?? true; + return new a({ + options: i2.options, + signal: i2.signal, + input: i2.input, + output: i2.output, + initialValues: i2.initialValues, + required: d, + cursorAt: i2.cursorAt, + validate(t2) { + if (d && (t2 === void 0 || t2.length === 0)) + return `Please select at least one option. +${styleText2( + "reset", + styleText2( + "dim", + `Press ${styleText2(["gray", "bgWhite", "inverse"], " space ")} to select, ${styleText2( + "gray", + styleText2("bgWhite", styleText2("inverse", " enter ")) + )} to submit` + ) + )}`; + }, + render() { + const t2 = i2.withGuide ?? settings.withGuide, a2 = wrapTextWithPrefix( + i2.output, + i2.message, + t2 ? `${symbolBar(this.state)} ` : "", + `${symbol(this.state)} ` + ), r2 = `${t2 ? `${styleText2("gray", S_BAR)} +` : ""}${a2} +`, o = this.value ?? [], g = (n3, l2) => { + if (n3.disabled) + return u3(n3, "disabled"); + const s = o.includes(n3.value); + return l2 && s ? u3(n3, "active-selected") : s ? u3(n3, "selected") : u3(n3, l2 ? "active" : "inactive"); + }; + switch (this.state) { + case "submit": { + const n3 = this.options.filter(({ value: s }) => o.includes(s)).map((s) => u3(s, "submitted")).join(styleText2("dim", ", ")) || styleText2("dim", "none"), l2 = wrapTextWithPrefix( + i2.output, + n3, + t2 ? `${styleText2("gray", S_BAR)} ` : "" + ); + return `${r2}${l2}`; + } + case "cancel": { + const n3 = this.options.filter(({ value: s }) => o.includes(s)).map((s) => u3(s, "cancelled")).join(styleText2("dim", ", ")); + if (n3.trim() === "") + return `${r2}${styleText2("gray", S_BAR)}`; + const l2 = wrapTextWithPrefix( + i2.output, + n3, + t2 ? `${styleText2("gray", S_BAR)} ` : "" + ); + return `${r2}${l2}${t2 ? ` +${styleText2("gray", S_BAR)}` : ""}`; + } + case "error": { + const n3 = t2 ? `${styleText2("yellow", S_BAR)} ` : "", l2 = this.error.split(` +`).map( + ($, v) => v === 0 ? `${t2 ? `${styleText2("yellow", S_BAR_END)} ` : ""}${styleText2("yellow", $)}` : ` ${$}` + ).join(` +`), s = r2.split(` +`).length, h2 = l2.split(` +`).length + 1; + return `${r2}${n3}${limitOptions({ + output: i2.output, + options: this.options, + cursor: this.cursor, + maxItems: i2.maxItems, + columnPadding: n3.length, + rowPadding: s + h2, + style: g + }).join(` +${n3}`)} +${l2} +`; + } + default: { + const n3 = t2 ? `${styleText2("cyan", S_BAR)} ` : "", l2 = r2.split(` +`).length, s = x ? formatInstructionFooter(MULTISELECT_INSTRUCTIONS, t2) : t2 ? [styleText2("cyan", S_BAR_END)] : [], h2 = s.join(` +`), $ = s.length + 1; + return `${r2}${n3}${limitOptions({ + output: i2.output, + options: this.options, + cursor: this.cursor, + maxItems: i2.maxItems, + columnPadding: n3.length, + rowPadding: l2 + $, + style: g + }).join(` +${n3}`)} +${h2} +`; + } + } + } + }).prompt(); +}; +var log = { + message: (s = [], { + symbol: e = styleText2("gray", S_BAR), + secondarySymbol: r2 = styleText2("gray", S_BAR), + output: m2 = process.stdout, + spacing: l2 = 1, + withGuide: c2 + } = {}) => { + const t2 = [], o = c2 ?? settings.withGuide, f = o ? r2 : "", O = o ? `${e} ` : "", u3 = o ? `${r2} ` : ""; + for (let i2 = 0; i2 < l2; i2++) + t2.push(f); + const g = Array.isArray(s) ? s : s.split(` +`); + if (g.length > 0) { + const [i2, ...y2] = g; + i2.length > 0 ? t2.push(`${O}${i2}`) : t2.push(o ? e : ""); + for (const p of y2) + p.length > 0 ? t2.push(`${u3}${p}`) : t2.push(o ? r2 : ""); + } + m2.write(`${t2.join(` +`)} +`); + }, + info: (s, e) => { + log.message(s, { ...e, symbol: styleText2("blue", S_INFO) }); + }, + success: (s, e) => { + log.message(s, { ...e, symbol: styleText2("green", S_SUCCESS) }); + }, + step: (s, e) => { + log.message(s, { ...e, symbol: styleText2("green", S_STEP_SUBMIT) }); + }, + warn: (s, e) => { + log.message(s, { ...e, symbol: styleText2("yellow", S_WARN) }); + }, + /** alias for `log.warn()`. */ + warning: (s, e) => { + log.warn(s, e); + }, + error: (s, e) => { + log.message(s, { ...e, symbol: styleText2("red", S_ERROR) }); + } +}; +var cancel = (o = "", t2) => { + const i2 = t2?.output ?? process.stdout, e = t2?.withGuide ?? settings.withGuide ? `${styleText2("gray", S_BAR_END)} ` : ""; + i2.write(`${e}${styleText2("red", o)} + +`); +}; +var W$1 = (o) => o; +var C = (o, e, s) => { + const a2 = { + hard: true, + trim: false + }, i2 = wrapAnsi(o, e, a2).split(` +`), c2 = i2.reduce((n3, t2) => Math.max(dist_default2(t2), n3), 0), u3 = i2.map(s).reduce((n3, t2) => Math.max(dist_default2(t2), n3), 0), g = e - (u3 - c2); + return wrapAnsi(o, g, a2); +}; +var note = (o = "", e = "", s) => { + const a2 = s?.output ?? process$1.stdout, i2 = s?.withGuide ?? settings.withGuide, c2 = s?.format ?? W$1, g = ["", ...C(o, getColumns(a2) - 6, c2).split(` +`).map(c2), ""], n3 = dist_default2(e), t2 = Math.max( + g.reduce((m2, F) => { + const O = dist_default2(F); + return O > m2 ? O : m2; + }, 0), + n3 + ) + 2, h2 = g.map( + (m2) => `${styleText2("gray", S_BAR)} ${m2}${" ".repeat(t2 - dist_default2(m2))}${styleText2("gray", S_BAR)}` + ).join(` +`), T = i2 ? `${styleText2("gray", S_BAR)} +` : "", l$1 = i2 ? S_CONNECT_LEFT : S_CORNER_BOTTOM_LEFT; + a2.write( + `${T}${styleText2("green", S_STEP_SUBMIT)} ${styleText2("reset", e)} ${styleText2( + "gray", + S_BAR_H.repeat(Math.max(t2 - n3 - 1, 1)) + S_CORNER_TOP_RIGHT + )} +${h2} +${styleText2("gray", l$1 + S_BAR_H.repeat(t2 + 2) + S_CORNER_BOTTOM_RIGHT)} +` + ); +}; +var W = (l2) => styleText2("magenta", l2); +var spinner = ({ + indicator: l2 = "dots", + onCancel: h2, + output: n3 = process.stdout, + cancelMessage: G, + errorMessage: O, + frames: E = unicode ? ["\u25D2", "\u25D0", "\u25D3", "\u25D1"] : ["\u2022", "o", "O", "0"], + delay: F = unicode ? 80 : 120, + signal: m2, + ...I2 +} = {}) => { + const u3 = isCI(); + let M, T, d = false, S = false, s = "", p, w = performance.now(); + const x = getColumns(n3), k = I2?.styleFrame ?? W, g = (e) => { + const r2 = e > 1 ? O ?? settings.messages.error : G ?? settings.messages.cancel; + S = e === 1, d && (a2(r2, e), S && typeof h2 == "function" && h2()); + }, f = () => g(2), i2 = () => g(1), A = () => { + process.on("uncaughtExceptionMonitor", f), process.on("unhandledRejection", f), process.on("SIGINT", i2), process.on("SIGTERM", i2), process.on("exit", g), m2 && m2.addEventListener("abort", i2); + }, H = () => { + process.removeListener("uncaughtExceptionMonitor", f), process.removeListener("unhandledRejection", f), process.removeListener("SIGINT", i2), process.removeListener("SIGTERM", i2), process.removeListener("exit", g), m2 && m2.removeEventListener("abort", i2); + }, y2 = () => { + if (p === void 0) return; + u3 && n3.write(` +`); + const r2 = wrapAnsi(p, x, { + hard: true, + trim: false + }).split(` +`); + r2.length > 1 && n3.write(import_sisteransi2.cursor.up(r2.length - 1)), n3.write(import_sisteransi2.cursor.to(0)), n3.write(import_sisteransi2.erase.down()); + }, C2 = (e) => e.replace(/\.+$/, ""), _ = (e) => { + const r2 = (performance.now() - e) / 1e3, t2 = Math.floor(r2 / 60), o = Math.floor(r2 % 60); + return t2 > 0 ? `[${t2}m ${o}s]` : `[${o}s]`; + }, N = I2.withGuide ?? settings.withGuide, P = (e = "") => { + d = true, M = block({ output: n3 }), s = C2(e), w = performance.now(), N && n3.write(`${styleText2("gray", S_BAR)} +`); + let r2 = 0, t2 = 0; + A(), T = setInterval(() => { + if (u3 && s === p) + return; + y2(), p = s; + const o = k(E[r2]); + let v; + if (u3) + v = `${o} ${s}...`; + else if (l2 === "timer") + v = `${o} ${s} ${_(w)}`; + else { + const B = ".".repeat(Math.floor(t2)).slice(0, 3); + v = `${o} ${s}${B}`; + } + const j = wrapAnsi(v, x, { + hard: true, + trim: false + }); + n3.write(j), r2 = r2 + 1 < E.length ? r2 + 1 : 0, t2 = t2 < 4 ? t2 + 0.125 : 0; + }, F); + }, a2 = (e = "", r2 = 0, t2 = false) => { + if (!d) return; + d = false, clearInterval(T), y2(); + const o = r2 === 0 ? styleText2("green", S_STEP_SUBMIT) : r2 === 1 ? styleText2("red", S_STEP_CANCEL) : styleText2("red", S_STEP_ERROR); + s = e ?? s, t2 || (l2 === "timer" ? n3.write(`${o} ${s} ${_(w)} +`) : n3.write(`${o} ${s} +`)), H(), M(); + }; + return { + start: P, + stop: (e = "") => a2(e, 0), + message: (e = "") => { + s = C2(e ?? s); + }, + cancel: (e = "") => a2(e, 1), + error: (e = "") => a2(e, 2), + clear: () => a2("", 0, true), + get isCancelled() { + return S; + } + }; +}; +var u2 = { + light: unicodeOr("\u2500", "-"), + heavy: unicodeOr("\u2501", "="), + block: unicodeOr("\u2588", "#") +}; +var SELECT_INSTRUCTIONS = [ + `${styleText2("dim", "\u2191/\u2193")} to navigate`, + `${styleText2("dim", "Enter:")} confirm` +]; +var c = (t2, o) => t2.includes(` +`) ? t2.split(` +`).map((d) => o(d)).join(` +`) : o(t2); +var select = (t2) => { + const o = (n3, m2) => { + if (n3 === void 0) + return ""; + const s = n3.label ?? String(n3.value); + switch (m2) { + case "disabled": + return `${styleText2("gray", S_RADIO_INACTIVE)} ${c(s, (i2) => styleText2("gray", i2))}${n3.hint ? ` ${styleText2("dim", `(${n3.hint ?? "disabled"})`)}` : ""}`; + case "selected": + return `${c(s, (i2) => styleText2("dim", i2))}`; + case "active": + return `${styleText2("green", S_RADIO_ACTIVE)} ${s}${n3.hint ? ` ${styleText2("dim", `(${n3.hint})`)}` : ""}`; + case "cancelled": + return `${c(s, (i2) => styleText2(["strikethrough", "dim"], i2))}`; + default: + return `${styleText2("dim", S_RADIO_INACTIVE)} ${c(s, (i2) => styleText2("dim", i2))}`; + } + }, d = t2.showInstructions ?? true; + return new n$1({ + options: t2.options, + signal: t2.signal, + input: t2.input, + output: t2.output, + initialValue: t2.initialValue, + render() { + const n3 = t2.withGuide ?? settings.withGuide, m2 = `${symbol(this.state)} `, s = `${symbolBar(this.state)} `, i2 = wrapTextWithPrefix( + t2.output, + t2.message, + s, + m2 + ), u3 = `${n3 ? `${styleText2("gray", S_BAR)} +` : ""}${i2} +`; + switch (this.state) { + case "submit": { + const r2 = n3 ? `${styleText2("gray", S_BAR)} ` : "", a2 = wrapTextWithPrefix( + t2.output, + o(this.options[this.cursor], "selected"), + r2 + ); + return `${u3}${a2}`; + } + case "cancel": { + const r2 = n3 ? `${styleText2("gray", S_BAR)} ` : "", a2 = wrapTextWithPrefix( + t2.output, + o(this.options[this.cursor], "cancelled"), + r2 + ); + return `${u3}${a2}${n3 ? ` +${styleText2("gray", S_BAR)}` : ""}`; + } + default: { + const r2 = n3 ? `${styleText2("cyan", S_BAR)} ` : "", a2 = u3.split(` +`).length, p = d ? formatInstructionFooter(SELECT_INSTRUCTIONS, n3) : n3 ? [styleText2("cyan", S_BAR_END)] : [], f = p.join(` +`), b = p.length + 1; + return `${u3}${r2}${limitOptions({ + output: t2.output, + cursor: this.cursor, + options: this.options, + maxItems: t2.maxItems, + columnPadding: r2.length, + rowPadding: a2 + b, + style: (g, x) => o(g, g.disabled ? "disabled" : x ? "active" : "inactive") + }).join(` +${r2}`)} +${f} +`; + } + } + } + }).prompt(); +}; +var i = `${styleText2("gray", S_BAR)} `; +var text = (t2) => new n2({ + validate: t2.validate, + placeholder: t2.placeholder, + defaultValue: t2.defaultValue, + initialValue: t2.initialValue, + output: t2.output, + signal: t2.signal, + input: t2.input, + render() { + const r2 = t2?.withGuide ?? settings.withGuide, l2 = `${`${r2 ? `${styleText2("gray", S_BAR)} +` : ""}${symbol(this.state)} `}${t2.message} +`, d = t2.placeholder && t2.placeholder.length > 0 ? ( + // biome-ignore lint/style/noNonNullAssertion: guarded by placeholder.length > 0 + styleText2("inverse", t2.placeholder[0]) + styleText2("dim", t2.placeholder.slice(1)) + ) : styleText2(["inverse", "hidden"], "_"), o = this.userInput ? this.userInputWithCursor : d, s = this.value ?? ""; + switch (this.state) { + case "validating": { + const n3 = r2 ? `${styleText2("cyan", S_BAR)} ` : "", i2 = r2 ? styleText2("cyan", S_BAR_END) : "", c2 = styleText2("dim", o), $ = styleText2("dim", "Validating..."); + return `${l2}${n3}${c2} +${i2} ${$} +`; + } + case "error": { + const n3 = this.error ? ` ${styleText2("yellow", this.error)}` : "", i2 = r2 ? `${styleText2("yellow", S_BAR)} ` : "", c2 = r2 ? styleText2("yellow", S_BAR_END) : ""; + return `${l2.trim()} +${i2}${o} +${c2}${n3} +`; + } + case "submit": { + const n3 = s ? `${r2 ? " " : ""}${styleText2("dim", s)}` : "", i2 = r2 ? styleText2("gray", S_BAR) : ""; + return `${l2}${i2}${n3}`; + } + case "cancel": { + const n3 = s ? ` ${styleText2(["strikethrough", "dim"], s)}` : "", i2 = r2 ? styleText2("gray", S_BAR) : ""; + return `${l2}${i2}${n3}${s.trim() ? ` +${i2}` : ""}`; + } + default: { + const n3 = r2 ? `${styleText2("cyan", S_BAR)} ` : "", i2 = r2 ? styleText2("cyan", S_BAR_END) : ""; + return `${l2}${n3}${o} +${i2} +`; + } + } + } +}).prompt(); + +// src/prompt.ts +var CancelledError = class extends Error { + constructor() { + super("Cancelled."); + this.name = "CancelledError"; + } +}; + +// src/clack-prompter.ts +function clackPrompter(input, output) { + return { + async ask(question, defaultValue, example) { + const answer = await text({ message: question, defaultValue, placeholder: defaultValue ?? example, input, output }); + return unwrap(answer).trim() || defaultValue || ""; + }, + async confirm(question, defaultValue) { + return unwrap(await confirm({ message: question, initialValue: defaultValue, input, output })); + }, + async checklist(question, items) { + const selected = unwrap( + await multiselect({ + message: question, + options: items.map((item, position) => ({ value: position, label: item.label, hint: item.hint })), + initialValues: items.flatMap((item, position) => item.checked ? [position] : []), + required: false, + input, + output + }) + ); + return selected.map((position) => items[position].value); + }, + async choose(question, options) { + const index = unwrap( + await select({ + message: question, + options: options.map((option, position) => ({ value: position, label: option.label, hint: option.hint })), + input, + output + }) + ); + return options[index].value; + } + }; +} +function unwrap(answer) { + if (isCancel(answer)) throw new CancelledError(); + return answer; +} + +// src/api.ts +var ApiError = class extends Error { + constructor(status, errorCode, message, body) { + super(message); + this.status = status; + this.errorCode = errorCode; + this.body = body; + this.name = "ApiError"; + } + status; + errorCode; + body; +}; +function createApiClient(baseUrl, token, fetchImpl = fetch) { + const root = `${baseUrl.replace(/\/$/, "")}/api/v2/`; + return { + async request(method, path, body) { + const headers = { authorization: `Bearer ${token}`, accept: "application/json" }; + const init = { method, headers }; + if (body !== void 0) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + const response = await fetchImpl(root + path, init); + const text2 = await response.text(); + const parsed = text2 ? safeJson(text2) : void 0; + if (!response.ok) { + throw toApiError(response.status, parsed); + } + return parsed; + } + }; +} +function toApiError(status, body) { + const record = isRecord(body) ? body : {}; + const message = typeof record.message === "string" ? record.message : `HTTP ${status}`; + const errorCode = typeof record.errorCode === "string" ? record.errorCode : void 0; + return new ApiError(status, errorCode, message, body); +} +function listItems(body, key) { + if (Array.isArray(body)) return body; + if (isRecord(body) && Array.isArray(body[key])) return body[key]; + return []; +} +var PAGE_SIZE = 100; +var MAX_PAGES = 100; +async function listAll(api, path, key) { + const separator = path.includes("?") ? "&" : "?"; + const items = []; + for (let page = 0; page < MAX_PAGES; page += 1) { + const batch = listItems(await api.request("GET", `${path}${separator}page=${page}&per_page=${PAGE_SIZE}`), key); + items.push(...batch); + if (batch.length < PAGE_SIZE) return items; + } + throw new Error(`${path} returned more than ${MAX_PAGES * PAGE_SIZE} items. The listing did not stop paging.`); +} +function safeJson(text2) { + try { + return JSON.parse(text2); + } catch { + return text2; + } +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} + +// src/cli-api.ts +import { spawn } from "node:child_process"; +var DEFAULT_CONCURRENCY = 4; +var INSTALL_URL = "https://github.com/auth0/auth0-cli#installation"; +var SIGN_IN_PROBLEM = /auth token expired|login session has expired|token is invalid|corrupted authentication token|failed to find tenant|config\.json file is missing/i; +function createCliApiClient(options) { + const { bin, tenant, run = runCommand } = options; + const gate = createGate(options.concurrency ?? DEFAULT_CONCURRENCY); + return { + async request(method, path, body) { + await gate.enter(); + try { + const args = ["api", method, path.replace(/^\//, ""), "--no-color", "--no-input"]; + if (tenant) args.push("--tenant", tenant); + if (method === "DELETE") args.push("--force"); + const what = `auth0 api ${method} ${path}`; + const result = await runOrExplain(run, bin, args, body === void 0 ? void 0 : JSON.stringify(body)); + if (result.exitCode !== 0) throw explainFailure(result.stderr, what, tenant); + return parseOutput(result.stdout, what); + } finally { + gate.leave(); + } + } + }; +} +function createGate(concurrency) { + let limit = 1; + let active = 0; + let firstFinished = false; + const waiting = []; + const admit = () => { + while (active < limit && waiting.length > 0) { + active += 1; + waiting.shift()?.(); + } + }; + return { + enter: () => new Promise((resolve) => { + waiting.push(resolve); + admit(); + }), + leave: () => { + active -= 1; + if (!firstFinished) { + firstFinished = true; + limit = concurrency; + } + admit(); + } + }; +} +async function runOrExplain(run, bin, args, input) { + try { + return await run(bin, args, input); + } catch (error) { + if (error.code === "ENOENT") { + throw new PluginError( + `The Auth0 CLI was not found (tried "${bin}"). Install it from ${INSTALL_URL}, or set AUTH0_CLI_BIN to its path.`, + "auth0_cli_not_found" + ); + } + throw error; + } +} +function parseOutput(stdout2, what) { + const text2 = stdout2.trim(); + if (text2 === "") return void 0; + try { + return JSON.parse(text2); + } catch { + throw new Error(`${what} printed output that is not JSON: ${text2.slice(0, 120)}`); + } +} +function explainFailure(stderr, what, tenant) { + const scope = /lacks scope: ([A-Za-z0-9_:,-]+)/i.exec(stderr)?.[1]; + if (scope) { + return toApiError(403, { + statusCode: 403, + error: "Forbidden", + errorCode: "insufficient_scope", + message: `Insufficient scope, expected any of: ${scope}` + }); + } + const api = /(\d{3}): API request failed: (.*)$/m.exec(stderr); + if (api) return toApiError(Number(api[1]), parseBody(api[2])); + if (SIGN_IN_PROBLEM.test(stderr)) { + return loginRequired(`The Auth0 CLI cannot use ${tenant ?? "its active tenant"}: ${firstMessage(stderr)}`); + } + return new Error(`${what} failed: ${firstMessage(stderr)}`); +} +function loginRequired(message) { + return new PluginError(`${message} Run auth0 login, then run this command again.`, "auth0_login_required", ["auth0 login"]); +} +function parseBody(text2) { + for (const candidate of [text2, text2.replace(/\.$/, "")]) { + try { + return JSON.parse(candidate); + } catch { + } + } + return text2; +} +function firstMessage(stderr) { + const line = stderr.split("\n").map((entry) => entry.trim()).find((entry) => entry !== "" && !entry.startsWith("===")); + return (line ?? "no message").replace(/^[^A-Za-z0-9]+/, ""); +} +async function findActiveTenant(bin, run = runCommand) { + const what = "auth0 tenants list"; + const result = await runOrExplain(run, bin, ["tenants", "list", "--json", "--no-color", "--no-input"], void 0); + if (result.exitCode !== 0) throw explainFailure(result.stderr, what, void 0); + let tenants; + try { + tenants = parseOutput(result.stdout, what); + } catch { + tenants = void 0; + } + const active = Array.isArray(tenants) ? tenants.find((tenant) => tenant?.active === true) : void 0; + if (typeof active?.name !== "string") throw loginRequired("The Auth0 CLI has no active tenant."); + return active.name; +} +function runCommand(bin, args, input) { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout2 = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => stdout2 += chunk); + child.stderr.setEncoding("utf8").on("data", (chunk) => stderr += chunk); + child.on("error", reject); + child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr })); + child.stdin.on("error", () => void 0); + child.stdin.end(input ?? ""); + }); +} + +// src/context.ts +function checkTenantFlag(requested, context, consequence = "") { + if (requested && requested !== context.tenantDomain) { + throw new UsageError( + `--tenant ${requested} does not match the tenant the CLI is running against (${context.tenantDomain}).${consequence}` + ); + } +} +async function readContext(env, requestedTenant, run = runCommand) { + const proxyUrl = env.AUTH0_CLI_PROXY_URL; + const proxyToken = env.AUTH0_CLI_PROXY_TOKEN; + if (proxyUrl || proxyToken) { + const tenantDomain2 = env.AUTH0_CLI_TENANT_DOMAIN; + const missing = [ + ["AUTH0_CLI_PROXY_URL", proxyUrl], + ["AUTH0_CLI_PROXY_TOKEN", proxyToken], + ["AUTH0_CLI_TENANT_DOMAIN", tenantDomain2] + ].filter(([, value]) => !value).map(([name]) => name); + if (missing.length > 0 || !proxyUrl || !proxyToken || !tenantDomain2) { + throw new Error( + `The Auth0 CLI started this command with an incomplete environment. Missing: ${missing.join(", ")}` + ); + } + return { tenantDomain: tenantDomain2, transport: { kind: "proxy", url: proxyUrl, token: proxyToken } }; + } + const bin = env.AUTH0_CLI_BIN || "auth0"; + const tenantDomain = requestedTenant || env.AUTH0_CLI_TENANT_DOMAIN || await findActiveTenant(bin, run); + return { tenantDomain, transport: { kind: "cli", bin } }; +} +function apiFor(context, options = {}) { + const { transport } = context; + if (transport.kind === "proxy") return createApiClient(transport.url, transport.token, options.fetchImpl); + return createCliApiClient({ bin: transport.bin, tenant: context.tenantDomain, run: options.run }); +} + +// src/templates/icons.ts +var SIDEBAR_ICONS = { + profile: "user", + security: "shield-check", + legalPrivacy: "file-text", + organization: "building-2", + domains: "globe", + preferences: "sliders-horizontal" +}; + +// src/templates/security.ts +var SECURITY_PAGE = { + title: "Security", + slug: "security", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Multi-factor authentication", + description: "Add an extra layer of protection to your account by requiring a second verification step each time you sign in.", + variant: "card", + children: [{ type: "page:component:auth0:my_account:mfa_management" }] + } + }, + { + type: "page:component:auth0:structure:section", + config: { + title: "Passkeys", + description: "Use your fingerprint, face, or screen lock instead of a password to sign in quickly and more securely.", + variant: "card", + children: [{ type: "page:component:auth0:my_account:passkey_management" }] + } + } + ] +}; +var SECURITY_SIDEBAR_ICON = SIDEBAR_ICONS.security; + +// src/templates/business.ts +function businessTemplate(forms) { + const pages = [ + { + title: "Organization", + slug: "organization", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Organization details", + description: "Update your organization's name and other details visible to its members.", + variant: "card", + children: [{ type: "page:component:auth0:my_organization:details_edit" }] + } + } + ] + }, + { + title: "Domains", + slug: "domains", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Verified domains", + description: "Add and verify the domains that your organization members sign in with.", + variant: "card", + children: [{ type: "page:component:auth0:my_organization:domain_table" }] + } + } + ] + }, + SECURITY_PAGE, + { + title: "Preferences", + slug: "preferences", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Personal information", + description: "Basic info about you, like your name and contact details, that you use across services.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.personalInfo, + completion_message: "Your personal information has been updated." + } + } + ] + } + }, + { + type: "page:component:auth0:structure:section", + config: { + title: "Communication preferences", + description: "Choose which emails and notifications you'd like to receive from us.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.communicationPreferences, + completion_message: "Your communication preferences have been updated." + } + } + ] + } + } + ] + } + ]; + const sidebar = [ + link("Organization", "organization", SIDEBAR_ICONS.organization), + link("Domains", "domains", SIDEBAR_ICONS.domains), + link("Security", "security", SECURITY_SIDEBAR_ICON), + link("Preferences", "preferences", SIDEBAR_ICONS.preferences) + ]; + return { + id: "business", + apis: ["my-organization", "my-account"], + defaultPage: "organization", + sidebar, + pages + }; +} +function link(label, pageSlug, icon) { + return { type: "sidebar:component:auth0:internal_link:v2", config: { label, page_slug: pageSlug, icon } }; +} + +// src/templates/consumer.ts +function consumerTemplate(forms) { + const pages = [ + { + title: "Profile", + slug: "profile", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Personal information", + description: "Basic info about you, like your name and contact details, that you use across services.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.personalInfo, + completion_message: "Your personal information has been updated." + } + } + ] + } + } + ] + }, + SECURITY_PAGE, + { + title: "Legal & privacy", + slug: "legal-privacy", + components: [ + { + type: "page:component:auth0:structure:section", + config: { + title: "Privacy & data consent", + description: "Control how your personal data is collected and used across our services.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.privacyConsent, + completion_message: "Your privacy preferences have been saved." + } + } + ] + } + }, + { + type: "page:component:auth0:structure:section", + config: { + title: "Communication preferences", + description: "Choose which emails and notifications you'd like to receive from us.", + variant: "card", + children: [ + { + type: "page:component:auth0:form", + config: { + form_id: forms.communicationPreferences, + completion_message: "Your communication preferences have been updated." + } + } + ] + } + } + ] + } + ]; + const sidebar = [ + link2("Profile", "profile", SIDEBAR_ICONS.profile), + link2("Security", "security", SECURITY_SIDEBAR_ICON), + link2("Legal & privacy", "legal-privacy", SIDEBAR_ICONS.legalPrivacy) + ]; + return { + id: "consumer", + apis: ["my-account"], + defaultPage: "profile", + sidebar, + pages + }; +} +function link2(label, pageSlug, icon) { + return { type: "sidebar:component:auth0:internal_link:v2", config: { label, page_slug: pageSlug, icon } }; +} + +// src/setup/payload.ts +var MY_ACCOUNT_SCOPES = [ + "read:me:factors", + "read:me:authentication_methods", + "create:me:authentication_methods", + "delete:me:authentication_methods" +]; +var MY_ORGANIZATION_SCOPES = [ + "read:my_org:details", + "update:my_org:details", + "read:my_org:configuration", + "read:my_org:domains", + "create:my_org:domains", + "update:my_org:domains", + "delete:my_org:domains", + "read:my_org:identity_providers", + "create:my_org:identity_providers_domains", + "delete:my_org:identity_providers_domains" +]; +var FORMS_VAULT_SCOPES = ["update:users", "update:users_app_metadata"]; +var PORTAL_APP_SUFFIX = "(Universal Portals)"; +var PORTAL_GRANT_TYPES = [ + "authorization_code", + "refresh_token", + "http://auth0.com/oauth/grant-type/mfa-oob", + "http://auth0.com/oauth/grant-type/mfa-otp", + "http://auth0.com/oauth/grant-type/mfa-recovery-code" +]; +var REFRESH_TOKEN_LIFETIMES = { + idleSeconds: 3600, + absoluteSeconds: 86400 +}; +function myAccountAudience(tenantDomain) { + return `https://${tenantDomain}/me/`; +} +function myOrganizationAudience(tenantDomain) { + return `https://${tenantDomain}/my-org/`; +} +function managementAudience(tenantDomain) { + return `https://${tenantDomain}/api/v2/`; +} +function audiencesFor(template, tenantDomain) { + const account = { audience: myAccountAudience(tenantDomain), scope: MY_ACCOUNT_SCOPES }; + if (template === "consumer") return [account]; + return [{ audience: myOrganizationAudience(tenantDomain), scope: MY_ORGANIZATION_SCOPES }, account]; +} +function buildPortalClient(input) { + const client = { + name: input.name, + app_type: "regular_web", + is_first_party: true, + oidc_conformant: true, + token_endpoint_auth_method: "client_secret_post", + is_token_endpoint_ip_header_trusted: true, + callbacks: [`https://${input.portalHost}/portals/auth/callback`], + // Users land on the portal's own host after logout. + allowed_logout_urls: [`https://${input.portalHost}`], + grant_types: PORTAL_GRANT_TYPES, + organization_usage: "allow", + organization_require_behavior: "no_prompt", + refresh_token: refreshTokenSettings(input.template, input.tenantDomain), + client_metadata: { portals_setup_slug: input.slug }, + // Lets a native app open the portal with a session transfer token. + session_transfer: { allowed_authentication_methods: ["query"], enforce_device_binding: "ip" } + }; + if (input.isCustomDomain) { + client.oidc_logout = backChannelLogout(input.portalHost); + } + return client; +} +function refreshTokenSettings(template, tenantDomain) { + return { + rotation_type: "non-rotating", + expiration_type: "expiring", + token_lifetime: REFRESH_TOKEN_LIFETIMES.absoluteSeconds, + idle_token_lifetime: REFRESH_TOKEN_LIFETIMES.idleSeconds, + policies: audiencesFor(template, tenantDomain).map((entry) => ({ + audience: entry.audience, + scope: entry.scope + })) + }; +} +function backChannelLogout(portalHost) { + return { + backchannel_logout_urls: [`https://${portalHost}/portals/auth/backchannel-logout`], + backchannel_logout_initiators: { + mode: "custom", + selected_initiators: [ + "idp-logout", + "rp-logout", + "session-expired", + "session-revoked", + "account-deleted", + "account-deactivated" + ] + } + }; +} +function buildFormsVaultClient(name) { + return { + name, + app_type: "non_interactive", + client_metadata: { auth0_portals_role: "forms-vault" } + }; +} +function buildPortalRequest(template, input) { + return { + slug: input.slug, + name: input.name, + client: { + token_endpoint_auth_method: "client_secret_post", + client_id: input.clientId, + client_secret: input.clientSecret + }, + navigation: { sidebar: { components: template.sidebar } }, + pages: { default: template.defaultPage, standard: template.pages } + }; +} +function templateFor(id, forms) { + return id === "consumer" ? consumerTemplate(forms) : businessTemplate(forms); +} + +// src/setup/resources.ts +function describe(status) { + return status === "reused" ? "already exists" : status === "updated" ? "updated" : "created"; +} +var NameInUseError = class extends Error { + constructor(name) { + super( + `An application named "${name}" already exists and was not created by this setup. Rename or delete it, then rerun.` + ); + this.name = name; + this.name = "NameInUseError"; + } + name; +}; +var SlugTakenError = class extends Error { + constructor(slug) { + super(`The portal slug "${slug}" is already used by another portal.`); + this.slug = slug; + this.name = "SlugTakenError"; + } + slug; +}; +async function ensureResourceServer(api, input) { + const existing = await findResourceServer(api, input.identifier); + if (existing) return { value: { id: existing.id }, status: "reused" }; + try { + const created = await api.request("POST", "resource-servers", { + name: input.name, + identifier: input.identifier, + skip_consent_for_verifiable_first_party_clients: true, + token_dialect: "rfc9068_profile" + }); + return { value: { id: created.id }, status: "created" }; + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + const raced = await findResourceServer(api, input.identifier); + if (raced) return { value: { id: raced.id }, status: "reused" }; + } + throw error; + } +} +async function findResourceServer(api, identifier) { + return (await listAll(api, "resource-servers", "resource_servers")).find((server) => server.identifier === identifier); +} +async function ensurePortalClient(api, input) { + const existing = await findClientByName(api, input.name); + const payload = buildPortalClient(input); + if (existing) { + if (existing.client_metadata?.portals_setup_slug !== input.slug) { + throw new NameInUseError(input.name); + } + await api.request("PATCH", `clients/${existing.client_id}`, { + callbacks: payload.callbacks, + allowed_logout_urls: payload.allowed_logout_urls, + grant_types: payload.grant_types, + organization_usage: payload.organization_usage, + organization_require_behavior: payload.organization_require_behavior, + is_token_endpoint_ip_header_trusted: payload.is_token_endpoint_ip_header_trusted, + refresh_token: payload.refresh_token, + ...payload.oidc_logout ? { oidc_logout: payload.oidc_logout } : {}, + // Set only when missing, so a session transfer setting changed on purpose survives a rerun. + ...existing.session_transfer?.allowed_authentication_methods?.length ? {} : { session_transfer: payload.session_transfer } + }); + const clientSecret = await readClientSecret(api, existing.client_id); + return { value: { clientId: existing.client_id, clientSecret }, status: "reused" }; + } + const created = await api.request("POST", "clients", payload); + return { value: { clientId: created.client_id, clientSecret: created.client_secret }, status: "created" }; +} +async function findClientByName(api, name) { + const clients = await listAll( + api, + "clients?fields=client_id,name,client_metadata,session_transfer&include_fields=true", + "clients" + ); + return clients.find((client) => client.name === name); +} +async function readClientSecret(api, clientId) { + const client = await api.request( + "GET", + `clients/${encodeURIComponent(clientId)}?fields=client_id,client_secret&include_fields=true` + ); + if (!client.client_secret) { + throw new Error(`Could not read the client secret for ${clientId}. The session needs read:client_keys.`); + } + return client.client_secret; +} +async function ensureClientGrant(api, input) { + const existing = (await listAll(api, `client-grants?client_id=${encodeURIComponent(input.clientId)}`, "client_grants")).find((grant) => grant.audience === input.audience); + if (!existing) { + const created = await api.request("POST", "client-grants", { + client_id: input.clientId, + audience: input.audience, + scope: input.scope, + ...input.subjectType ? { subject_type: input.subjectType } : {} + }); + return { value: { id: created.id }, status: "created" }; + } + if (sameScopes(existing.scope ?? [], input.scope)) { + return { value: { id: existing.id }, status: "reused" }; + } + await api.request("PATCH", `client-grants/${existing.id}`, { scope: input.scope }); + return { value: { id: existing.id }, status: "updated" }; +} +function sameScopes(left, right) { + const a2 = [...new Set(left)].sort(); + const b = [...new Set(right)].sort(); + return a2.length === b.length && a2.every((scope, index) => scope === b[index]); +} +async function ensureFormsVaultClient(api, name) { + const existing = await findClientByName(api, name); + if (existing) { + if (existing.client_metadata?.auth0_portals_role !== "forms-vault") { + throw new NameInUseError(name); + } + const clientSecret = await readClientSecret(api, existing.client_id); + return { value: { clientId: existing.client_id, clientSecret }, status: "reused" }; + } + const created = await api.request( + "POST", + "clients", + buildFormsVaultClient(name) + ); + return { value: { clientId: created.client_id, clientSecret: created.client_secret }, status: "created" }; +} +function vaultSetupPayload(setup) { + return { + domain: setup.domain, + client_id: setup.clientId, + client_secret: setup.clientSecret, + type: "OAUTH_APP" + }; +} +async function ensureVaultConnection(api, name, setup) { + const existing = (await listAll(api, "flows/vault/connections", "connections")).find((connection) => connection.name === name); + if (existing) return { value: { id: existing.id }, status: "reused" }; + const created = await api.request("POST", "flows/vault/connections", { + app_id: "AUTH0", + name, + setup: vaultSetupPayload(setup) + }); + return { value: { id: created.id }, status: "created" }; +} +async function refreshVaultConnection(api, vaultId, setup) { + await api.request("PATCH", `flows/vault/connections/${encodeURIComponent(vaultId)}`, { + setup: vaultSetupPayload(setup) + }); +} +function flowIdFromImport(bundle) { + return Object.values(bundle.flows ?? {}).find((flow) => flow.id)?.id; +} +function flowIdFromNodes(form) { + return form.nodes?.find((node) => node.type === "FLOW" && node.config?.flow_id)?.config?.flow_id; +} +async function ensureForms(api, forms) { + const existing = await listAll(api, "forms", "forms"); + const result = {}; + for (const form of forms) { + const match = existing.find((candidate) => candidate.name === form.name); + if (match) { + const detail = await api.request( + "GET", + `forms/${encodeURIComponent(match.id)}` + ); + result[form.key] = { value: { id: match.id, flowId: flowIdFromNodes(detail) }, status: "reused" }; + continue; + } + const imported = await api.request("POST", "forms/import", form.document); + const flowId = flowIdFromImport(imported) ?? flowIdFromNodes(imported.form); + result[form.key] = { value: { id: imported.form.id, flowId }, status: "created" }; + } + return result; +} +async function findPortalBySlug(api, slug) { + const listed = (await listAll(api, "portals", "portals")).find((portal) => portal.slug === slug); + if (!listed) return void 0; + return api.request("GET", `portals/${encodeURIComponent(listed.id)}`); +} +async function ensurePortal(api, request) { + const existing = await findPortalBySlug(api, request.slug); + if (existing) { + if (existing.client?.client_id !== request.client.client_id) { + throw new SlugTakenError(request.slug); + } + return { value: { id: existing.id, slug: existing.slug }, status: "reused" }; + } + try { + const created = await api.request("POST", "portals", request); + return { value: { id: created.id, slug: created.slug }, status: "created" }; + } catch (error) { + if (error instanceof ApiError && error.status === 409) throw new SlugTakenError(request.slug); + throw error; + } +} + +// src/audit/snapshot.ts +async function fetchPortalSnapshot(api, portalId, tenantDomain, options = {}) { + const portal = await api.request("GET", `portals/${encodeURIComponent(portalId)}`); + const [applicationParts, resourceServers, forms, customDomain, organization] = await Promise.all([ + readApplicationParts(api, portal.client?.client_id), + listAll(api, "resource-servers", "resource_servers").then( + (servers) => servers.map((server) => server.identifier) + ), + readForms(api, portal), + readCustomDomain(api), + options.organizationId ? readOrganization(api, options.organizationId) : void 0 + ]); + const { application, grants, applicationConnections } = applicationParts; + return { + tenantDomain, + portal, + application, + grants, + resourceServers, + forms, + applicationConnections, + customDomain, + organization + }; +} +async function readApplicationParts(api, clientId) { + const application = clientId ? await readApplication(api, clientId) : void 0; + if (!application || !clientId) return { application, grants: [], applicationConnections: [] }; + const [grants, applicationConnections] = await Promise.all([ + listAll(api, `client-grants?client_id=${encodeURIComponent(clientId)}`, "client_grants"), + listAll(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections") + ]); + return { application, grants, applicationConnections }; +} +async function readForms(api, portal) { + const formIds = formIdsOf(portal); + const exist = await Promise.all(formIds.map((formId) => formExists(api, formId))); + return Object.fromEntries(formIds.map((formId, index) => [formId, exist[index]])); +} +function formIdsOf(portal) { + const ids = []; + const visit = (components = []) => { + for (const component of components) { + if (component.type === "page:component:auth0:form" && component.config?.form_id) { + ids.push(component.config.form_id); + } + visit(component.config?.children); + } + }; + for (const page of portal.pages?.standard ?? []) visit(page.components); + return [...new Set(ids)]; +} +async function readApplication(api, clientId) { + try { + return await api.request("GET", `clients/${encodeURIComponent(clientId)}`); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return void 0; + throw error; + } +} +async function formExists(api, formId) { + try { + await api.request("GET", `forms/${encodeURIComponent(formId)}`); + return true; + } catch (error) { + if (error instanceof ApiError && (error.status === 404 || error.status === 400)) return false; + throw error; + } +} +async function readCustomDomain(api) { + try { + const custom = await api.request("GET", "custom-domains/default"); + return custom; + } catch (error) { + if (error instanceof ApiError && error.status === 404) return void 0; + throw error; + } +} +async function readOrganization(api, organizationId) { + const org = await api.request("GET", `organizations/${encodeURIComponent(organizationId)}`); + const connections = (await listAll(api, `organizations/${encodeURIComponent(organizationId)}/enabled_connections`, "enabled_connections")).map((connection) => connection.connection_id); + return { id: org.id, name: org.name, connections }; +} +function hostOf(snapshot) { + return snapshot.customDomain?.status === "ready" ? snapshot.customDomain.domain : snapshot.tenantDomain; +} + +// src/audit/remedies.ts +function isLegacyLink(item) { + return !item.type.endsWith(":v2") || item.config?.to !== void 0 || item.config?.url !== void 0; +} +function toV2Link(item) { + if (!isLegacyLink(item)) return item; + const external = item.config?.url !== void 0 || item.config?.href !== void 0 || item.type.includes("external"); + const config = { label: item.config?.label, icon: item.config?.icon }; + if (external) { + config.href = item.config?.href ?? item.config?.url; + return { type: "sidebar:component:auth0:external_link:v2", config }; + } + config.page_slug = item.config?.page_slug ?? item.config?.to; + return { type: "sidebar:component:auth0:internal_link:v2", config }; +} +async function createPortalApplication(api, snapshot, usesOrganization) { + const client = await ensurePortalClient(api, { + name: `${snapshot.portal.name ?? snapshot.portal.slug} ${PORTAL_APP_SUFFIX}`, + slug: snapshot.portal.slug, + template: usesOrganization ? "business" : "consumer", + tenantDomain: snapshot.tenantDomain, + portalHost: hostOf(snapshot), + isCustomDomain: snapshot.customDomain?.status === "ready" + }); + return api.request("PATCH", `portals/${encodeURIComponent(snapshot.portal.id)}`, { + client: { + client_id: client.value.clientId, + client_secret: client.value.clientSecret, + token_endpoint_auth_method: "client_secret_post" + } + }); +} +function patchRefresh(api, app, changes) { + const current = app.refresh_token ?? {}; + return api.request("PATCH", `clients/${encodeURIComponent(app.client_id)}`, { + refresh_token: { + rotation_type: current.rotation_type ?? "non-rotating", + expiration_type: "expiring", + token_lifetime: current.token_lifetime ?? REFRESH_TOKEN_LIFETIMES.absoluteSeconds, + idle_token_lifetime: current.idle_token_lifetime ?? REFRESH_TOKEN_LIFETIMES.idleSeconds, + policies: current.policies ?? [], + ...changes + } + }); +} +function patchPages(api, portalId, snapshot, home) { + return api.request("PATCH", `portals/${encodeURIComponent(portalId)}`, { + pages: { default: home, standard: snapshot.portal.pages?.standard ?? [] } + }); +} +function addSidebarLink(api, portalId, snapshot, pageSlug, label, icon) { + const components = snapshot.portal.navigation?.sidebar?.components ?? []; + return api.request("PATCH", `portals/${encodeURIComponent(portalId)}`, { + navigation: { + sidebar: { + components: [ + ...components, + { type: "sidebar:component:auth0:internal_link:v2", config: { label, page_slug: pageSlug, icon } } + ] + } + } + }); +} + +// src/audit/checks.ts +function auditPortal(snapshot) { + const findings = []; + const add = (finding) => findings.push(finding); + const portalId = snapshot.portal.id; + const pages = snapshot.portal.pages?.standard ?? []; + const pageSlugs = new Set(pages.map((page) => page.slug)); + const sidebar = snapshot.portal.navigation?.sidebar?.components ?? []; + const sidebarPages = new Set(sidebar.map((item) => String(item.config?.page_slug ?? ""))); + const usedComponents = allComponents(pages); + const usesAccount = usedComponents.some((c2) => c2.type.includes("my_account")); + const usesOrganization = usedComponents.some((c2) => c2.type.includes("my_organization")); + const home = snapshot.portal.pages?.default; + if (!home || !pageSlugs.has(home)) { + const first = pages[0]; + add({ + id: "P001", + severity: "error", + check: "home page", + message: home ? `The home page "${home}" does not match any page, so users land on an error.` : "No home page is set, so users land on an error.", + remedy: first ? { + summary: `Set "${first.title}" as the home page`, + command: `auth0 api patch "portals/${portalId}" --data '{"pages":{"default":"${first.slug}","standard":[...]}}'`, + apply: (api) => patchPages(api, portalId, snapshot, first.slug) + } : { + summary: "Add a page and set it as the home page", + command: `auth0 api patch "portals/${portalId}" --data '{"pages":{"default":"","standard":[...]}}'` + } + }); + } else if (!sidebarPages.has(home)) { + const page = pages.find((candidate) => candidate.slug === home); + add({ + id: "P002", + severity: "warning", + check: "home page in navigation", + message: `The home page "${home}" is not in the sidebar, so users cannot get back to it.`, + remedy: { + summary: `Add "${page?.title ?? home}" to the sidebar`, + apply: (api) => addSidebarLink(api, portalId, snapshot, home, page?.title ?? home, "house") + } + }); + } + const legacyLinks = sidebar.filter(isLegacyLink); + if (legacyLinks.length > 0) { + add({ + id: "P003", + severity: "error", + check: "navigation shape", + message: `${legacyLinks.length} sidebar link(s) use the legacy shape.`, + remedy: { + summary: "Convert the sidebar links to the :v2 link types", + apply: (api) => api.request("PATCH", `portals/${encodeURIComponent(portalId)}`, { + navigation: { sidebar: { components: sidebar.map(toV2Link) } } + }) + } + }); + } + for (const item of sidebar) { + const target = item.config?.page_slug; + if (typeof target === "string" && !pageSlugs.has(target)) { + add({ + id: "P004", + subject: target, + severity: "error", + check: "sidebar link target", + message: `A sidebar link points to the page "${target}", which does not exist.`, + remedy: { summary: "Point the link at an existing page, or remove it" } + }); + } + } + for (const page of pages) { + if (!sidebarPages.has(page.slug)) { + add({ + id: "P005", + subject: page.slug, + severity: "warning", + check: "page in navigation", + message: `The page "${page.title}" is not in the sidebar, so users cannot reach it.`, + remedy: { + summary: `Add "${page.title}" to the sidebar`, + apply: (api) => addSidebarLink(api, portalId, snapshot, page.slug, page.title, "file") + } + }); + } + if ((page.components ?? []).length === 0) { + add({ + id: "P006", + subject: page.slug, + severity: "warning", + check: "empty page", + message: `The page "${page.title}" has no components.`, + remedy: { summary: "Add components to the page, or remove it" } + }); + } + } + for (const formId of Object.keys(snapshot.forms)) { + if (!snapshot.forms[formId]) { + add({ + id: "P007", + subject: formId, + severity: "error", + check: "form exists", + message: `A form on the portal (${formId}) does not exist in the tenant, so its component fails.`, + remedy: { summary: "Recreate the form, or remove the component from the page" } + }); + } + } + if (snapshot.portal.custom_code?.head && !snapshot.customDomain) { + add({ + id: "P008", + severity: "warning", + check: "custom code", + message: "Custom code is set, but the tenant has no ready custom domain, so it is never injected.", + remedy: { summary: "Set up a custom domain, or remove the custom code" } + }); + } + const app = snapshot.application; + if (!app) { + add({ + id: "P009", + severity: "error", + check: "application", + message: "The portal has no application linked to it.", + remedy: { + summary: "Create the portal application with the portal defaults and link it to the portal", + apply: (api) => createPortalApplication(api, snapshot, usesOrganization) + } + }); + return findings; + } + const clientId = app.client_id; + if (app.grant_types?.includes("client_credentials")) { + add({ + id: "P010", + severity: "error", + check: "client credentials", + message: "The portal application has the client_credentials grant, which the setup guide says must be off.", + remedy: { + summary: "Turn off the client_credentials grant", + apply: (api) => api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { + grant_types: (app.grant_types ?? []).filter((grant) => grant !== "client_credentials") + }) + } + }); + } + if (app.require_proof_of_possession) { + add({ + id: "P023", + severity: "error", + check: "DPoP", + message: "DPoP is enabled on the portal application. Portals are confidential clients and do not use DPoP.", + remedy: { + summary: "Turn DPoP off on the portal application", + apply: (api) => api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { require_proof_of_possession: false }) + } + }); + } + const managementGrants = snapshot.grants.filter((candidate) => candidate.audience.endsWith("/api/v2/")); + if (managementGrants.length > 0) { + const ids = managementGrants.map((grant) => grant.id).filter((id) => Boolean(id)); + add({ + id: "P011", + severity: "error", + check: "management api grant", + message: "The portal application has a Management API grant, which the setup guide says must not be there.", + remedy: { + summary: "Remove the Management API grant from the portal application", + apply: ids.length === managementGrants.length ? async (api) => { + for (const id of ids) await api.request("DELETE", `client-grants/${encodeURIComponent(id)}`); + } : void 0, + command: ids.length === managementGrants.length ? void 0 : 'auth0 api get "client-grants?client_id=' + clientId + '"' + } + }); + } + const callback = `https://${hostOf(snapshot)}/portals/auth/callback`; + if (!app.callbacks?.includes(callback)) { + add({ + id: "P012", + severity: "error", + check: "callback url", + message: `The callback URL ${callback} is missing, so sign-in to the portal fails.`, + remedy: { + summary: "Add the portal callback URL", + apply: (api) => api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { + callbacks: [...app.callbacks ?? [], callback] + }) + } + }); + } + const refresh = app.refresh_token; + if (refresh?.rotation_type && refresh.rotation_type !== "non-rotating") { + add({ + id: "P013", + severity: "error", + check: "refresh token rotation", + message: "Refresh token rotation is on, and portals do not support it. Components fail intermittently.", + remedy: { + summary: "Turn refresh token rotation off", + apply: (api) => patchRefresh(api, app, { rotation_type: "non-rotating" }) + } + }); + } + if (refresh?.infinite_idle_token_lifetime) { + add({ + id: "P014", + severity: "error", + check: "refresh token idle lifetime", + message: "The refresh token has no idle expiry, so the portal session never ends by inactivity.", + remedy: { + summary: `Set the idle lifetime to ${REFRESH_TOKEN_LIFETIMES.idleSeconds} seconds`, + apply: (api) => patchRefresh(api, app, { infinite_idle_token_lifetime: false, idle_token_lifetime: REFRESH_TOKEN_LIFETIMES.idleSeconds }) + } + }); + } else if (refresh && (refresh.idle_token_lifetime !== REFRESH_TOKEN_LIFETIMES.idleSeconds || refresh.token_lifetime !== REFRESH_TOKEN_LIFETIMES.absoluteSeconds)) { + add({ + id: "P015", + severity: "warning", + check: "refresh token lifetimes", + message: `The refresh token lifetimes differ from the guide (${REFRESH_TOKEN_LIFETIMES.idleSeconds}s idle, ${REFRESH_TOKEN_LIFETIMES.absoluteSeconds}s total).`, + remedy: { + summary: "Set the lifetimes to the guide's values", + apply: (api) => patchRefresh(api, app, { + idle_token_lifetime: REFRESH_TOKEN_LIFETIMES.idleSeconds, + token_lifetime: REFRESH_TOKEN_LIFETIMES.absoluteSeconds + }) + } + }); + } + if (app.organization_usage !== "allow" && usesOrganization) { + add({ + id: "P016", + severity: "error", + check: "organization usage", + message: "The portal has organization components, but the application does not allow organizations.", + remedy: { + summary: "Allow organizations on the application", + apply: (api) => api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { organization_usage: "allow" }) + } + }); + } + if (app.is_token_endpoint_ip_header_trusted === false) { + add({ + id: "P017", + severity: "warning", + check: "token endpoint IP header", + message: "The guide says to trust the token endpoint IP header, and it is off.", + remedy: { + summary: "Trust the token endpoint IP header", + apply: (api) => api.request("PATCH", `clients/${encodeURIComponent(clientId)}`, { is_token_endpoint_ip_header_trusted: true }) + } + }); + } + const needed = [ + { audience: myAccountAudience(snapshot.tenantDomain), name: "My Account API", scope: MY_ACCOUNT_SCOPES, used: usesAccount }, + { + audience: myOrganizationAudience(snapshot.tenantDomain), + name: "My Organization API", + scope: MY_ORGANIZATION_SCOPES, + used: usesOrganization + } + ]; + for (const api of needed.filter((entry) => entry.used)) { + const usedBy = componentsOn(usedComponents, api.name).map((component) => componentLabel(component)).join(", "); + if (!snapshot.resourceServers.includes(api.audience)) { + add({ + id: "P018", + subject: api.audience, + severity: "error", + check: "resource server", + message: `The components (${usedBy}) need the ${api.name}, but the tenant has no resource server for ${api.audience}.`, + remedy: { + summary: `Create the ${api.name} resource server`, + apply: (client) => client.request("POST", "resource-servers", { + name: `Auth0 ${api.name}`, + identifier: api.audience, + skip_consent_for_verifiable_first_party_clients: true, + token_dialect: "rfc9068_profile" + }) + } + }); + } + if (!snapshot.grants.some((grant) => grant.audience === api.audience)) { + add({ + id: "P019", + subject: api.audience, + severity: "error", + check: "client grant", + message: `The components (${usedBy}) need the ${api.name}, but the application has no client grant for it.`, + remedy: { + summary: `Grant the application access to the ${api.name}`, + apply: (client) => client.request("POST", "client-grants", { + client_id: clientId, + audience: api.audience, + scope: api.scope, + subject_type: "user" + }) + } + }); + } + if (!refresh?.policies?.some((policy) => policy.audience === api.audience)) { + add({ + id: "P020", + subject: api.audience, + severity: "error", + check: "refresh token policy", + message: `The components (${usedBy}) need the ${api.name}, but the refresh token has no policy for it, so the portal cannot get its tokens.`, + remedy: { + summary: `Add the ${api.name} to the multi-resource refresh token`, + apply: (client) => patchRefresh(client, app, { + policies: [...refresh?.policies ?? [], { audience: api.audience, scope: api.scope }] + }) + } + }); + } + } + if (snapshot.applicationConnections.length === 0) { + add({ + id: "P021", + severity: "error", + check: "connections", + message: "No connection is enabled for the application, so nobody can sign in.", + remedy: { + summary: "Enable a connection for the application, or promote one to the tenant level", + command: `auth0 api patch "connections//clients" --data '[{"client_id":"${clientId}","status":true}]'` + } + }); + } + if (snapshot.organization) { + const shared = snapshot.organization.connections.some( + (id) => snapshot.applicationConnections.some((connection) => connection.id === id) + ); + if (!shared) { + add({ + id: "P022", + severity: "error", + check: "organization connections", + message: `Organization "${snapshot.organization.name}" has no connection in common with the application, so its members cannot sign in.`, + remedy: { + summary: "Enable a connection that the application and the organization share", + command: `auth0 api post "organizations/${snapshot.organization.id}/enabled_connections" --data '{"connection_id":"","assign_membership_on_login":false}'` + } + }); + } + } + if (!app.session_transfer?.allowed_authentication_methods?.length) { + add({ + id: "P024", + severity: "warning", + check: "session transfer", + message: "Session transfer is not enabled, so a native app cannot open the portal with a session.", + remedy: { + summary: "Allow session transfer on the portal application", + command: `auth0 api patch "clients/${clientId}" --data '{"session_transfer":{"allowed_authentication_methods":["query"],"enforce_device_binding":"ip"}}'` + } + }); + } + return findings; +} +function componentsOn(components, apiName) { + const needle = apiName === "My Account API" ? "my_account" : "my_organization"; + return components.filter((component) => component.type.includes(needle)); +} +function componentLabel(component) { + const name = component.type.split(":").pop() ?? component.type; + return name.replace(/_/g, " "); +} +function allComponents(pages) { + const all = []; + const visit = (components = []) => { + for (const component of components) { + all.push(component); + visit(component.config?.children); + } + }; + for (const page of pages) visit(page.components); + return all; +} + +// src/audit/fix.ts +function fixableFindings(findings) { + return findings.filter((finding) => finding.severity === "error" && finding.remedy?.apply); +} +function findingKey(finding) { + return `${finding.id}:${finding.subject ?? ""}`; +} +async function applyFixes(selected, deps) { + const { ui } = deps; + const result = { applied: 0, failed: 0, alreadyFixed: 0 }; + for (const finding of selected) { + const fresh = auditPortal(await deps.readSnapshot()).find((candidate) => findingKey(candidate) === findingKey(finding)); + if (!fresh?.remedy?.apply) { + result.alreadyFixed += 1; + ui.success(`${finding.id} ${finding.check}`, "already fixed"); + continue; + } + const label = `${fresh.id} ${fresh.check}`; + try { + await ui.task(`Fixing ${fresh.check}`, () => fresh.remedy.apply(deps.api)); + result.applied += 1; + ui.fixed(label, fresh.remedy.summary); + } catch (error) { + result.failed += 1; + ui.error(`${label}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return result; +} +var MAX_FIX_ROUNDS = 3; +async function applyFixRounds(selected, deps, chooseNext) { + const total = { applied: 0, failed: 0, alreadyFixed: 0 }; + const attempted = /* @__PURE__ */ new Set(); + let pending = selected; + for (let round = 0; round < MAX_FIX_ROUNDS && pending.length > 0; round += 1) { + for (const finding of pending) attempted.add(findingKey(finding)); + const result = await applyFixes(pending, deps); + total.applied += result.applied; + total.failed += result.failed; + total.alreadyFixed += result.alreadyFixed; + const exposed = fixableFindings(auditPortal(await deps.readSnapshot())).filter((finding) => !attempted.has(findingKey(finding))); + pending = exposed.length > 0 ? await chooseNext(exposed) : []; + } + return total; +} + +// src/audit/run.ts +async function runAudit(options, deps) { + const { api, ui, tenantDomain } = deps; + if (options.showBanner !== false) ui.banner("Universal Portals audit", tenantDomain); + const snapshot = await ui.task( + "Reading the portal", + () => fetchPortalSnapshot(api, options.portalId, tenantDomain, { organizationId: options.organizationId }) + ); + const findings = auditPortal(snapshot); + const errors = findings.filter((finding) => finding.severity === "error").length; + const warnings = findings.length - errors; + ui.section(`Portal "${snapshot.portal.slug}"`); + if (findings.length === 0) { + ui.success("No problems found"); + } else { + ui.table( + ["", "ID", "Check", "Fix"], + findings.map((finding) => [ + finding.severity === "error" ? "\u2716" : "\u25B2", + finding.id, + finding.check, + finding.remedy?.summary ?? finding.message + ]) + ); + for (const finding of findings.filter((item) => item.remedy?.command && !item.remedy.apply)) { + ui.detail(`${finding.id} manually: ${finding.remedy?.command}`); + } + } + ui.newline(); + const summary = `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`; + if (errors > 0) ui.error(summary); + else if (warnings > 0) ui.warn(summary); + else ui.success(summary); + return { + portalId: options.portalId, + findings, + errors, + warnings, + failed: errors > 0 || options.strict && warnings > 0 + }; +} + +// src/scope-fixes.ts +function scopeFixes(scopes) { + return [ + `Signed in as a user: auth0 login --scopes ${scopes.join(",")}`, + `Signed in with client credentials: add ${scopes.join(", ")} to the application's Management API grant` + ]; +} + +// src/setup/preflight.ts +var SETUP_PROBES = [ + { name: "list resource servers", method: "GET", path: "resource-servers?per_page=1" }, + { name: "create resource server", method: "POST", path: "resource-servers", body: {} }, + { name: "list clients", method: "GET", path: "clients?per_page=1" }, + { name: "create client", method: "POST", path: "clients", body: {} }, + { name: "list client grants", method: "GET", path: "client-grants?per_page=1" }, + { name: "create client grant", method: "POST", path: "client-grants", body: {} }, + { name: "list vault connections", method: "GET", path: "flows/vault/connections" }, + { name: "create vault connection", method: "POST", path: "flows/vault/connections", body: {} }, + { name: "list forms", method: "GET", path: "forms?per_page=1" }, + { name: "import forms", method: "POST", path: "forms/import", body: {} }, + { name: "list portals", method: "GET", path: "portals" }, + { name: "create portal", method: "POST", path: "portals", body: {} }, + { + name: "look up users by email", + method: "GET", + path: "users?q=email%3A%22preflight%40example.invalid%22&search_engine=v3", + businessOnly: true + }, + { name: "list organizations", method: "GET", path: "organizations?per_page=1", businessOnly: true } +]; +var FeatureNotEnabledError = class extends PluginError { + constructor(tenantDomain) { + super( + `Universal Portals is not enabled for ${tenantDomain}. Ask Auth0 to enable it for this tenant, then run the command again.`, + "feature_not_enabled" + ); + this.tenantDomain = tenantDomain; + } + tenantDomain; +}; +async function runPreflight(api, probes, tenantDomain) { + const gaps = []; + const unexpected = []; + const failures = await Promise.all( + probes.map(async (probe) => { + try { + await api.request(probe.method, probe.path, probe.body); + return void 0; + } catch (error) { + return { probe, error }; + } + }) + ); + for (const failure of failures) { + if (!failure) continue; + const { probe, error } = failure; + if (!(error instanceof ApiError)) throw error; + if (error.status === 400) continue; + if (error.errorCode === "feature_not_enabled") throw new FeatureNotEnabledError(tenantDomain); + const gap = parseScopeGap(probe.name, error); + if (gap) { + gaps.push(gap); + } else { + unexpected.push({ probe: probe.name, status: error.status, message: error.message }); + } + } + return { gaps, unexpected }; +} +function parseScopeGap(probe, error) { + if (error.status !== 403) return void 0; + const match = /expected (any|all) of:\s*(.+)$/.exec(error.message); + if (!match) return void 0; + const requirement = match[1] === "all" ? "all" : "any"; + const scopes = match[2].split(",").map((scope) => scope.trim()).filter(Boolean); + return { probe, requirement, scopes }; +} +function requiredMissingScopes(result) { + const required = result.gaps.filter((gap) => gap.requirement === "all" || gap.scopes.length === 1); + return [...new Set(required.flatMap((gap) => gap.scopes))].sort(); +} +function choiceGaps(result) { + return result.gaps.filter((gap) => gap.requirement === "any" && gap.scopes.length > 1); +} + +// src/errors.ts +function reportError(error, json, io, ui) { + const { code, message, details } = describeError(error); + if (json) { + io.stderr.write(`${JSON.stringify({ error: code, message, ...details })} +`); + return 1; + } + ui.error(message); + for (const fix of details.fixes ?? []) ui.detail(fix); + return error instanceof UsageError ? 2 : 1; +} +function describeError(error) { + if (error instanceof PluginError) { + return { code: error.code, message: error.message, details: error.fixes ? { fixes: error.fixes } : {} }; + } + if (error instanceof ApiError) { + const gap = parseScopeGap("", error); + const scopes = gap && gap.requirement === "any" ? gap.scopes.slice(0, 1) : gap?.scopes ?? []; + const fixes = scopes.length > 0 ? { fixes: scopeFixes(scopes) } : {}; + return { code: error.errorCode ?? "api_error", message: error.message, details: { status: error.status, ...fixes } }; + } + if (error instanceof Error) return { code: "setup_failed", message: error.message, details: {} }; + return { code: "setup_failed", message: String(error), details: {} }; +} + +// src/audit-command.ts +async function runAuditCommand(args, env, io, ui, prompter) { + try { + const context = await readContext(env, args.tenant, io.run); + checkTenantFlag(args.tenant, context); + const api = apiFor(context, { fetchImpl: io.fetchImpl, run: io.run }); + const portalId = args.portalId ?? ""; + const organizationId = args.organization; + const readSnapshot = () => fetchPortalSnapshot(api, portalId, context.tenantDomain, { organizationId }); + const options = { portalId, organizationId, strict: args.strict }; + const report = await runAudit(options, { api, ui, tenantDomain: context.tenantDomain }); + const fixable = fixableFindings(report.findings); + const applyDeps = { api, ui, readSnapshot }; + let result = report; + if (args.fix) { + const errors = fixable.filter((finding) => finding.severity === "error"); + if (errors.length > 0) { + ui.section("Fix"); + await applyFixRounds(errors, applyDeps, async (exposed) => exposed); + result = await finalAudit(options, ui, api, context.tenantDomain); + } + } else if (prompter && fixable.length > 0) { + const selected = await chooseFixes(prompter, fixable, ui); + if (selected.length > 0) { + ui.section("Fix"); + await applyFixRounds(selected, applyDeps, async (exposed) => { + ui.detail("These errors appeared after the fixes above."); + return chooseFixes(prompter, exposed, ui); + }); + result = await finalAudit(options, ui, api, context.tenantDomain); + } + } else if (fixable.length > 0) { + ui.detail("Run with --fix to apply the errors the CLI can fix."); + } + if (args.json) io.stdout.write(`${JSON.stringify(result, null, 2)} +`); + return result.failed ? 1 : 0; + } catch (error) { + if (error instanceof CancelledError && !args.json) { + ui.warn("Cancelled. Fixes already applied stay in your tenant."); + ui.cancelled("Audit cancelled."); + return 130; + } + return reportError(error, args.json, io, ui); + } +} +async function chooseFixes(prompter, errors, ui) { + ui.detail("Warnings are not changed. They may be intentional."); + const choice = await prompter.choose("What do you want to fix?", [ + { label: "Fix all errors", hint: "recommended", value: "all" }, + { label: "Choose errors to fix", hint: "every error starts checked", value: "choose" }, + { label: "Skip", hint: "leave the portal as it is", value: "skip" } + ]); + if (choice === "skip") return []; + if (choice === "all") return errors; + return prompter.checklist( + "Errors to fix", + errors.map((finding) => ({ + label: `${finding.id} ${finding.remedy?.summary ?? finding.message}`, + value: finding, + checked: true + })) + ); +} +async function finalAudit(options, ui, api, tenantDomain) { + ui.section("Checking the portal again"); + return runAudit({ ...options, showBanner: false }, { api, ui, tenantDomain }); +} + +// src/browser.ts +import { spawn as spawn2 } from "node:child_process"; +function browserCommand(url, platform) { + if (platform === "darwin") return { command: "open", args: [url] }; + if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] }; + return { command: "xdg-open", args: [url] }; +} +function openBrowser(url, platform = process.platform) { + const { command, args } = browserCommand(url, platform); + const child = spawn2(command, args, { detached: true, stdio: "ignore" }); + child.on("error", () => void 0); + child.unref(); +} + +// src/dashboard.ts +function tenantLocation(tenantDomain) { + const parts = tenantDomain.split("."); + const hasRegion = parts.length >= 4 && parts[1].length === 2; + const serviceDomain = hasRegion ? parts.slice(2) : parts.slice(1); + return { + base: `https://manage.${serviceDomain.join(".")}`, + region: hasRegion ? parts[1] : "us", + name: parts[0] + }; +} +function dashboardRoot(tenantDomain) { + const { base, region, name } = tenantLocation(tenantDomain); + return `${base}/dashboard/${region}/${name}`; +} +function applicationUrl(tenantDomain, clientId) { + return `${dashboardRoot(tenantDomain)}/applications/${clientId}/settings`; +} +function portalEditUrl(tenantDomain, portalId) { + return `${dashboardRoot(tenantDomain)}/universal-portals/${portalId}/edit`; +} +function formsRoot(tenantDomain) { + const { region, name } = tenantLocation(tenantDomain); + return `https://forms.auth0.com/tenants/${region}/${name}`; +} +function formEditUrl(tenantDomain, formId) { + return `${formsRoot(tenantDomain)}/forms/${formId}/edit`; +} +function flowEditUrl(tenantDomain, flowId) { + return `${formsRoot(tenantDomain)}/flows/${flowId}/edit`; +} +function vaultAppsUrl(tenantDomain) { + return `${formsRoot(tenantDomain)}/vault/apps/AUTH0/edit`; +} + +// src/forms.ts +var FORM_NAMES = { + personalInfo: "Profile update (Universal Portals)", + privacyConsent: "Privacy settings (Universal Portals)", + communicationPreferences: "Marketing communication preferences (Universal Portals)" +}; +function buildPortalForms(vaultConnectionId) { + return [ + { key: "personalInfo", name: FORM_NAMES.personalInfo, document: personalInfoDocument(vaultConnectionId) }, + { + key: "communicationPreferences", + name: FORM_NAMES.communicationPreferences, + document: communicationPreferencesDocument(vaultConnectionId) + }, + { key: "privacyConsent", name: FORM_NAMES.privacyConsent, document: privacyDocument(vaultConnectionId) } + ]; +} +function field(id, label, type, extra = {}) { + return { id, category: "FIELD", type, label, required: false, sensitive: false, ...extra }; +} +function nextButton(id, text2) { + return { id, category: "BLOCK", type: "NEXT_BUTTON", config: { text: text2 } }; +} +function formDocument(name, step, start, ending, flowName, actions, connectionId) { + return { + version: "4.0.0", + form: { + name, + languages: { primary: "en" }, + nodes: [ + { + id: step.id, + type: "STEP", + coordinates: step.coordinates, + config: { components: step.components, next_node: step.flowNodeId } + }, + { + id: step.flowNodeId, + type: "FLOW", + coordinates: { x: 988, y: 9 }, + config: { flow_id: "#FLOW-1#", next_node: "$ending" } + } + ], + start, + ending + }, + flows: { "#FLOW-1#": { name: flowName, actions } }, + connections: { "#CONN-1#": { id: connectionId } } + }; +} +function updateUserAction(id, changes) { + return { + id, + type: "AUTH0", + action: "UPDATE_USER", + allow_failure: false, + mask_output: false, + params: { + connection_id: "#CONN-1#", + user_id: "{{context.user.user_id}}", + changes + } + }; +} +function personalInfoDocument(connectionId) { + return formDocument( + FORM_NAMES.personalInfo, + { + id: "step_BL3V", + coordinates: { x: 346, y: -221 }, + flowNodeId: "flow_DcHK", + components: [ + field("full_name", "Full name", "TEXT", { config: { multiline: false } }), + field("job_title", "Job title", "TEXT", { config: { multiline: false } }), + field("mobile_number", "Mobile number", "TEL", { config: { country_picker: true } }), + field("date_of_birth", "Date of birth", "DATE", { config: { format: "DATE" } }), + field("linkedin", "LinkedIn", "URL"), + nextButton("next_button_UmEY", "Update") + ] + }, + { next_node: "step_BL3V", coordinates: { x: 0, y: 0 } }, + { resume_flow: true, coordinates: { x: 1367, y: -7 } }, + "Update profile", + [ + updateUserAction("update_user_v4C0", { + user_metadata: { + linkedin: "{{fields.linkedin}}", + full_name: "{{fields.full_name}}", + job_title: "{{fields.job_title}}", + date_of_birth: "{{fields.date_of_birth}}", + mobile_number: "{{fields.mobile_number.international_number}}" + } + }) + ], + connectionId + ); +} +function communicationPreferencesDocument(connectionId) { + return formDocument( + FORM_NAMES.communicationPreferences, + { + id: "step_4Td2", + coordinates: { x: 217, y: -218 }, + flowNodeId: "flow_etvH", + components: [ + field("communication_preferences", "Communication preferences", "CHOICE", { + config: { + multiple: true, + options: [ + { label: "Product Announcements", value: "Product Announcements" }, + { label: "Featured Content", value: "Featured Content" }, + { label: "Digest News", value: "Digest News" }, + { label: "Events", value: "Events" } + ] + } + }), + nextButton("next_button_US5N", "Update") + ] + }, + { next_node: "step_4Td2", coordinates: { x: -25, y: -98 } }, + { resume_flow: true, coordinates: { x: 1194, y: -59 } }, + "Update preferences", + [ + updateUserAction("update_user_HitP", { + app_metadata: { communication_preferences: "{{fields.communication_preferences}}" } + }) + ], + connectionId + ); +} +function privacyDocument(connectionId) { + const consentField = (id, label, hint) => ({ + id, + category: "FIELD", + type: "BOOLEAN", + label, + hint, + required: true, + sensitive: false, + config: { default_value: false } + }); + const learnMore = (text2) => `

${text2} Learn more.

`; + return formDocument( + FORM_NAMES.privacyConsent, + { + id: "step_y0Ri", + coordinates: { x: 500, y: 0 }, + flowNodeId: "flow_AB1r", + components: [ + consentField("data_sharing", "Data sharing", learnMore("Allow app usage data to improve features.")), + consentField("profile_visibility", "Profile visibility", learnMore("Show my profile in search results.")), + consentField( + "location_tracking", + "Location tracking", + learnMore("Allow location tracking for personalized recommendations.") + ), + consentField("ad_personalization", "Ad personalization", learnMore("Use my data to personalize ads.")), + nextButton("next_button_aB8e", "Update") + ] + }, + { next_node: "step_y0Ri", coordinates: { x: 202, y: 247 } }, + { resume_flow: true, coordinates: { x: 1445, y: 247 } }, + "Update privacy settings", + [ + updateUserAction("update_user_uOZW", { + app_metadata: { + data_sharing: "{{fields.data_sharing}}", + location_tracking: "{{fields.location_tracking}}", + ad_personalization: "{{fields.ad_personalization}}", + profile_visibility: "{{fields.profile_visibility}}" + } + }) + ], + connectionId + ); +} + +// src/portal-model.ts +var PORTAL_SLUG_PATTERN = /^[a-z][a-z0-9-]{0,99}$/; +function isValidPortalSlug(slug) { + return PORTAL_SLUG_PATTERN.test(slug); +} + +// src/setup/business-types.ts +var BusinessAccessError = class extends PluginError { + constructor(message, fixes) { + super(message, "business_access", fixes); + } +}; +function userLabel(user) { + return user.email ?? user.user_id; +} + +// src/setup/business-organization.ts +var ORG_NAME_PATTERN = /^[a-z0-9_-]{1,50}$/; +function suggestOrgName(displayName) { + return displayName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50); +} +async function createOrganization(user, deps) { + const displayName = (await deps.prompt.ask("Organization display name", void 0, "Acme Corp")).trim(); + if (!displayName) throw new BusinessAccessError("The organization needs a display name.", []); + let suggestion = suggestOrgName(displayName); + for (; ; ) { + const orgName = await askOrgName(deps, suggestion); + const confirmed = await deps.prompt.confirm( + `Create organization "${displayName}" (${orgName}), enable a database connection on it, and add ${userLabel(user)}?`, + true + ); + if (!confirmed) { + throw new BusinessAccessError("No organization was created.", [ + `Create one: auth0 orgs create --name ${orgName} --display "${displayName}"` + ]); + } + let created; + try { + created = await deps.api.request("POST", "organizations", { + name: orgName, + display_name: displayName + }); + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 409) throw error; + deps.ui.warn(`The organization name "${orgName}" is already used in this tenant.`); + suggestion = `${orgName}-2`.slice(0, 50); + continue; + } + deps.ui.success(`Created organization "${displayName}"`); + const connection = await chooseDatabaseConnection(deps); + await deps.api.request("POST", `organizations/${encodeURIComponent(created.id)}/enabled_connections`, { + connection_id: connection, + assign_membership_on_login: false + }); + await deps.api.request("POST", `organizations/${encodeURIComponent(created.id)}/members`, { + members: [user.user_id] + }); + deps.ui.success(`Added ${userLabel(user)} to ${displayName}`); + return created; + } +} +async function askOrgName(deps, suggestion) { + for (; ; ) { + const answer = (await deps.prompt.ask("Organization name", suggestion)).trim(); + if (ORG_NAME_PATTERN.test(answer)) return answer; + deps.ui.error("Use lowercase letters, numbers, hyphens and underscores, up to 50 characters."); + } +} +async function chooseDatabaseConnection(deps) { + const connections = (await listAll(deps.api, "connections", "connections")).filter((connection) => connection.strategy === "auth0"); + if (connections.length === 0) { + throw new BusinessAccessError("This tenant has no database connection to sign in with.", [ + "Create one in the dashboard, then rerun setup." + ]); + } + if (connections.length === 1) return connections[0].id; + return deps.prompt.choose( + "Database connection for the organization", + connections.map((connection) => ({ label: connection.name, value: connection.id })) + ); +} +async function requireEnabledConnection(organization, api) { + const connections = await listAll(api, `organizations/${encodeURIComponent(organization.id)}/enabled_connections`, "enabled_connections"); + if (connections.length === 0) { + throw new BusinessAccessError(`Organization "${organization.name}" has no enabled connections, so members cannot sign in.`, [ + `Enable one: auth0 api post "organizations/${organization.id}/enabled_connections" --data '{"connection_id":"","assign_membership_on_login":false}'` + ]); + } +} + +// src/setup/business-role.ts +async function ensureOrganizationRole(user, organization, deps) { + const roles = await listAll( + deps.api, + `organizations/${encodeURIComponent(organization.id)}/members/${encodeURIComponent(user.user_id)}/roles`, + "roles" + ); + const granted = /* @__PURE__ */ new Set(); + for (const role2 of roles) { + for (const scope of await permissionsOf(role2.id, deps)) granted.add(scope); + } + const missing = MY_ORGANIZATION_SCOPES.filter((scope) => !granted.has(scope)); + if (missing.length === 0) return true; + const missingReads = missing.filter((scope) => scope.startsWith("read:")); + const missingWrites = missing.filter((scope) => !scope.startsWith("read:")); + if (missingReads.length > 0) { + deps.ui.warn( + `${userLabel(user)} lacks read permissions for the organization components in "${organization.name}": ${missingReads.join(", ")}. Those components will not open for them.` + ); + } + if (missingWrites.length > 0) { + deps.ui.warn( + `${userLabel(user)} lacks write permissions for the organization components in "${organization.name}": ${missingWrites.join(", ")}. The components open, but changes cannot be saved.` + ); + } + if (!deps.interactive) return false; + const candidates = await rolesGranting(missing, deps); + let role; + if (candidates.length === 0) { + const create = await deps.prompt.confirm( + `No role in this tenant has these permissions. Create "${PORTAL_ROLE_NAME}" and assign it to ${userLabel(user)}?`, + true + ); + if (!create) return false; + role = await createPortalRole(deps); + } else { + const assign = await deps.prompt.confirm("Assign an organization role now?", true); + if (!assign) return false; + const picked = await deps.prompt.choose("Role to assign", [ + ...candidates.map((candidate) => ({ label: candidate.name, value: candidate })), + { label: `Create "${PORTAL_ROLE_NAME}"`, hint: "only the permissions the portal needs", value: "create" } + ]); + role = picked === "create" ? await createPortalRole(deps) : picked; + } + await deps.api.request( + "POST", + `organizations/${encodeURIComponent(organization.id)}/members/${encodeURIComponent(user.user_id)}/roles`, + { roles: [role.id] } + ); + deps.ui.success(`Assigned role ${role.name} to ${userLabel(user)}`); + return true; +} +var PORTAL_ROLE_NAME = "Universal Portals - Admin"; +async function createPortalRole(deps) { + const tenantRoles = await listAll(deps.api, "roles", "roles"); + const role = tenantRoles.find((candidate) => candidate.name === PORTAL_ROLE_NAME) ?? await deps.api.request("POST", "roles", { + name: PORTAL_ROLE_NAME, + description: "The organization permissions the Universal Portals components need." + }); + const granted = await permissionsOf(role.id, deps); + const missing = MY_ORGANIZATION_SCOPES.filter((scope) => !granted.includes(scope)); + if (missing.length > 0) { + await deps.api.request("POST", `roles/${encodeURIComponent(role.id)}/permissions`, { + permissions: missing.map((permission_name) => ({ + permission_name, + resource_server_identifier: myOrganizationAudience(deps.tenantDomain) + })) + }); + } + return role; +} +async function permissionsOf(roleId, deps) { + const permissions = await listAll( + deps.api, + `roles/${encodeURIComponent(roleId)}/permissions`, + "permissions" + ); + return permissions.map((permission) => permission.permission_name); +} +async function rolesGranting(scopes, deps) { + const tenantRoles = await listAll(deps.api, "roles", "roles"); + const matches = []; + for (const role of tenantRoles) { + const granted = await permissionsOf(role.id, deps); + if (scopes.every((scope) => granted.includes(scope))) matches.push(role); + } + return matches; +} + +// src/setup/business.ts +async function resolveBusinessAccess(input, deps) { + const user = await chooseUser(input.userQuery, deps); + deps.ui.success(`"${userLabel(user)}" found`); + const organization = await chooseOrganization(user, input.organization, deps); + deps.ui.success(`Organization "${organization.name}" selected`); + await requireEnabledConnection(organization, deps.api); + const hasOrganizationRole = await ensureOrganizationRole(user, organization, deps); + return { + userId: user.user_id, + organizationId: organization.id, + organizationName: organization.name, + hasOrganizationRole + }; +} +function describeUser(user) { + return `${user.email ?? "no email"} \xB7 ${user.user_id}${user.name ? ` \xB7 ${user.name}` : ""}`; +} +async function lookupUsers(query, api) { + if (query.includes("@")) { + const phrase = `email:"${query.replace(/["\\]/g, "\\$&")}"`; + const found = await listAll(api, `users?q=${encodeURIComponent(phrase)}&search_engine=v3`, "users"); + return found.filter((user) => user.email?.toLowerCase() === query.toLowerCase()); + } + try { + return [await api.request("GET", `users/${encodeURIComponent(query)}`)]; + } catch (error) { + if (error instanceof ApiError && error.status === 404) return []; + throw error; + } +} +async function chooseUser(initialQuery, deps) { + let query = initialQuery; + for (; ; ) { + if (!query) { + if (!deps.interactive) { + throw new BusinessAccessError("A Business portal needs the user who will test it.", [ + "Rerun with --user ." + ]); + } + query = (await deps.prompt.ask("Enter the test user's email or user ID", void 0, "jane@acme.com")).trim(); + if (!query) continue; + } + const matches = await lookupUsers(query, deps.api); + if (matches.length === 1) return matches[0]; + if (!deps.interactive) { + if (matches.length === 0) { + throw new BusinessAccessError(`No user matches "${query}".`, ["Check the email or user ID, then rerun with --user."]); + } + throw new BusinessAccessError(`More than one user has the email "${query}".`, [ + "Rerun with the user ID instead: " + matches.map((user) => user.user_id).join(", ") + ]); + } + if (matches.length === 0) { + deps.ui.warn(`No user matches "${query}".`); + query = void 0; + continue; + } + const choice = await deps.prompt.choose(`${matches.length} users match "${query}"`, [ + ...matches.map((user) => ({ label: describeUser(user), value: user })), + { label: "Try another email or user ID", value: "retry" } + ]); + if (choice === "retry") { + query = void 0; + continue; + } + return choice; + } +} +async function chooseOrganization(user, requested, deps) { + const memberships = await listAll(deps.api, `users/${encodeURIComponent(user.user_id)}/organizations`, "organizations"); + if (requested) { + const match = memberships.find((org) => org.id === requested || org.name === requested); + if (match) return match; + throw new BusinessAccessError(`${userLabel(user)} is not a member of "${requested}".`, [ + `Add them: auth0 api post "organizations/${requested}/members" --data '{"members":["${user.user_id}"]}'` + ]); + } + if (!deps.interactive) { + if (memberships.length === 1) return memberships[0]; + if (memberships.length > 1) { + throw new BusinessAccessError(`${userLabel(user)} belongs to ${memberships.length} organizations.`, [ + "Rerun with --organization : " + memberships.map((org) => org.id).join(", ") + ]); + } + throw new BusinessAccessError(`${userLabel(user)} is not a member of any organization.`, [ + `Add them: auth0 api post "organizations//members" --data '{"members":["${user.user_id}"]}'` + ]); + } + if (memberships.length === 1) return memberships[0]; + const candidates = memberships.length > 0 ? memberships : await listTenantOrganizations(deps.api); + if (memberships.length === 0) deps.ui.warn(`${userLabel(user)} is not a member of any organization.`); + deps.ui.info("The portal opens in one organization, and the test user has to be a member of it."); + const choice = await deps.prompt.choose("Choose the organization to test the portal in", [ + ...candidates.map((org) => ({ + label: `${org.name} (${org.id})`, + value: org + })), + { label: "None of these, create a new organization", value: "create" } + ]); + if (choice === "create") return createOrganization(user, deps); + if (memberships.length > 0) return choice; + return addToOrganization(user, choice, deps); +} +async function listTenantOrganizations(api) { + return await listAll(api, "organizations", "organizations"); +} +async function addToOrganization(user, organization, deps) { + const confirmed = await deps.prompt.confirm(`Add ${userLabel(user)} as a member of ${organization.name}?`, true); + if (!confirmed) { + throw new BusinessAccessError(`${userLabel(user)} is not a member of an organization, and setup did not add them.`, [ + `Add them: auth0 api post "organizations/${organization.id}/members" --data '{"members":["${user.user_id}"]}'` + ]); + } + await deps.api.request("POST", `organizations/${encodeURIComponent(organization.id)}/members`, { + members: [user.user_id] + }); + deps.ui.success(`Added ${userLabel(user)} to ${organization.name}`); + return organization; +} +var BUSINESS_REQUIREMENTS = "Business portals only work in the context of an organization: without it, the organization components do not load. Pick the user who will test the portal, so the link opens in their organization."; +var SKIPPED_TEST_USER_REQUIREMENTS = "Without a test user, the link needs an organization. The portal only signs members in when the URL carries ?organization=."; +var ORGANIZATION_PLACEHOLDER = ""; +async function chooseTestLink(input, deps) { + const { ui, prompt, interactive } = deps; + ui.section("Test user"); + let userQuery = input.userQuery; + if (userQuery === void 0 && interactive) { + ui.info(BUSINESS_REQUIREMENTS); + userQuery = (await prompt.ask("Enter the test user's email or user ID (press Enter to skip)", void 0, "jane@acme.com")).trim() || void 0; + } + if (userQuery) { + const access = await resolveBusinessAccess({ userQuery, organization: input.organization }, deps); + return { organizationId: access.organizationId, isPlaceholder: false, access }; + } + if (interactive) ui.info(SKIPPED_TEST_USER_REQUIREMENTS); + const organizationId = await prompt.ask( + "Enter the organization ID to test in (the link adds ?organization=)", + ORGANIZATION_PLACEHOLDER + ); + return { organizationId, isPlaceholder: organizationId === ORGANIZATION_PLACEHOLDER }; +} + +// src/setup/host.ts +async function resolvePortalHost(api, tenantDomain) { + const custom = await readCustomDomain(api); + if (custom?.status === "ready" && custom.domain) return { domain: custom.domain, isCustom: true }; + return { domain: tenantDomain, isCustom: false }; +} +function portalUrl(domain, slug, organizationId) { + const base = `https://${domain}/portals/${slug}`; + if (!organizationId) return base; + const value = organizationId === ORGANIZATION_PLACEHOLDER ? organizationId : encodeURIComponent(organizationId); + return `${base}?organization=${value}`; +} + +// src/setup/vault.ts +var FORMS_VAULT_NAME = "Universal Portals forms vault"; +async function ensureFormsVault(api, ui, tenantDomain, resources) { + const app = await ui.task("Preparing the forms vault", () => ensureFormsVaultClient(api, FORMS_VAULT_NAME)); + const grant = await ensureClientGrant(api, { + clientId: app.value.clientId, + audience: managementAudience(tenantDomain), + scope: FORMS_VAULT_SCOPES, + subjectType: "client" + }); + const setup = { domain: tenantDomain, clientId: app.value.clientId, clientSecret: app.value.clientSecret }; + const vault = await ensureVaultConnection(api, FORMS_VAULT_NAME, setup); + if (grant.status === "updated" && vault.status === "reused") { + await refreshVaultConnection(api, vault.value.id, setup); + ui.success("Forms vault refreshed with the updated permissions"); + } + resources.push({ type: "forms_vault_client", id: app.value.clientId, status: app.status }); + resources.push({ type: "forms_vault_grant", id: grant.value.id, status: grant.status }); + resources.push({ type: "forms_vault", id: vault.value.id, status: vault.status }); + ui.success(`"${FORMS_VAULT_NAME}"`, describe(vault.status)); + ui.reference("Vault connection", vault.value.id, vaultAppsUrl(tenantDomain)); + return vault; +} + +// src/setup/run.ts +var SlugRaceError = class extends PluginError { + constructor(slug) { + super( + `The slug "${slug}" was taken while setup was running. Rerun with a different --name and --slug.`, + "slug_race" + ); + this.slug = slug; + } + slug; +}; +var MissingScopesError = class extends PluginError { + constructor(scopes, choices) { + super( + scopes.length > 0 ? `The CLI session is missing ${scopes.length} scope(s): ${scopes.join(", ")}.` : "The CLI session is missing a scope it needs.", + "missing_scopes", + scopes.length > 0 ? scopeFixes(scopes) : void 0 + ); + this.scopes = scopes; + this.choices = choices; + } + scopes; + choices; +}; +async function runSetup(options, deps) { + const resources = []; + const { ui, api, tenantDomain } = deps; + const probes = SETUP_PROBES.filter((probe) => !probe.businessOnly || options.template === "business"); + await checkPermissions(probes, deps); + let slug = options.slug; + if (!isValidPortalSlug(slug)) throw new Error(`The slug "${slug}" must match ^[a-z][a-z0-9-]{0,99}$.`); + if (options.template === "business" && !options.userQuery && !deps.interactive) { + throw new BusinessAccessError("A Business portal needs the user who will test it.", [ + "Rerun with --user ." + ]); + } + const appName = `${options.name} ${PORTAL_APP_SUFFIX}`; + slug = await availableSlug(api, slug, appName, deps); + const testLink = options.template === "business" ? await chooseTestLink(options, deps) : void 0; + const host = await resolvePortalHost(api, tenantDomain); + ui.section("Resource servers"); + for (const server of resourceServersFor(options.template, tenantDomain)) { + const outcome = await ui.task(`Checking ${server.name}`, () => ensureResourceServer(api, server)); + resources.push({ type: "resource_server", id: outcome.value.id, status: outcome.status }); + ui.success(`"${server.name}"`, describe(outcome.status)); + } + ui.result("Resource servers ready"); + ui.section("Application"); + const client = await ui.task( + "Creating application", + () => ensurePortalClient(api, { + name: appName, + slug, + template: options.template, + tenantDomain, + portalHost: host.domain, + isCustomDomain: host.isCustom + }) + ); + resources.push({ type: "application", id: client.value.clientId, status: client.status }); + ui.success(`"${appName}"`, describe(client.status)); + ui.reference("Application", client.value.clientId, applicationUrl(tenantDomain, client.value.clientId)); + for (const audience of audiencesFor(options.template, tenantDomain)) { + const grant = await ui.task( + `Granting ${apiLabel(audience.audience, tenantDomain)}`, + () => ensureClientGrant(api, { + clientId: client.value.clientId, + audience: audience.audience, + scope: audience.scope, + subjectType: "user" + }) + ); + resources.push({ type: "client_grant", id: grant.value.id, status: grant.status }); + ui.success(`"${apiLabel(audience.audience, tenantDomain)}" access`, describe(grant.status)); + } + ui.result("Application ready"); + ui.section("Forms"); + const vault = await ensureFormsVault(api, deps.ui, tenantDomain, resources); + const forms = await ui.task("Creating forms", () => ensureForms(api, buildPortalForms(vault.value.id))); + for (const [key, outcome] of Object.entries(forms)) { + resources.push({ type: "form", id: outcome.value.id, status: outcome.status }); + ui.success(`"${FORM_NAMES[key]}"`, describe(outcome.status)); + ui.reference("Form", outcome.value.id, formEditUrl(tenantDomain, outcome.value.id)); + if (outcome.value.flowId) ui.reference("Flow", outcome.value.flowId, flowEditUrl(tenantDomain, outcome.value.flowId)); + } + ui.result("Forms ready"); + const template = templateFor(options.template, { + personalInfo: forms.personalInfo.value.id, + privacyConsent: forms.privacyConsent.value.id, + communicationPreferences: forms.communicationPreferences.value.id + }); + ui.section("Portal"); + let portal; + try { + portal = await ui.task( + "Creating portal", + () => ensurePortal( + api, + buildPortalRequest(template, { + slug, + name: options.name, + clientId: client.value.clientId, + clientSecret: client.value.clientSecret + }) + ) + ); + } catch (error) { + if (!(error instanceof SlugTakenError)) throw error; + throw new SlugRaceError(slug); + } + resources.push({ type: "portal", id: portal.value.id, status: portal.status }); + ui.success(`"${options.name}"`, describe(portal.status)); + ui.result("Portal ready"); + ui.reference("Portal", portal.value.id, portalEditUrl(tenantDomain, portal.value.id)); + const url = portalUrl(host.domain, portal.value.slug, testLink?.organizationId); + if (testLink?.isPlaceholder) { + ui.info(`Replace ${ORGANIZATION_PLACEHOLDER} in the link with the organization ID to test with.`); + } + return { + template: options.template, + portal: { id: portal.value.id, slug: portal.value.slug, url, status: portal.status }, + application: { clientId: client.value.clientId, status: client.status }, + vault: { id: vault.value.id, status: vault.status }, + forms: Object.fromEntries( + Object.entries(forms).map(([key, outcome]) => [key, { id: outcome.value.id, status: outcome.status }]) + ), + resources, + organization: testLink?.access ? { + id: testLink.access.organizationId, + name: testLink.access.organizationName, + hasRole: testLink.access.hasOrganizationRole + } : void 0 + }; +} +async function checkPermissions(probes, deps) { + deps.ui.section("Permissions"); + const result = await deps.ui.task("Checking permissions", () => runPreflight(deps.api, probes, deps.tenantDomain)); + for (const issue of result.unexpected) { + deps.ui.warn(`Could not verify "${issue.probe}": ${issue.message} (HTTP ${issue.status})`); + } + const required = requiredMissingScopes(result); + const choices = choiceGaps(result); + if (required.length === 0 && choices.length === 0) { + deps.ui.result("The CLI session has every permission setup needs"); + return; + } + deps.ui.error("The CLI session is missing permissions setup needs"); + for (const scope of required) deps.ui.detail(scope); + for (const choice of choices) deps.ui.detail(`one of: ${choice.scopes.join(", ")}`); + throw new MissingScopesError(required, choices); +} +function resourceServersFor(template, tenantDomain) { + const account = { name: "Auth0 My Account API", identifier: myAccountAudience(tenantDomain) }; + if (template === "consumer") return [account]; + return [{ name: "Auth0 My Organization API", identifier: myOrganizationAudience(tenantDomain) }, account]; +} +function apiLabel(audience, tenantDomain) { + if (audience === myAccountAudience(tenantDomain)) return "My Account API"; + if (audience === myOrganizationAudience(tenantDomain)) return "My Organization API"; + return "Management API"; +} +async function availableSlug(api, slug, appName, deps) { + let current = slug; + for (; ; ) { + const portal = await findPortalBySlug(api, current); + if (!portal) return current; + const app = await findClientByName(api, appName); + if (app !== void 0 && portal.client?.client_id === app.client_id) return current; + if (!deps.interactive) { + throw new Error(`The slug "${current}" is already used by another portal. Rerun with a different --slug.`); + } + deps.ui.warn(`The slug "${current}" is already taken.`); + current = await askValidSlug(deps.prompt, `${current}-2`, deps.ui); + } +} +async function askValidSlug(prompt, suggestion, ui) { + for (; ; ) { + const answer = await prompt.ask("Slug", suggestion); + if (isValidPortalSlug(answer)) return answer; + ui.error("The slug must start with a lowercase letter and use only lowercase letters, digits and hyphens."); + } +} + +// src/slug.ts +function toPortalSlug(name) { + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "").slice(0, 100).replace(/-+$/, ""); + return slug || "my-portal"; +} + +// src/write-tracker.ts +function trackWrites(api) { + let wrote = false; + return { + api: { + async request(method, path, body) { + const result = await api.request(method, path, body); + if (method !== "GET") wrote = true; + return result; + } + }, + wrote: () => wrote + }; +} + +// src/setup-command.ts +async function runSetupCommand(args, env, io, ui, interactive, prompter) { + try { + const context = await readContext(env, args.tenant, io.run); + checkTenantFlag(args.tenant, context, " Nothing was created."); + if (!interactive) { + const missing = missingFlags(args); + if (missing.length > 0) { + throw new UsageError(`Missing required flags for a non-interactive run: ${missing.join(", ")}`); + } + } + const tracked = trackWrites(apiFor(context, { fetchImpl: io.fetchImpl, run: io.run })); + ui.banner("Universal Portals setup", context.tenantDomain); + try { + const options = prompter ? await askForMissing(args, prompter, context.tenantDomain) : optionsFromArgs(args); + const result = await runSetup(options, { + api: tracked.api, + ui, + prompt: prompter ?? unansweredPrompter(), + interactive, + tenantDomain: context.tenantDomain + }); + if (args.json) { + io.stdout.write(`${JSON.stringify(result, null, 2)} +`); + return 0; + } + ui.ready(result.portal.url); + if (prompter && await declinesToOpen(prompter)) return 0; + if (prompter) (io.openBrowser ?? openBrowser)(result.portal.url); + return 0; + } catch (error) { + if (error instanceof CancelledError && !args.json) return reportCancelled(ui, tracked.wrote()); + if (!args.json && tracked.wrote() && !(error instanceof SlugRaceError)) { + const code = reportError(error, false, io, ui); + ui.detail(RESUME_HINT); + return code; + } + throw error; + } + } catch (error) { + return reportError(error, args.json, io, ui); + } +} +async function declinesToOpen(prompter) { + try { + return !await prompter.confirm("Open the portal in your browser?", true); + } catch (error) { + if (error instanceof CancelledError) return true; + throw error; + } +} +var RESUME_HINT = "Run the same command again to resume. Existing resources are reused."; +function reportCancelled(ui, wrote) { + if (wrote) { + ui.warn("Cancelled. Nothing was rolled back: what was already created is still in your tenant."); + ui.warn(RESUME_HINT); + } else { + ui.warn("Cancelled. Nothing was created."); + } + ui.cancelled("Setup cancelled."); + return 130; +} +function optionsFromArgs(args) { + const name = args.name ?? ""; + return { + template: args.template ?? "consumer", + name, + slug: args.slug ?? toPortalSlug(name), + userQuery: args.user, + organization: args.organization + }; +} +async function askForMissing(args, prompt, tenantDomain) { + const template = args.template ?? await prompt.choose("Which kind of portal do you want to set up?", [ + { + label: "Consumer portal", + hint: "for end-users to manage their account in a self-service way", + value: "consumer" + }, + { + label: "Business portal", + hint: "for organization administrators to manage their organization in a self-service way", + value: "business" + } + ]); + const name = args.name ?? await prompt.ask("Portal name", "My Portal"); + const slug = args.slug ?? await prompt.ask("URL slug", toPortalSlug(name)); + return { template, name, slug, userQuery: args.user, organization: args.organization }; +} +function unansweredPrompter() { + const refuse = () => { + throw new UsageError("A question was needed but this run cannot prompt. Pass the flag instead."); + }; + return { + ask: async () => refuse(), + confirm: async () => refuse(), + choose: async () => refuse(), + checklist: async () => refuse() + }; +} + +// src/ui.ts +var RESET = "\x1B[0m"; +var codes = { + bold: "\x1B[1m", + dim: "\x1B[2m", + green: "\x1B[32m", + yellow: "\x1B[33m", + red: "\x1B[91m", + blue: "\x1B[34m", + cyan: "\x1B[36m", + magenta: "\x1B[35m" +}; +function colorEnabled(env, isTTY, noColorFlag = false) { + if (env.NO_COLOR !== void 0 || noColorFlag) return false; + return isTTY; +} +var Ui = class { + constructor(output, color, silent, animate = false) { + this.output = output; + this.color = color; + this.silent = silent; + this.animate = animate; + } + output; + color; + silent; + animate; + // silent suppresses all human output (agent and --json modes). animate enables + // the spinner, which needs a terminal. + spinnerGap = false; + paint(style, text2) { + if (!this.color) return text2; + return `${codes[style]}${text2}${RESET}`; + } + // The title box: the command, and the tenant every change will go to. It prints + // before any question, so a wrong tenant is visible from the start. + banner(title, tenant) { + if (this.silent) return; + box(this.paint("cyan", tenant), this.paint("bold", title), { + output: this.output, + width: "auto", + rounded: true, + contentAlign: "center", + titleAlign: "center" + }); + } + // A section header starts a block of items, closed by its result. + section(title) { + if (this.silent) return; + this.breakLine(); + log.step(title, { output: this.output, spacing: 0 }); + } + // One item of a section: a plain line. Only the section result carries a symbol. + success(text2, detail) { + if (this.silent) return; + const suffix = detail ? ` ${this.paint("dim", detail)}` : ""; + this.breakLine(); + log.message(`${text2}${suffix}`, { output: this.output, spacing: 0 }); + } + // A change the CLI made, with a green check so it reads as done. + fixed(text2, detail) { + if (this.silent) return; + const suffix = detail ? ` ${this.paint("dim", detail)}` : ""; + this.breakLine(); + log.message(`${this.paint("green", "\u2713")} ${text2}${suffix}`, { output: this.output, spacing: 0 }); + } + // The outcome of a section, drawn like the other results. + result(text2) { + if (this.silent) return; + this.breakLine(); + log.success(text2, { output: this.output, spacing: 0 }); + } + warn(text2) { + if (this.silent) return; + log.warn(text2, { output: this.output, spacing: 0 }); + } + error(text2) { + if (this.silent) return; + log.error(text2, { output: this.output, spacing: 0 }); + } + info(text2) { + if (this.silent) return; + log.info(text2, { output: this.output, spacing: 0 }); + } + detail(text2) { + if (this.silent) return; + log.message(this.paint("dim", text2), { output: this.output, spacing: 0 }); + } + // Shows an ID that links to its dashboard page. Terminals with OSC 8 support + // make the ID clickable. Without color (no terminal, NO_COLOR) it stays plain + // text, as the core CLI does. + reference(label, id, url) { + if (this.silent) return; + const linked = this.color ? `\x1B]8;;${url}\x1B\\${this.paint("cyan", id)}\x1B]8;;\x1B\\` : id; + log.message(`${this.paint("dim", `${label}:`)} ${linked}`, { output: this.output, spacing: 0 }); + } + // The closing block of a successful setup: the result, then the portal URL in a note. + ready(url) { + if (this.silent) return; + this.breakLine(); + const linked = this.color ? `\x1B]8;;${url}\x1B\\${this.paint("cyan", url)}\x1B]8;;\x1B\\` : url; + note(`${this.paint("dim", "Open portal:")} ${linked}`, "Portal URL", { output: this.output }); + } + // The closing line when the user cancels. Clack draws it in the frame. + cancelled(message) { + if (this.silent) return; + cancel(message, { output: this.output }); + } + // A table with a header row. Widths follow the longest cell, and the first + // column is the status symbol, colored by its text. + table(headers, rows) { + if (this.silent) return; + const widths = headers.map( + (header, column) => Math.max(header.length, ...rows.map((row) => row[column].length)) + ); + const format = (cells, style) => cells.map((cell, column) => { + const padded = cell.padEnd(widths[column]); + return style ? style(padded, column) : padded; + }).join(" "); + log.message(this.paint("dim", format(headers)), { output: this.output, spacing: 0 }); + for (const row of rows) { + const status = row[0] === "\u2716" ? "red" : "yellow"; + log.message(format(row, (cell, column) => column === 0 ? this.paint(status, cell) : cell), { output: this.output, spacing: 0 }); + } + } + newline() { + if (this.silent) return; + log.message("", { output: this.output, spacing: 0 }); + } + // Runs work behind a spinner. The spinner line is cleared when the work ends, + // so the caller prints the result in its place. Without a terminal there is no + // spinner, only the work itself. + async task(label, work) { + if (this.silent || !this.animate) return work(); + const running = spinner({ output: this.output }); + running.start(label); + try { + return await work(); + } finally { + running.clear(); + this.spinnerGap = true; + } + } + // A blank line before a header, item or result. Right after a spinner the gap is + // already there, so no second one is drawn. + breakLine() { + if (this.spinnerGap) { + this.spinnerGap = false; + return; + } + log.message("", { output: this.output, spacing: 0 }); + } +}; + +// src/cli.ts +async function main(argv, env, io) { + let args; + try { + args = parseSetupArgs(argv); + } catch (error) { + io.stderr.write(`${describeError(error).message} +`); + return error instanceof UsageError ? 2 : 1; + } + if (args.command === "help") { + io.stdout.write(`${usage()} +`); + return 0; + } + const agentMode = args.json || env.AUTH0_AGENT_MODE === "1" || env.AUTH0_AGENT_MODE === "true"; + const interactive = !args.noInput && !agentMode && io.isTTY; + const ui = new Ui( + io.stderr, + colorEnabled(env, io.isTTY, args.noColor) && !agentMode, + agentMode, + io.isTTY && !agentMode + ); + const prompter = interactive ? io.prompter ?? clackPrompter(io.stdinStream, io.stderr) : void 0; + if (args.command === "audit") return runAuditCommand(args, env, io, ui, prompter); + return runSetupCommand(args, env, io, ui, interactive, prompter); +} +var entryPoint = process.argv[1] ? realpathSync(process.argv[1]) : void 0; +if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { + main(process.argv.slice(2), process.env, { + stdout: process.stdout, + stderr: process.stderr, + stdinStream: process.stdin, + isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY) + }).then((code) => { + process.exitCode = code; + }); +} +export { + main +}; From 8265492b492150765e945305cef6b261148a1cab Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 17:03:44 +0100 Subject: [PATCH 07/11] fix(portals): read an application's connections by checkpoint The audit failed against a real tenant with "Additional properties not allowed: per_page,page". The endpoint that lists an application's connections pages by checkpoint (a count, a token to start from, and a `next` token in the response) and rejects page numbers, but every list was read with page numbers. That endpoint now has its own helper that follows the `next` token. The test double was too forgiving: it accepted page numbers on this endpoint, so no test noticed. It now behaves like the real endpoint, and a test reads an application with more connections than one page. The bundle is rebuilt in the same commit, because a test fails when the bundle is older than its sources. Co-Authored-By: Claude --- .../data/universal-portals/_source/src/api.ts | 21 +++++++++-- .../_source/src/audit/snapshot.ts | 5 +-- .../_source/test/api.test.ts | 36 ++++++++++++++++++- .../_source/test/fake-tenant.ts | 15 +++++++- .../_source/test/parallel.test.ts | 23 ++++++++++++ .../universal-portals/universal-portals.mjs | 18 ++++++++-- 6 files changed, 110 insertions(+), 8 deletions(-) diff --git a/internal/cli/data/universal-portals/_source/src/api.ts b/internal/cli/data/universal-portals/_source/src/api.ts index e0a66f24e..4aaedd657 100644 --- a/internal/cli/data/universal-portals/_source/src/api.ts +++ b/internal/cli/data/universal-portals/_source/src/api.ts @@ -62,8 +62,9 @@ export function listItems(body: unknown, key: string): T[] { const PAGE_SIZE = 100; const MAX_PAGES = 100; -// Reads every page of a list endpoint. A short page is the last one, so the total is -// not needed, and endpoints that reject extra query parameters are not affected. +// Reads every page of a list endpoint that takes page and per_page. A short page is the last +// one, so the total is not needed. A few endpoints page by checkpoint instead and reject these +// parameters: they use listAllByCheckpoint. export async function listAll(api: ApiClient, path: string, key: string): Promise { const separator = path.includes("?") ? "&" : "?"; const items: T[] = []; @@ -75,6 +76,22 @@ export async function listAll(api: ApiClient, path: string, key: string): Pro throw new Error(`${path} returned more than ${MAX_PAGES * PAGE_SIZE} items. The listing did not stop paging.`); } +// Reads every page of an endpoint that pages by checkpoint: the request takes a count and the +// token of the page to start from, and the response carries `next` while there is more. +export async function listAllByCheckpoint(api: ApiClient, path: string, key: string): Promise { + const separator = path.includes("?") ? "&" : "?"; + const items: T[] = []; + let from: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const query = `take=${PAGE_SIZE}${from ? `&from=${encodeURIComponent(from)}` : ""}`; + const body = await api.request("GET", `${path}${separator}${query}`); + items.push(...listItems(body, key)); + from = isRecord(body) && typeof body.next === "string" && body.next !== "" ? body.next : undefined; + if (!from) return items; + } + throw new Error(`${path} returned more than ${MAX_PAGES * PAGE_SIZE} items. The listing did not stop paging.`); +} + function safeJson(text: string): unknown { try { return JSON.parse(text); diff --git a/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts b/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts index 10719ede0..6a381630c 100644 --- a/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts +++ b/internal/cli/data/universal-portals/_source/src/audit/snapshot.ts @@ -1,4 +1,4 @@ -import { ApiClient, ApiError, listAll } from "../api.js"; +import { ApiClient, ApiError, listAll, listAllByCheckpoint } from "../api.js"; // Everything the audit needs about one portal, read from the tenant. The checks // work on this snapshot only, so they can be tested without a tenant. @@ -110,7 +110,8 @@ async function readApplicationParts( const [grants, applicationConnections] = await Promise.all([ listAll(api, `client-grants?client_id=${encodeURIComponent(clientId)}`, "client_grants"), - listAll<{ id: string; name?: string }>(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections"), + // This endpoint pages by checkpoint and rejects page and per_page. + listAllByCheckpoint<{ id: string; name?: string }>(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections"), ]); return { application, grants, applicationConnections }; } diff --git a/internal/cli/data/universal-portals/_source/test/api.test.ts b/internal/cli/data/universal-portals/_source/test/api.test.ts index 2184f768f..b9afdb14d 100644 --- a/internal/cli/data/universal-portals/_source/test/api.test.ts +++ b/internal/cli/data/universal-portals/_source/test/api.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; -import { ApiClient, ApiError, createApiClient, listAll, listItems, type FetchLike } from "../src/api.js"; +import { ApiClient, ApiError, createApiClient, listAll, listAllByCheckpoint, listItems, type FetchLike } from "../src/api.js"; function fakeFetch(status: number, body: unknown): { fetch: FetchLike; calls: { url: string; init: RequestInit }[] } { const calls: { url: string; init: RequestInit }[] = []; @@ -76,3 +76,37 @@ test("listItems reads both array responses and keyed responses", () => { assert.deepEqual(listItems({ portals: [{ id: 2 }] }, "portals"), [{ id: 2 }]); assert.deepEqual(listItems({ other: [] }, "portals"), []); }); + +test("listAllByCheckpoint follows the next token until there is none, and never sends page numbers", async () => { + const everything = Array.from({ length: 250 }, (_, index) => ({ id: `item_${index}` })); + const paths: string[] = []; + const api = { + async request(_method: string, path: string) { + paths.push(path); + const params = new URL(`https://x/${path}`).searchParams; + const start = params.has("from") ? everything.findIndex((item) => item.id === params.get("from")) : 0; + const take = Number(params.get("take")); + const next = everything[start + take]; + return { things: everything.slice(start, start + take), ...(next ? { next: next.id } : {}) }; + }, + } as ApiClient; + + const items = await listAllByCheckpoint<{ id: string }>(api, "things?strategy=auth0", "things"); + + assert.equal(items.length, 250); + assert.deepEqual(paths, [ + "things?strategy=auth0&take=100", + "things?strategy=auth0&take=100&from=item_100", + "things?strategy=auth0&take=100&from=item_200", + ]); +}); + +test("listAllByCheckpoint stops with an error when the token never runs out", async () => { + const api = { + async request() { + return { things: [{ id: "a" }], next: "again" }; + }, + } as ApiClient; + + await assert.rejects(listAllByCheckpoint(api, "things", "things"), /did not stop paging/); +}); diff --git a/internal/cli/data/universal-portals/_source/test/fake-tenant.ts b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts index 850f3c4ce..5e81d5432 100644 --- a/internal/cli/data/universal-portals/_source/test/fake-tenant.ts +++ b/internal/cli/data/universal-portals/_source/test/fake-tenant.ts @@ -240,8 +240,21 @@ export class FakeTenant implements ApiClient { this.organizations.set(org.id, org); return org; } + // Like the real endpoint: pages are taken by checkpoint (take, from, and a next token), and + // page and per_page are rejected. if (method === "GET" && parts[0] === "clients" && parts[2] === "connections") { - return { connections: [...this.connections.values()] }; + const params = new URLSearchParams(query); + if (params.has("page") || params.has("per_page")) { + throw toApiError(400, { + message: "Query validation error: 'Additional properties not allowed: per_page,page'.", + errorCode: "invalid_query_string", + }); + } + const all = [...this.connections.values()]; + const take = Number(params.get("take") ?? 50); + const start = params.has("from") ? all.findIndex((connection) => connection.id === params.get("from")) : 0; + const next = all[start + take]; + return { connections: all.slice(start, start + take), ...(next ? { next: next.id } : {}) }; } if (method === "GET" && path === "connections") { return [...this.connections.values()]; diff --git a/internal/cli/data/universal-portals/_source/test/parallel.test.ts b/internal/cli/data/universal-portals/_source/test/parallel.test.ts index 4cb10e92f..32aa25251 100644 --- a/internal/cli/data/universal-portals/_source/test/parallel.test.ts +++ b/internal/cli/data/universal-portals/_source/test/parallel.test.ts @@ -71,3 +71,26 @@ test("the audit reads the independent parts of a portal together and gets the sa assert.deepEqual(together, sequential); assert.ok(network.peak() > 1, "the reads were sent one after another"); }); + +test("the audit reads every connection of an application, not only the first page", async () => { + const tenant = new FakeTenant(); + tenant.connections.set("con_db", { id: "con_db", name: "Username-Password-Authentication", strategy: "auth0" }); + const quiet = new Ui(captureOutput().stream, false, true); + const setup = await runSetup( + { template: "consumer", name: "Acme Account", slug: "acme-account" }, + { + api: tenant, + ui: quiet, + prompt: { ask: async (_q, d) => d ?? "", confirm: async (_q, d) => d, choose: async (_q, o) => o[0].value, checklist: async () => [] }, + interactive: false, + tenantDomain: DOMAIN, + }, + ); + for (let index = 0; index < 129; index += 1) { + tenant.connections.set(`con_${index}`, { id: `con_${index}`, name: `Connection ${index}`, strategy: "auth0" }); + } + + const snapshot = await fetchPortalSnapshot(tenant, setup.portal.id, DOMAIN); + + assert.equal(snapshot.applicationConnections.length, 130); +}); diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index e2be272af..e883697e3 100755 --- a/internal/cli/data/universal-portals/universal-portals.mjs +++ b/internal/cli/data/universal-portals/universal-portals.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generated by "npm run bundle" in the _source folder. Do not edit. -// source-sha256: 57aff7535a99816d4543800ab198dbd3864558993de3aa4cec3dca202532cd90 +// source-sha256: c32b6b8b568849efb61ffaa860745c5e5fc36575fa704164ce66d29e80675267 import { createRequire } from "node:module"; const require = createRequire(import.meta.url); var __create = Object.create; @@ -1768,6 +1768,19 @@ async function listAll(api, path, key) { } throw new Error(`${path} returned more than ${MAX_PAGES * PAGE_SIZE} items. The listing did not stop paging.`); } +async function listAllByCheckpoint(api, path, key) { + const separator = path.includes("?") ? "&" : "?"; + const items = []; + let from; + for (let page = 0; page < MAX_PAGES; page += 1) { + const query = `take=${PAGE_SIZE}${from ? `&from=${encodeURIComponent(from)}` : ""}`; + const body = await api.request("GET", `${path}${separator}${query}`); + items.push(...listItems(body, key)); + from = isRecord(body) && typeof body.next === "string" && body.next !== "" ? body.next : void 0; + if (!from) return items; + } + throw new Error(`${path} returned more than ${MAX_PAGES * PAGE_SIZE} items. The listing did not stop paging.`); +} function safeJson(text2) { try { return JSON.parse(text2); @@ -2510,7 +2523,8 @@ async function readApplicationParts(api, clientId) { if (!application || !clientId) return { application, grants: [], applicationConnections: [] }; const [grants, applicationConnections] = await Promise.all([ listAll(api, `client-grants?client_id=${encodeURIComponent(clientId)}`, "client_grants"), - listAll(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections") + // This endpoint pages by checkpoint and rejects page and per_page. + listAllByCheckpoint(api, `clients/${encodeURIComponent(clientId)}/connections`, "connections") ]); return { application, grants, applicationConnections }; } From 52e69f43a0088c5b8626740c9339d8ca2407aa75 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 17:38:05 +0100 Subject: [PATCH 08/11] test(portals): check a request without a body closes stdin `auth0 api` reads stdin until it ends for every method except GET, even when there is no body, and waits forever if it never ends. The transport closes stdin for every request, but nothing tested that. A test now runs a stand-in command that reads stdin to the end and fails after a few seconds if the request does not finish. The stand-in stops by itself, so a failing run does not keep the test process alive. Co-Authored-By: Claude --- .../_source/test/cli-api.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts index 5cdfd8ad5..6457081e6 100644 --- a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts @@ -192,3 +192,25 @@ test("the real runner passes the body on stdin to the process and reads its stdo assert.deepEqual(JSON.parse(echoed.input), { name: "x" }); assert.deepEqual(echoed.args.slice(0, 3), ["api", "POST", "clients"]); }); + +// `auth0 api` reads stdin until it ends for every method except GET, even when there is no body. A +// request without a body must still close it, or the command waits forever. +test("a request without a body closes stdin, so a command that reads it to the end does not hang", async () => { + const dir = mkdtempSync(join(tmpdir(), "up-fake-auth0-")); + const script = join(dir, "auth0"); + writeFileSync( + script, + '#!/usr/bin/env node\nprocess.stdin.resume();\nprocess.stdin.on("end", () => console.log("{}"));\n' + + // If stdin is never closed, stop on our own, so a failing test does not keep the run alive. + "setTimeout(() => process.exit(2), 8000).unref();\n", + ); + chmodSync(script, 0o755); + const api = createCliApiClient({ bin: script }); + + const outcome = await Promise.race([ + api.request("DELETE", "client-grants/cgr_1").then(() => "finished"), + new Promise((resolve) => setTimeout(() => resolve("hung"), 5000)), + ]); + + assert.equal(outcome, "finished"); +}); From 28cb04c5528031ad1f0649a493a93d37f8719e01 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 18:49:54 +0100 Subject: [PATCH 09/11] fix(portals): read an API error before a scope message The scope message was matched on all of the CLI's output first, so an API error whose message quoted "lacks scope" was reported as a missing permission. The API error now comes first, its body is read to the end of the output (the CLI prints long bodies over several lines), and the status must not be part of a longer number. Co-Authored-By: Claude --- .../universal-portals/_source/src/cli-api.ts | 8 ++++-- .../_source/test/cli-api.test.ts | 28 +++++++++++++++++++ .../universal-portals/universal-portals.mjs | 6 ++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/internal/cli/data/universal-portals/_source/src/cli-api.ts b/internal/cli/data/universal-portals/_source/src/cli-api.ts index e0b10fa96..f7b9d48d6 100644 --- a/internal/cli/data/universal-portals/_source/src/cli-api.ts +++ b/internal/cli/data/universal-portals/_source/src/cli-api.ts @@ -122,6 +122,11 @@ function parseOutput(stdout: string, what: string): T { // What went wrong, from the CLI's message. `what` names the command for the messages that // have no better explanation. function explainFailure(stderr: string, what: string, tenant: string | undefined): Error { + // An API error comes first: its body can quote any text, including a scope message. The body runs + // to the end of the output, because the CLI prints it over several lines when it is long. + const api = /(? { assert.deepEqual([conflict.status, conflict.errorCode], [409, "portal_conflict"]); }); +test("an API error whose message quotes a missing scope stays an API error", async () => { + const error = await failure( + "\n=== acme.us.auth0.com error\n\n ▸ 400: API request failed: " + + '{"statusCode":400,"error":"Bad Request","message":"Invalid description: access token lacks scope: read:portals","errorCode":"invalid_body"}.\n\n', + ); + + assert.ok(error instanceof ApiError); + assert.deepEqual([error.status, error.errorCode], [400, "invalid_body"]); +}); + +test("an API error body printed over several lines keeps its status, code and message", async () => { + const body = JSON.stringify( + { statusCode: 404, error: "Not Found", message: "The portal does not exist", errorCode: "inexistent_portal" }, + null, + 2, + ); + const error = await failure(`\n=== acme.us.auth0.com error\n\n ▸ 404: API request failed: ${body}.\n\n`); + + assert.ok(error instanceof ApiError); + assert.deepEqual([error.status, error.errorCode, error.message], [404, "inexistent_portal", "The portal does not exist"]); +}); + +test("a number that only ends in three digits is not read as a status", async () => { + const error = await failure("\n=== acme.us.auth0.com error\n\n ▸ 1404: API request failed: {}.\n\n"); + + assert.ok(!(error instanceof ApiError), "1404 is not an HTTP status"); +}); + test("a missing scope becomes the 403 the preflight already understands", async () => { const error = await failure(STDERR.missingScope); diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index e883697e3..15d0b215a 100755 --- a/internal/cli/data/universal-portals/universal-portals.mjs +++ b/internal/cli/data/universal-portals/universal-portals.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generated by "npm run bundle" in the _source folder. Do not edit. -// source-sha256: c32b6b8b568849efb61ffaa860745c5e5fc36575fa704164ce66d29e80675267 +// source-sha256: f737d773956fb44d0d9ed0ec002acbfe14537ccb5b62d5e4c03ee5c5dfe02def import { createRequire } from "node:module"; const require = createRequire(import.meta.url); var __create = Object.create; @@ -1866,6 +1866,8 @@ function parseOutput(stdout2, what) { } } function explainFailure(stderr, what, tenant) { + const api = /(? Date: Mon, 26 Oct 2026 18:51:43 +0100 Subject: [PATCH 10/11] test(portals): tighten assertions that could not fail The login test checked for text the message always contains, the half-set proxy test looked for the token name with a trailing comma, and no test proved that the source files are hashed in sorted order. They now check the CLI's own reason, the bare variable name, and a tree whose sorted order differs from the order a directory walk finds it. Co-Authored-By: Claude --- .../universal-portals/_source/test/cli-api.test.ts | 8 ++++++-- .../universal-portals/_source/test/context.test.ts | 2 +- .../data/universal-portals/_source/test/scopes.test.ts | 2 +- .../universal-portals/_source/test/source-hash.test.ts | 10 ++++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts index 9f8c9f3df..b2d06eb1f 100644 --- a/internal/cli/data/universal-portals/_source/test/cli-api.test.ts +++ b/internal/cli/data/universal-portals/_source/test/cli-api.test.ts @@ -156,12 +156,16 @@ test("a missing scope becomes the 403 the preflight already understands", async }); test("an expired session or an unknown tenant asks the user to log in", async () => { - for (const stderr of [STDERR.expiredSession, STDERR.unknownTenant]) { + const cases: [string, RegExp][] = [ + [STDERR.expiredSession, /Auth token expired/], + [STDERR.unknownTenant, /Failed to find tenant: nope\.us\.auth0\.com/], + ]; + for (const [stderr, reason] of cases) { const error = await failure(stderr); assert.ok(error instanceof PluginError); assert.equal(error.code, "auth0_login_required"); assert.deepEqual(error.fixes, ["auth0 login"]); - assert.match(error.message, /auth0 login/); + assert.match(error.message, reason, "the CLI's own reason is kept"); } }); diff --git a/internal/cli/data/universal-portals/_source/test/context.test.ts b/internal/cli/data/universal-portals/_source/test/context.test.ts index 985cdb6ca..527c39062 100644 --- a/internal/cli/data/universal-portals/_source/test/context.test.ts +++ b/internal/cli/data/universal-portals/_source/test/context.test.ts @@ -43,7 +43,7 @@ test("a half-set proxy environment names every variable that is missing", async assert.ok(error instanceof Error); assert.match(error.message, /AUTH0_CLI_PROXY_URL/); assert.match(error.message, /AUTH0_CLI_TENANT_DOMAIN/); - assert.doesNotMatch(error.message, /AUTH0_CLI_PROXY_TOKEN,/); + assert.doesNotMatch(error.message, /AUTH0_CLI_PROXY_TOKEN/, "the variable that is set is not listed as missing"); assert.match(error.message, /Auth0 CLI/); return true; }); diff --git a/internal/cli/data/universal-portals/_source/test/scopes.test.ts b/internal/cli/data/universal-portals/_source/test/scopes.test.ts index c22572546..687204995 100644 --- a/internal/cli/data/universal-portals/_source/test/scopes.test.ts +++ b/internal/cli/data/universal-portals/_source/test/scopes.test.ts @@ -15,7 +15,7 @@ import { captureOutput } from "./capture-output.js"; const DOMAIN = "acme.us.auth0.com"; const quiet = () => new Ui(captureOutput().stream, false, true); -// Mirrors the CLI proxy's rule (internal/plugins/proxy.go, requiredScopeFor): the verb +// Mirrors the rule the CLI proxy of the plugin framework uses (requiredScopeFor): the verb // comes from the HTTP method and the resource is the first path segment. const VERBS: Record = { GET: "read", POST: "create", PATCH: "update", PUT: "update", DELETE: "delete" }; diff --git a/internal/cli/data/universal-portals/_source/test/source-hash.test.ts b/internal/cli/data/universal-portals/_source/test/source-hash.test.ts index 5f8073fd2..eb1162314 100644 --- a/internal/cli/data/universal-portals/_source/test/source-hash.test.ts +++ b/internal/cli/data/universal-portals/_source/test/source-hash.test.ts @@ -28,6 +28,16 @@ test("the hash covers the lockfile, the bundle script and every file under src, assert.equal(sourceHash(tree(FILES)), expected); }); +// "src/a-b/x" sorts before "src/a/x" because "-" comes before "/", while a directory walk finds "a" first. +test("the files are sorted by path, not in the order the folders are walked", () => { + const files = { "package-lock.json": "{}", "scripts/bundle.mjs": "x", "src/a/x": "1", "src/a-b/x": "2" }; + const expected = createHash("sha256") + .update(["package-lock.json", "{}", "scripts/bundle.mjs", "x", "src/a-b/x", "2", "src/a/x", "1", ""].join("\0")) + .digest("hex"); + + assert.equal(sourceHash(tree(files)), expected); +}); + test("a change under src changes the hash, a change outside it does not", () => { const before = sourceHash(tree(FILES)); From 60e1fae154828e7f2f67cfa256d1f28894b24df0 Mon Sep 17 00:00:00 2001 From: Jacobo Vidal Date: Mon, 26 Oct 2026 18:51:43 +0100 Subject: [PATCH 11/11] docs(portals): describe both ways of calling the API The header of api.ts still said every request goes through the loopback proxy, which is no longer true now that the auth0 api command is used as well. The bundle is rebuilt because it carries the comment. Co-Authored-By: Claude --- internal/cli/data/universal-portals/_source/src/api.ts | 4 ++-- internal/cli/data/universal-portals/universal-portals.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cli/data/universal-portals/_source/src/api.ts b/internal/cli/data/universal-portals/_source/src/api.ts index 4aaedd657..58e7f4de9 100644 --- a/internal/cli/data/universal-portals/_source/src/api.ts +++ b/internal/cli/data/universal-portals/_source/src/api.ts @@ -1,5 +1,5 @@ -// Management API client. Requests go through the CLI's loopback proxy, which -// swaps the proxy token for the real access token, so this module never holds it. +// Management API client interface. An implementation sends the requests through the CLI, either its +// loopback proxy or the `auth0 api` command (see cli-api.ts), so this module never holds a token. export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE"; diff --git a/internal/cli/data/universal-portals/universal-portals.mjs b/internal/cli/data/universal-portals/universal-portals.mjs index 15d0b215a..ae35bd3e5 100755 --- a/internal/cli/data/universal-portals/universal-portals.mjs +++ b/internal/cli/data/universal-portals/universal-portals.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generated by "npm run bundle" in the _source folder. Do not edit. -// source-sha256: f737d773956fb44d0d9ed0ec002acbfe14537ccb5b62d5e4c03ee5c5dfe02def +// source-sha256: 229a16a14dba7461826d1b828358f469e9f41e78b501a4dc5ffabb54f89513f9 import { createRequire } from "node:module"; const require = createRequire(import.meta.url); var __create = Object.create;