From ff0d32bec2c76c5695d4b15013235473ddd92d48 Mon Sep 17 00:00:00 2001 From: Sudhanshu Vohra Date: Wed, 8 Jul 2026 14:06:08 +0530 Subject: [PATCH] Updated bootstrap script to auto-setup the tenant --- package-lock.json | 6 + scripts/.gitignore | 3 + scripts/.npmrc | 0 scripts/README.md | 92 ++++ scripts/bootstrap.mjs | 226 +++++++-- scripts/package-lock.json | 428 ++++++++-------- scripts/package.json | 2 +- scripts/utils/auth0-api.mjs | 117 ++++- scripts/utils/clients.mjs | 163 +++++- scripts/utils/connections.mjs | 149 +++++- scripts/utils/discovery.mjs | 35 +- scripts/utils/guardian-factors.mjs | 113 +++++ scripts/utils/helpers.mjs | 9 + scripts/utils/info-plist-writer.mjs | 169 +++++++ scripts/utils/manual-actions.mjs | 60 +++ scripts/utils/roles.mjs | 137 +---- scripts/utils/tenant-config.mjs | 37 ++ scripts/utils/validation.mjs | 740 +++++++++++++++++++++++++++- 18 files changed, 2021 insertions(+), 465 deletions(-) create mode 100644 package-lock.json create mode 100644 scripts/.gitignore create mode 100644 scripts/.npmrc create mode 100644 scripts/README.md create mode 100644 scripts/utils/guardian-factors.mjs create mode 100644 scripts/utils/info-plist-writer.mjs create mode 100644 scripts/utils/manual-actions.mjs diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..584a1685 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "ui-components-ios", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 00000000..f7c4136a --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +.env.local diff --git a/scripts/.npmrc b/scripts/.npmrc new file mode 100644 index 00000000..e69de29b diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..a78dce5f --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,92 @@ +# Auth0 Tenant Bootstrap Script (iOS) + +An interactive CLI that configures your Auth0 tenant with everything the +**AppUIComponents** sample app needs, then wires the app up locally by writing +`Auth0.plist` and registering the OAuth callback URL scheme. The script +discovers existing resources, builds a change plan, and only creates what's +missing — it never modifies configuration that is already correct. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Usage](#usage) +- [What It Configures](#what-it-configures) +- [Auth0 CLI Scopes](#auth0-cli-scopes) +- [Manual Configuration](#manual-configuration) + +## Prerequisites + +1. **Node.js 20 or later** — [nodejs.org](https://nodejs.org/) +2. **Auth0 CLI** — [github.com/auth0/auth0-cli](https://github.com/auth0/auth0-cli) +3. **An Auth0 tenant** — sign up at [auth0.com/signup](https://auth0.com/signup) + if you don't have one. You can use an existing tenant; the script only adds + what's missing. + +> ⚠️ **Note:** You do not need to log in to the Auth0 CLI beforehand. The script +> checks your CLI session and, if it is missing or expired, offers to log you in +> (requesting the [required scopes](#auth0-cli-scopes)) and switch to the +> requested tenant automatically. + +## Usage + +Run from the `scripts/` directory: + +```bash +cd scripts +npm install +npm run auth0:bootstrap +``` + +The tenant domain argument is required (e.g. `my-tenant.us.auth0.com`) as a +safety measure to prevent accidentally configuring the wrong tenant. + +The script guides you through: + +1. **Pre-flight checks** — Node version, Auth0 CLI install, and CLI session. + If your session is expired it offers to log you in. +2. **Tenant validation** — confirms the provided domain matches your active CLI + tenant. On a mismatch it offers to `auth0 tenants use ` (switch) or + log in to it, then continues. +3. **Resource discovery** — scans the tenant and warns (softly) if the My + Account API is missing MFA scopes. +4. **Change plan review** — displays what will be created, updated, or skipped. +5. **Confirmation** — prompts for approval before applying any changes. +6. **Apply changes** — creates and configures the required Auth0 resources. +7. **Local wiring** — writes `AppUIComponents/Auth0.plist` and adds the `demo` + callback URL scheme to `AppUIComponents/Info.plist` (idempotent). + +## What It Configures + +| Resource | Details | +| -------------------------- | ----------------------------------------------------------------------------------------------- | +| **Native Application** | `iOS UI Components Demo` (`app_type: native`) with the `demo://…/callback` callback + refresh-token rotation, and a My Account API refresh-token policy | +| **My Account API** | Resource server at `https://{domain}/me/` (MFA / authentication-methods) | +| **Client Grant** | Native app authorized for the available My Account API scopes | +| **Database Connection** | `Username-Password-Authentication` enabled for the application | +| **Connection Profile** | `Universal Components Connection Profile` | +| **User Attribute Profile** | `Universal Components Profile` | +| **Admin Role** | `admin` role with the My Account API permissions | +| **Tenant Settings** | Identifier-first prompt and MFA customization in the post-login action | + +Locally, it then writes: + +- `AppUIComponents/Auth0.plist` — `Domain` and `ClientId` read by the SDK at + launch (`Auth0UniversalComponentsSDKInitializer`). +- `AppUIComponents/Info.plist` — a `CFBundleURLTypes` entry registering the + `demo` URL scheme so the login/logout callback returns to the app. + +## Auth0 CLI Scopes + +If the script triggers a login, it requests these scopes automatically. To +authenticate manually beforehand: + +```bash +auth0 login --scopes "read:connection_profiles,create:connection_profiles,update:connection_profiles,read:user_attribute_profiles,create:user_attribute_profiles,update:user_attribute_profiles,read:client_grants,create:client_grants,update:client_grants,delete:client_grants,read:connections,create:connections,update:connections,read:clients,create:clients,update:clients,read:client_keys,read:roles,create:roles,update:roles,read:resource_servers,create:resource_servers,update:resource_servers,update:tenant_settings,update:prompts" +``` + +## Manual Configuration + +If you prefer to configure the tenant by hand, follow **Option 2: Manual Setup** +in the [root README](../README.md#option-2-manual-setup), which covers creating +the native application, allowed callback URLs, `AppUIComponents/Auth0.plist`, +and the `Info.plist` URL scheme. diff --git a/scripts/bootstrap.mjs b/scripts/bootstrap.mjs index d9ab9d80..02b8e8ca 100644 --- a/scripts/bootstrap.mjs +++ b/scripts/bootstrap.mjs @@ -10,7 +10,9 @@ import { displayChangePlan, } from "./utils/discovery.mjs" import { writeAuth0Plist } from "./utils/plist-writer.mjs" +import { writeInfoPlistUrlScheme } from "./utils/info-plist-writer.mjs" import { confirmWithUser } from "./utils/helpers.mjs" +import { getManualActions } from "./utils/manual-actions.mjs" import { applyConnectionProfileChanges, applyUserAttributeProfileChanges, @@ -24,12 +26,18 @@ import { applyPromptSettingsChanges, applyTenantSettingsChanges, } from "./utils/tenant-config.mjs" +import { applyGuardianFactorChanges } from "./utils/guardian-factors.mjs" import { checkAuth0CLI, checkNodeVersion, + hasMachineCredentials, + printScopeUsageDetails, + validateAuth0Session, validateIOSProject, + validateMyAccountScopes, validateTenant, } from "./utils/validation.mjs" +import { ChangeAction } from "./utils/change-plan.mjs" // ============================================================================ // Main Bootstrap Flow @@ -42,61 +50,111 @@ async function main() { const args = process.argv.slice(2) if (args.includes("--help") || args.includes("-h")) { - console.log("Usage: npm run auth0:bootstrap ") + console.log("Usage: npm run auth0:bootstrap [--yes]") console.log("\nArguments:") console.log( " tenant-domain Required. The Auth0 tenant domain to configure." ) console.log(" Must match your Auth0 CLI active tenant.") + console.log("\nOptions:") + console.log( + " --yes, -y Skip the confirmation prompt and apply changes." + ) + console.log( + " Auto-enabled when M2M credentials are set and stdin is" + ) + console.log(" not a TTY (headless/CI). Env: AUTH0_BOOTSTRAP_YES=1") console.log("\nExample:") console.log(" npm run auth0:bootstrap my-tenant.us.auth0.com") console.log("\nPrerequisites:") console.log(" 1. Install Auth0 CLI: https://github.com/auth0/auth0-cli") - console.log(" 2. Login to Auth0 CLI: auth0 login") - console.log(" 3. Select your tenant: auth0 tenants use ") + console.log( + "\nNote: The script checks your Auth0 CLI session and, if needed, logs you" + ) + console.log( + " in and switches to the requested tenant automatically." + ) + console.log("\nNon-interactive (standalone / CI) login:") + console.log( + " Set these environment variables for a Management-API M2M app and the" + ) + console.log( + " script authenticates via client credentials — no browser required:" + ) + console.log(" AUTH0_CLIENT_ID Client ID of the M2M application") + console.log(" AUTH0_CLIENT_SECRET Client secret of the M2M application") + console.log( + " AUTH0_DOMAIN Tenant domain (optional; defaults to the arg)" + ) console.log( "\nNote: Tenant name is required as a safety measure to prevent accidentally" ) console.log(" configuring the wrong tenant.") + // Expand the full per-scope rationale for users who want to know why each + // Management API permission is requested at login. + printScopeUsageDetails() process.exit(0) } - const tenantName = args[0] + // Flags: --yes/-y (or AUTH0_BOOTSTRAP_YES) skip the confirm prompt. The first + // non-flag argument is the tenant domain. + const flags = args.filter((a) => a.startsWith("-")) + const tenantName = args.find((a) => !a.startsWith("-")) + const yesFlag = + flags.includes("--yes") || + flags.includes("-y") || + process.env.AUTH0_BOOTSTRAP_YES === "1" + + // Consistent step numbering across the run. TOTAL is the count of top-level + // steps below; bump it if you add/remove one. + const TOTAL_STEPS = 6 + let stepNo = 0 + const step = (emoji, title) => + console.log(`\n${emoji} Step ${++stepNo}/${TOTAL_STEPS}: ${title}`) // Step 1: Validation - console.log("📋 Step 1: Pre-flight Checks") + step("📋", "Pre-flight Checks") checkNodeVersion() await checkAuth0CLI() + await validateAuth0Session(tenantName) const domain = await validateTenant(tenantName) const iosConfig = validateIOSProject() - const scheme = "demo" // Default scheme for iOS sample app + // Auth0.swift builds the custom-scheme callback ({bundleId}://.../callback) + // from the bundle identifier, so the Info.plist URL scheme must be the bundle + // identifier for the OAuth redirect to reach the app. + const scheme = iosConfig.bundleIdentifier iosConfig.scheme = scheme - console.log("") // Step 2: Discovery - console.log("🔍 Step 2: Resource Discovery") + step("🔍", "Resource Discovery") const resources = await discoverExistingResources(domain) - console.log("") + validateMyAccountScopes(resources, domain) // Step 3: Build Change Plan - console.log("📝 Step 3: Analyzing Changes") + step("📝", "Analyzing Changes") const plan = await buildChangePlan(resources, domain, iosConfig) console.log("") // Step 4: Display Plan displayChangePlan(plan) - // Check if there are any changes to apply - const hasChanges = - plan.clients.dashboard.action !== "skip" || - plan.clientGrants.myAccount.action !== "skip" || - plan.connection.action !== "skip" || - plan.connectionProfile.action !== "skip" || - plan.userAttributeProfile.action !== "skip" || - plan.resourceServer.action !== "skip" || - plan.roles.admin.action !== "skip" || - plan.tenantConfig.settings.action !== "skip" || - plan.tenantConfig.prompts.action !== "skip" + // Flatten the plan into a single list so change detection and the end-of-run + // summary work off the same source of truth. + const planItems = [ + plan.tenantConfig.settings, + plan.tenantConfig.prompts, + plan.connectionProfile, + plan.userAttributeProfile, + plan.resourceServer, + plan.clients.dashboard, + plan.clientGrants.myAccount, + plan.connection, + plan.roles.admin, + plan.guardianFactors, + ] + const countByAction = (action) => + planItems.filter((i) => i.action === action).length + const hasChanges = planItems.some((i) => i.action !== ChangeAction.SKIP) if (!hasChanges) { console.log( @@ -111,32 +169,46 @@ async function main() { plan.clients.dashboard.existing?.client_id, iosConfig.auth0PlistPath ) + await writeInfoPlistUrlScheme(iosConfig.infoPlistPath, scheme) console.log("\n✅ Auth0.plist updated!\n") } process.exit(0) } - // Step 5: User Confirmation - const confirmed = await confirmWithUser( - "Do you want to proceed with these changes? " - ) - if (!confirmed) { - console.log("\n❌ Bootstrap cancelled by user.\n") - process.exit(0) + // User Confirmation. Skip the prompt when --yes is set, or automatically in a + // non-interactive M2M run (no TTY) so the "standalone/CI" path doesn't hang. + const autoConfirm = + yesFlag || (hasMachineCredentials(tenantName) && !process.stdin.isTTY) + + if (autoConfirm) { + console.log( + `\n▶️ Proceeding with ${countByAction(ChangeAction.CREATE)} create, ` + + `${countByAction(ChangeAction.UPDATE)} update ` + + `(auto-confirmed${yesFlag ? " via --yes" : " — non-interactive M2M run"}).` + ) + } else { + const confirmed = await confirmWithUser( + "Do you want to proceed with these changes? " + ) + if (!confirmed) { + console.log("\n❌ Bootstrap cancelled by user.\n") + process.exit(0) + } } console.log("") - // Step 6: Apply Changes - console.log("⚙️ Step 4: Applying Changes\n") + // Step 4: Apply Changes + step("⚙️ ", "Applying Changes") + console.log("") - // 6a. Tenant Configuration + // 4a. Tenant Configuration console.log("Configuring Tenant...") await applyTenantSettingsChanges(plan.tenantConfig.settings) await applyPromptSettingsChanges(plan.tenantConfig.prompts) console.log("") - // 6b. Profiles + // 4b. Profiles console.log("Configuring Profiles...") const connectionProfile = await applyConnectionProfileChanges( plan.connectionProfile @@ -146,12 +218,12 @@ async function main() { ) console.log("") - // 6c. Resource Server (My Account API) + // 4c. Resource Server (My Account API) console.log("Configuring My Account API...") await applyMyAccountResourceServerChanges(plan.resourceServer, domain) console.log("") - // 6d. Native Client + // 4d. Native Client console.log("Configuring Native Client...") const dashboardClient = await applyDashboardClientChanges( plan.clients.dashboard, @@ -162,7 +234,7 @@ async function main() { ) console.log("") - // 6e. Client Grants + // 4e. Client Grants console.log("Configuring Client Grants...") await applyMyAccountClientGrantChanges( plan.clientGrants.myAccount, @@ -171,7 +243,7 @@ async function main() { ) console.log("") - // 6f. Database Connection + // 4f. Database Connection console.log("Configuring Database Connection...") const connection = await applyDatabaseConnectionChanges( plan.connection, @@ -179,36 +251,84 @@ async function main() { ) console.log("") - // 6g. Roles + // 4g. Roles console.log("Configuring Roles...") await applyAdminRoleChanges(plan.roles.admin) console.log("") - // Step 7: Generate Auth0.plist - console.log("📝 Step 5: Generating Auth0.plist\n") + // 4h. MFA Factors (WebAuthn / Passkey) + console.log("Configuring MFA Factors...") + await applyGuardianFactorChanges(plan.guardianFactors) + console.log("") + + // Step 5: Generate Auth0.plist + step("📝", "Generating Auth0.plist") + console.log("") await writeAuth0Plist( domain, dashboardClient.client_id, iosConfig.auth0PlistPath ) + // Step 6: Configure the callback URL scheme in the sample app's Info.plist + step("📝", "Configuring URL scheme") + console.log("") + await writeInfoPlistUrlScheme(iosConfig.infoPlistPath, scheme) + // Done! console.log("\n✅ Bootstrap complete!\n") + + // Summary of what the plan intended, plus anything that needs manual follow-up. + const manualCount = getManualActions().length + console.log( + `Summary: ${countByAction(ChangeAction.CREATE)} created, ` + + `${countByAction(ChangeAction.UPDATE)} updated, ` + + `${countByAction(ChangeAction.SKIP)} already up to date` + + (manualCount > 0 ? `, ${manualCount} need manual attention` : "") + + ".\n" + ) + + reportManualActions() + console.log("Next steps:") - console.log(" 1. Review the updated Auth0.plist file") - console.log(" 2. Add Auth0.plist to your Xcode project if not already added") - console.log(" 3. Add the callback URL scheme to Info.plist:") - console.log(` CFBundleURLTypes`) - console.log(` `) - console.log(` `) - console.log(` CFBundleURLSchemes`) - console.log(` ${scheme}`) - console.log(` CFBundleTypeRole`) - console.log(` None`) - console.log(` `) - console.log(` `) - console.log(" 4. Build and run the sample app in Xcode") - console.log(" 5. Login and explore the UI components\n") + console.log(" 1. Open Auth0UniversalComponents.xcodeproj in Xcode") + console.log(" 2. Select the AppUIComponents target") + console.log( + " 3. Build and run the sample app (Auth0.plist and the callback URL" + ) + console.log(" scheme have already been configured for you)") + console.log(" 4. Login and explore the UI components\n") +} + +/** + * Print a consolidated list of operations that were skipped because the + * authenticated identity lacked the required Management API scope. Each entry + * names the scope to grant (or the dashboard step to perform) so the tenant + * can be finished without re-running everything blindly. + */ +function reportManualActions() { + const actions = getManualActions() + if (actions.length === 0) return + + console.log( + "⚠️ Some steps need manual attention (the login identity lacked the scope):\n" + ) + actions.forEach((a, i) => { + console.log(` ${i + 1}. ${a.resource}`) + if (a.scope) console.log(` Missing scope: ${a.scope}`) + if (a.reason) console.log(` Why it matters: ${a.reason}`) + if (a.manualStep) console.log(` Fix: ${a.manualStep}`) + console.log("") + }) + console.log( + " Tip: grant the scope(s) above to your M2M app (Dashboard → Applications →" + ) + console.log( + " APIs → Auth0 Management API → Machine to Machine Applications), then re-run" + ) + console.log( + " the bootstrap. It is idempotent — completed resources will be skipped.\n" + ) } // Run the main function diff --git a/scripts/package-lock.json b/scripts/package-lock.json index ac4c2360..19ec82c9 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -1,11 +1,11 @@ { - "name": "auth0-android-bootstrap", + "name": "auth0-ios-bootstrap", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "auth0-android-bootstrap", + "name": "auth0-ios-bootstrap", "version": "0.0.1", "dependencies": { "@inquirer/prompts": "^8.1.0", @@ -15,27 +15,27 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.3", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/ansi/-/ansi-2.0.3.tgz", - "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", "license": "MIT", "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/checkbox": { - "version": "5.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/checkbox/-/checkbox-5.0.4.tgz", - "integrity": "sha512-DrAMU3YBGMUAp6ArwTIp/25CNDtDbxk7UjIrrtM25JVVrlVYlVzHh5HR1BDFu9JMyUoZ4ZanzeaHqNDttf3gVg==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.1", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -47,16 +47,16 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/confirm/-/confirm-6.0.4.tgz", - "integrity": "sha512-WdaPe7foUnoGYvXzH4jp4wH/3l+dBhZ3uwhKjXjwdrq5tEIFaANxj6zrGHxLdsIA0yKM0kFPVcEalOZXBB5ISA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -68,21 +68,21 @@ } }, "node_modules/@inquirer/core": { - "version": "11.1.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/core/-/core-11.1.1.tgz", - "integrity": "sha512-hV9o15UxX46OyQAtaoMqAOxGR8RVl1aZtDx1jHbCtSJy1tBdTfKxLPKf7utsE4cRy4tcmCQ4+vdV+ca+oNxqNA==", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3", + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^9.0.2" + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -94,17 +94,17 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/editor/-/editor-5.0.4.tgz", - "integrity": "sha512-QI3Jfqcv6UO2/VJaEFONH8Im1ll++Xn/AJTBn9Xf+qx2M+H8KZAdQ5sAe2vtYlo+mLW+d7JaMJB4qWtK4BG3pw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", - "@inquirer/external-editor": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -116,16 +116,16 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/expand/-/expand-5.0.4.tgz", - "integrity": "sha512-0I/16YwPPP0Co7a5MsomlZLpch48NzYfToyqYAOWtBmaXSB80RiNQ1J+0xx2eG+Wfxt0nHtpEWSRr6CzNVnOGg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -137,16 +137,16 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "2.0.3", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/external-editor/-/external-editor-2.0.3.tgz", - "integrity": "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", "license": "MIT", "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -158,25 +158,25 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.3", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/figures/-/figures-2.0.3.tgz", - "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "license": "MIT", "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/input": { - "version": "5.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/input/-/input-5.0.4.tgz", - "integrity": "sha512-4B3s3jvTREDFvXWit92Yc6jF1RJMDy2VpSqKtm4We2oVU65YOh2szY5/G14h4fHlyQdpUmazU5MPCFZPRJ0AOw==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -188,16 +188,16 @@ } }, "node_modules/@inquirer/number": { - "version": "4.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/number/-/number-4.0.4.tgz", - "integrity": "sha512-CmMp9LF5HwE+G/xWsC333TlCzYYbXMkcADkKzcawh49fg2a1ryLc7JL1NJYYt1lJ+8f4slikNjJM9TEL/AljYQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -209,17 +209,17 @@ } }, "node_modules/@inquirer/password": { - "version": "5.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/password/-/password-5.0.4.tgz", - "integrity": "sha512-ZCEPyVYvHK4W4p2Gy6sTp9nqsdHQCfiPXIP9LbJVW4yCinnxL/dDDmPaEZVysGrj8vxVReRnpfS2fOeODe9zjg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.1", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -231,24 +231,24 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.2.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/prompts/-/prompts-8.2.0.tgz", - "integrity": "sha512-rqTzOprAj55a27jctS3vhvDDJzYXsr33WXTjODgVOru21NvBo9yIgLIAf7SBdSV0WERVly3dR6TWyp7ZHkvKFA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.0.4", - "@inquirer/confirm": "^6.0.4", - "@inquirer/editor": "^5.0.4", - "@inquirer/expand": "^5.0.4", - "@inquirer/input": "^5.0.4", - "@inquirer/number": "^4.0.4", - "@inquirer/password": "^5.0.4", - "@inquirer/rawlist": "^5.2.0", - "@inquirer/search": "^4.1.0", - "@inquirer/select": "^5.0.4" + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -260,16 +260,16 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.2.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/rawlist/-/rawlist-5.2.0.tgz", - "integrity": "sha512-CciqGoOUMrFo6HxvOtU5uL8fkjCmzyeB6fG7O1vdVAZVSopUBYECOwevDBlqNLyyYmzpm2Gsn/7nLrpruy9RFg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -281,17 +281,17 @@ } }, "node_modules/@inquirer/search": { - "version": "4.1.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/search/-/search-4.1.0.tgz", - "integrity": "sha512-EAzemfiP4IFvIuWnrHpgZs9lAhWDA0GM3l9F4t4mTQ22IFtzfrk8xbkMLcAN7gmVML9O/i+Hzu8yOUyAaL6BKA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -303,18 +303,18 @@ } }, "node_modules/@inquirer/select": { - "version": "5.0.4", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/select/-/select-5.0.4.tgz", - "integrity": "sha512-s8KoGpPYMEQ6WXc0dT9blX2NtIulMdLOO3LA1UKOiv7KFWzlJ6eLkEYTDBIi+JkyKXyn8t/CD6TinxGjyLt57g==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.1", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -326,12 +326,12 @@ } }, "node_modules/@inquirer/type": { - "version": "4.0.3", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@inquirer/type/-/type-4.0.3.tgz", - "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "license": "MIT", "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, "peerDependencies": { "@types/node": ">=18" @@ -344,13 +344,13 @@ }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "license": "MIT", "engines": { @@ -362,7 +362,7 @@ }, "node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/ansi-regex/-/ansi-regex-6.2.2.tgz", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { @@ -372,21 +372,9 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/chalk": { "version": "5.6.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/chalk/-/chalk-5.6.2.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { @@ -397,14 +385,14 @@ } }, "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "license": "MIT" }, "node_modules/cli-cursor": { "version": "5.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/cli-cursor/-/cli-cursor-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "license": "MIT", "dependencies": { @@ -419,7 +407,7 @@ }, "node_modules/cli-spinners": { "version": "3.4.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/cli-spinners/-/cli-spinners-3.4.0.tgz", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", "license": "MIT", "engines": { @@ -431,7 +419,7 @@ }, "node_modules/cli-width": { "version": "4.1.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/cli-width/-/cli-width-4.1.0.tgz", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "license": "ISC", "engines": { @@ -440,7 +428,7 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/cross-spawn/-/cross-spawn-7.0.6.tgz", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { @@ -452,15 +440,9 @@ "node": ">= 8" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, "node_modules/execa": { "version": "9.6.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/execa/-/execa-9.6.1.tgz", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "license": "MIT", "dependencies": { @@ -484,9 +466,33 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/figures": { "version": "6.1.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/figures/-/figures-6.1.0.tgz", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "license": "MIT", "dependencies": { @@ -500,9 +506,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -513,7 +519,7 @@ }, "node_modules/get-stream": { "version": "9.0.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/get-stream/-/get-stream-9.0.1.tgz", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "license": "MIT", "dependencies": { @@ -529,7 +535,7 @@ }, "node_modules/human-signals": { "version": "8.0.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/human-signals/-/human-signals-8.0.1.tgz", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "license": "Apache-2.0", "engines": { @@ -538,7 +544,7 @@ }, "node_modules/iconv-lite": { "version": "0.7.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/iconv-lite/-/iconv-lite-0.7.2.tgz", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { @@ -553,15 +559,15 @@ } }, "node_modules/inquirer": { - "version": "13.2.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/inquirer/-/inquirer-13.2.2.tgz", - "integrity": "sha512-+hlN8I88JE9T3zjWHGnMhryniRDbSgFNJHJTyD2iKO5YNpMRyfghQ6wVoe+gV4ygMM4r4GzlsBxNa1g/UUZixA==", + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.4.3.tgz", + "integrity": "sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.1", - "@inquirer/prompts": "^8.2.0", - "@inquirer/type": "^4.0.3", + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.10", + "@inquirer/prompts": "^8.4.3", + "@inquirer/type": "^4.0.5", "mute-stream": "^3.0.0", "run-async": "^4.0.6", "rxjs": "^7.8.2" @@ -580,7 +586,7 @@ }, "node_modules/is-interactive": { "version": "2.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/is-interactive/-/is-interactive-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "license": "MIT", "engines": { @@ -592,7 +598,7 @@ }, "node_modules/is-plain-obj": { "version": "4.1.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "license": "MIT", "engines": { @@ -604,7 +610,7 @@ }, "node_modules/is-stream": { "version": "4.0.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/is-stream/-/is-stream-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "license": "MIT", "engines": { @@ -616,7 +622,7 @@ }, "node_modules/is-unicode-supported": { "version": "2.1.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "license": "MIT", "engines": { @@ -628,13 +634,13 @@ }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/isexe/-/isexe-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/log-symbols": { "version": "7.0.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/log-symbols/-/log-symbols-7.0.1.tgz", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "license": "MIT", "dependencies": { @@ -650,7 +656,7 @@ }, "node_modules/mimic-function": { "version": "5.0.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/mimic-function/-/mimic-function-5.0.1.tgz", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "license": "MIT", "engines": { @@ -662,7 +668,7 @@ }, "node_modules/mute-stream": { "version": "3.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/mute-stream/-/mute-stream-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "license": "ISC", "engines": { @@ -671,7 +677,7 @@ }, "node_modules/npm-run-path": { "version": "6.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/npm-run-path/-/npm-run-path-6.0.0.tgz", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "license": "MIT", "dependencies": { @@ -687,7 +693,7 @@ }, "node_modules/npm-run-path/node_modules/path-key": { "version": "4.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/path-key/-/path-key-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "license": "MIT", "engines": { @@ -699,7 +705,7 @@ }, "node_modules/onetime": { "version": "7.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/onetime/-/onetime-7.0.0.tgz", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "license": "MIT", "dependencies": { @@ -713,9 +719,9 @@ } }, "node_modules/ora": { - "version": "9.3.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/ora/-/ora-9.3.0.tgz", - "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -724,7 +730,7 @@ "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.1", + "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" }, "engines": { @@ -736,7 +742,7 @@ }, "node_modules/parse-ms": { "version": "4.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/parse-ms/-/parse-ms-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "license": "MIT", "engines": { @@ -748,7 +754,7 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/path-key/-/path-key-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { @@ -757,7 +763,7 @@ }, "node_modules/pretty-ms": { "version": "9.3.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/pretty-ms/-/pretty-ms-9.3.0.tgz", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", "license": "MIT", "dependencies": { @@ -772,7 +778,7 @@ }, "node_modules/restore-cursor": { "version": "5.1.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/restore-cursor/-/restore-cursor-5.1.0.tgz", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "license": "MIT", "dependencies": { @@ -788,7 +794,7 @@ }, "node_modules/run-async": { "version": "4.0.6", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/run-async/-/run-async-4.0.6.tgz", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==", "license": "MIT", "engines": { @@ -797,7 +803,7 @@ }, "node_modules/rxjs": { "version": "7.8.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/rxjs/-/rxjs-7.8.2.tgz", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", "dependencies": { @@ -806,13 +812,13 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/shebang-command/-/shebang-command-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { @@ -824,7 +830,7 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/shebang-regex/-/shebang-regex-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { @@ -833,7 +839,7 @@ }, "node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/signal-exit/-/signal-exit-4.1.0.tgz", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { @@ -844,9 +850,9 @@ } }, "node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", "license": "MIT", "engines": { "node": ">=18" @@ -856,13 +862,13 @@ } }, "node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -872,12 +878,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -888,7 +894,7 @@ }, "node_modules/strip-final-newline": { "version": "4.0.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "license": "MIT", "engines": { @@ -900,13 +906,13 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/tslib/-/tslib-2.8.1.tgz", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/unicorn-magic": { "version": "0.3.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "license": "MIT", "engines": { @@ -918,7 +924,7 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/which/-/which-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { @@ -931,43 +937,9 @@ "node": ">= 8" } }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yoctocolors": { "version": "2.1.2", - "resolved": "https://a0us.jfrog.io/artifactory/api/npm/npm/yoctocolors/-/yoctocolors-2.1.2.tgz", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "license": "MIT", "engines": { diff --git a/scripts/package.json b/scripts/package.json index 6af011e7..8b856e60 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -1,5 +1,5 @@ { - "name": "auth0-android-bootstrap", + "name": "auth0-ios-bootstrap", "version": "0.0.1", "scripts": { "auth0:bootstrap": "node bootstrap.mjs" diff --git a/scripts/utils/auth0-api.mjs b/scripts/utils/auth0-api.mjs index 9d9a8de4..0cdc4a21 100644 --- a/scripts/utils/auth0-api.mjs +++ b/scripts/utils/auth0-api.mjs @@ -1,20 +1,129 @@ import { $ } from "execa" +// Default timeout for API calls (30 seconds) +const DEFAULT_API_TIMEOUT = 30000 + +/** + * Check if an error indicates an authentication/authorization issue + * @param {Error} e - The error to check + * @returns {boolean} True if it's an auth error + */ +function isAuthError(e) { + const stderr = e.stderr?.toLowerCase() || "" + + // Check for clear authentication failures + if (stderr.includes("unauthorized") || stderr.includes("401")) { + return true + } + + // Check for token expiration messages + if (stderr.includes("token") && (stderr.includes("expired") || stderr.includes("invalid"))) { + return true + } + + // Check for "please login" type messages (but not "during login" which is scope advice) + if (stderr.includes("please login") || stderr.includes("not logged in")) { + return true + } + + return false +} + /** * Make a generic API call using auth0 CLI + * @param {string} method - HTTP method (get, post, patch, delete) + * @param {string} endpoint - API endpoint + * @param {object} data - Optional data payload + * @param {number} timeout - Optional timeout in ms (default 30s) */ -export async function auth0ApiCall(method, endpoint, data = null) { - const args = ["api", method, endpoint] +export async function auth0ApiCall(method, endpoint, data = null, timeout = DEFAULT_API_TIMEOUT) { + const args = ["api", method, endpoint, "--no-input"] if (data) { args.push("--data", JSON.stringify(data)) } try { - const { stdout } = await $`auth0 ${args}` - return stdout ? JSON.parse(stdout) : null + const { stdout } = await $({ timeout })`auth0 ${args}` + const result = stdout ? JSON.parse(stdout) : null + + // The Auth0 CLI exits 0 even when the Management API returns an HTTP error + // (e.g. 400/403), printing the error body as JSON on stdout. Detect that + // shape and surface it as a real failure instead of a silent success. + if ( + result && + typeof result === "object" && + !Array.isArray(result) && + typeof result.statusCode === "number" && + result.statusCode >= 400 + ) { + const detail = result.message || result.error || "Unknown error" + throw new Error( + `Auth0 API ${method.toUpperCase()} ${endpoint} failed (${result.statusCode}): ${detail}` + ) + } + + return result } catch (e) { + // Check if it's a timeout error + if (e.timedOut) { + throw new Error( + `API call timed out after ${timeout}ms. Your Auth0 session may have expired.` + ) + } + // Check for authentication errors + if (isAuthError(e)) { + throw new Error(`Authentication failed. Your Auth0 session may have expired.`) + } + // For scope errors, return null gracefully (the feature may not be available) + if (e.stderr?.includes("lacks scope") || e.stderr?.includes("insufficient_scope")) { + console.warn( + `⚠️ Warning: Missing required scope for ${endpoint}. Some features may not be available.` + ) + return null + } console.warn(`⚠️ Warning: API Call failed: ${e.message}`) throw e } } + +/** + * Check if the Auth0 CLI session is valid by making a simple API call + * @param {number} timeout - Timeout in ms (default 10s for quick check) + * @returns {Promise} True if session is valid + */ +export async function isSessionValid(timeout = 10000) { + try { + // Probe with an endpoint whose scope is part of the bootstrap set + // (read:client_grants). Using `get users` would require read:users, which + // the bootstrap never requests — so a correctly-scoped M2M app would look + // "invalid" here even though its session is fine. + await $({ timeout })`auth0 api get client-grants --no-input` + return true + } catch (e) { + return false + } +} + +/** + * Detect the Auth0 CLI "corrupted token" state. The CLI can persist a malformed + * token (a known issue) that no refresh can fix — it must be cleared with + * `auth0 logout` before logging in again. A plain expiry does NOT report this, + * so we treat it distinctly to trigger an automatic logout+relogin. + * @param {number} timeout + * @returns {Promise} True if the stored token is corrupted + */ +export async function isTokenCorrupted(timeout = 10000) { + try { + // Same in-set probe as isSessionValid (read:client_grants) — see note there. + await $({ timeout })`auth0 api get client-grants --no-input` + return false + } catch (e) { + const text = `${e.stderr || ""} ${e.stdout || ""} ${e.message || ""}`.toLowerCase() + return ( + text.includes("token is corrupted") || + text.includes("malformed token") || + text.includes("auth0 logout") + ) + } +} diff --git a/scripts/utils/clients.mjs b/scripts/utils/clients.mjs index 59c6a650..621b10af 100644 --- a/scripts/utils/clients.mjs +++ b/scripts/utils/clients.mjs @@ -3,10 +3,73 @@ import ora from "ora" import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" // Constants export const CLIENT_NAME = "iOS UI Components Demo" +/** + * Build the allowed callback / logout URLs for the native iOS client. + * + * Auth0.swift derives its redirect URL from the app's bundle identifier. Both + * forms below must be registered so login/logout resolve whether or not the app + * has associated-domains (universal links) configured: + * - HTTPS universal link: https://{domain}/ios/{bundleId}/callback + * - Custom scheme: {bundleId}://{domain}/ios/{bundleId}/callback + * + * @param {string} domain - Auth0 tenant domain + * @param {string} bundleIdentifier - iOS app bundle identifier + * @returns {string[]} Redirect URLs (used for both callbacks and logout URLs) + */ +export function buildRedirectUrls(domain, bundleIdentifier) { + return [ + `https://${domain}/ios/${bundleIdentifier}/callback`, + `${bundleIdentifier}://${domain}/ios/${bundleIdentifier}/callback`, + ] +} + +/** + * Detect a stale iOS callback URL: any `://{domain}/ios/{bundleId}/callback` + * whose scheme is neither `https` nor the bundle identifier. These are leftovers + * from earlier runs (e.g. the old hardcoded `demo://…` scheme) and should be + * removed so the client ends up with exactly the two correct redirect URLs. + * + * @param {string} url - An existing callback/logout URL + * @param {string} domain - Auth0 tenant domain + * @param {string} bundleIdentifier - iOS app bundle identifier + * @returns {boolean} True if the URL is a stale iOS callback for this app + */ +function isStaleIosCallback(url, domain, bundleIdentifier) { + const suffix = `://${domain}/ios/${bundleIdentifier}/callback` + if (!url.endsWith(suffix)) return false + const scheme = url.slice(0, url.length - suffix.length) + return scheme !== "https" && scheme !== bundleIdentifier +} + +/** + * Reconcile an existing URL list to the desired end state: drop any stale iOS + * callback URLs for this app, preserve everything else, and ensure both desired + * redirect URLs are present (de-duplicated, order-stable). + * + * @param {string[]} existing - Current callback/logout URLs on the client + * @param {string[]} desired - The two correct redirect URLs + * @param {string} domain - Auth0 tenant domain + * @param {string} bundleIdentifier - iOS app bundle identifier + * @returns {string[]} The reconciled URL list + */ +function reconcileRedirectUrls(existing, desired, domain, bundleIdentifier) { + const kept = existing.filter( + (url) => + !isStaleIosCallback(url, domain, bundleIdentifier) && + !desired.includes(url) + ) + return [...kept, ...desired] +} + // ============================================================================ // CHECK FUNCTIONS // ============================================================================ @@ -17,8 +80,14 @@ export async function checkDashboardClientChanges( iosConfig, myAccountApiScopes ) { - const { bundleIdentifier, scheme } = iosConfig - const callbackUrl = `${scheme}://${domain}/ios/${bundleIdentifier}/callback` + const { bundleIdentifier } = iosConfig + + // Auth0.swift's WebAuthentication builds its redirect URL from the bundle + // identifier, not an arbitrary scheme. The two supported forms are the + // custom-scheme callback (scheme == bundle id) and the HTTPS universal-link + // callback. Register BOTH as allowed callback and logout URLs so login works + // whether or not associated domains are configured. + const redirectUrls = buildRedirectUrls(domain, bundleIdentifier) const existingClient = existingClients.find( (c) => c.name === CLIENT_NAME && c.app_type === "native" @@ -28,13 +97,34 @@ export async function checkDashboardClientChanges( return createChangeItem(ChangeAction.CREATE, { resource: "Native Client", name: CLIENT_NAME, - callbackUrl, + redirectUrls, }) } - // Check if callback URL needs updating + // Reconcile callback and logout URLs to the desired end state: add the two + // correct redirects and strip stale iOS callbacks (e.g. old `demo://…`), + // while preserving any unrelated URLs already on the client. const existingCallbacks = existingClient.callbacks || [] - const hasCorrectCallback = existingCallbacks.includes(callbackUrl) + const existingLogoutUrls = existingClient.allowed_logout_urls || [] + const desiredCallbacks = reconcileRedirectUrls( + existingCallbacks, + redirectUrls, + domain, + bundleIdentifier + ) + const desiredLogoutUrls = reconcileRedirectUrls( + existingLogoutUrls, + redirectUrls, + domain, + bundleIdentifier + ) + + // Order-independent comparison so both additions and removals are detected. + const sameSet = (a, b) => + a.length === b.length && + a.slice().sort().toString() === b.slice().sort().toString() + const callbacksNeedUpdate = !sameSet(existingCallbacks, desiredCallbacks) + const logoutUrlsNeedUpdate = !sameSet(existingLogoutUrls, desiredLogoutUrls) // Check if My Account API refresh token policy exists with correct scopes const hasMyAccountPolicy = existingClient.refresh_token?.policies?.some( @@ -46,22 +136,30 @@ export async function checkDashboardClientChanges( const refreshTokenPoliciesNeedUpdate = !hasMyAccountPolicy - if (!hasCorrectCallback || refreshTokenPoliciesNeedUpdate) { + if ( + callbacksNeedUpdate || + logoutUrlsNeedUpdate || + refreshTokenPoliciesNeedUpdate + ) { const updates = {} - if (!hasCorrectCallback) { - updates.callbacks = [...existingCallbacks, callbackUrl] + if (callbacksNeedUpdate) { + updates.callbacks = desiredCallbacks + } + if (logoutUrlsNeedUpdate) { + updates.allowedLogoutUrls = desiredLogoutUrls } updates.refreshTokenNeedsUpdate = refreshTokenPoliciesNeedUpdate const changes = [] - if (!hasCorrectCallback) changes.push("Update callback URL") + if (callbacksNeedUpdate) changes.push("Update callback URLs") + if (logoutUrlsNeedUpdate) changes.push("Update logout URLs") if (refreshTokenPoliciesNeedUpdate) changes.push("Update refresh token policies") return createChangeItem(ChangeAction.UPDATE, { resource: "Native Client", name: CLIENT_NAME, existing: existingClient, - callbackUrl, + redirectUrls, updates, summary: changes.join(", "), }) @@ -106,8 +204,8 @@ export async function applyDashboardClientChanges( app_type: "native", oidc_conformant: true, is_first_party: true, - callbacks: [changePlan.callbackUrl], - allowed_logout_urls: [changePlan.callbackUrl], + callbacks: changePlan.redirectUrls, + allowed_logout_urls: changePlan.redirectUrls, grant_types: ["authorization_code", "refresh_token"], token_endpoint_auth_method: "none", jwt_configuration: { @@ -142,7 +240,24 @@ export async function applyDashboardClientChanges( spinner.succeed(`Created Native Client: ${CLIENT_NAME}`) return client } catch (e) { - spinner.fail(`Failed to create Native Client`) + // The native client is the anchor for everything downstream (client + // grant, connection enablement, Auth0.plist). If we lack create:clients + // we cannot continue meaningfully, so surface a clear manual action and + // re-throw rather than pretending success. + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "create:clients" + spinner.fail(`Cannot create Native Client — M2M app lacks scope: ${scope}`) + recordManualAction({ + resource: `Native Client: ${CLIENT_NAME}`, + scope, + reason: + "The native client is required for the sample app to authenticate; the rest of the bootstrap depends on it.", + manualStep: + "Grant create:clients to the M2M app and re-run, OR create a Native application manually in the Dashboard.", + }) + } else { + spinner.fail(`Failed to create Native Client`) + } throw e } } @@ -160,6 +275,10 @@ export async function applyDashboardClientChanges( updateData.callbacks = updates.callbacks } + if (updates.allowedLogoutUrls) { + updateData.allowed_logout_urls = updates.allowedLogoutUrls + } + if (updates.refreshTokenNeedsUpdate) { const desiredMyAccountPolicy = { audience: `https://${domain}/me/`, @@ -212,6 +331,24 @@ export async function applyDashboardClientChanges( spinner.succeed(`Updated Native Client: ${CLIENT_NAME}`) return client } catch (e) { + // A missing scope (e.g. update:clients on the M2M app) should not abort + // the whole bootstrap. Record it as a manual action and continue with the + // existing client so the rest of the setup still runs. + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:clients" + spinner.warn( + `Skipped updating Native Client — M2M app lacks scope: ${scope}` + ) + recordManualAction({ + resource: `Native Client: ${CLIENT_NAME}`, + scope, + reason: + "The native client's callback/logout URLs (and My Account refresh-token policy) must be set for the app's login/logout redirects to resolve.", + manualStep: + "Grant update:clients to the M2M app and re-run, OR Dashboard → Applications → → Settings → set Allowed Callback URLs and Allowed Logout URLs to the two iOS redirect URLs.", + }) + return changePlan.existing + } spinner.fail(`Failed to update Native Client`) throw e } diff --git a/scripts/utils/connections.mjs b/scripts/utils/connections.mjs index cfe7ebae..90f52870 100644 --- a/scripts/utils/connections.mjs +++ b/scripts/utils/connections.mjs @@ -3,10 +3,31 @@ import ora from "ora" import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" // Constants export const DEFAULT_CONNECTION_NAME = "Username-Password-Authentication" +// Passkeys are a first-class authentication method for the sample app's MFA / +// Passkeys UI components. Enabling them on the database connection is what makes +// the "Passkey" option appear in Universal Login (alongside identifier-first). +// `passkey_options` mirror Auth0's recommended progressive-enrollment defaults. +const PASSKEY_CONNECTION_OPTIONS = { + authentication_methods: { + password: { enabled: true }, + passkey: { enabled: true }, + }, + passkey_options: { + challenge_ui: "both", + local_enrollment_enabled: true, + progressive_enrollment_enabled: true, + }, +} + // ============================================================================ // CHECK FUNCTIONS // ============================================================================ @@ -35,15 +56,29 @@ export function checkDatabaseConnectionChanges( (clientId) => !existingEnabledClients.includes(clientId) ) + // Check whether passkeys are enabled on the connection. If not, the sample + // app's Passkeys UI component has nothing to surface in Universal Login. + const passkeyEnabled = + existing.options?.authentication_methods?.passkey?.enabled === true + + const changes = [] if (missingClients.length > 0) { + changes.push(`Add ${missingClients.length} enabled client(s)`) + } + if (!passkeyEnabled) { + changes.push("Enable passkey authentication method") + } + + if (changes.length > 0) { return createChangeItem(ChangeAction.UPDATE, { resource: "Database Connection", name: DEFAULT_CONNECTION_NAME, existing, updates: { missingClients, + enablePasskey: !passkeyEnabled, }, - summary: `Add ${missingClients.length} enabled client(s)`, + summary: changes.join(", "), }) } @@ -81,6 +116,7 @@ export async function applyDatabaseConnectionChanges( name: DEFAULT_CONNECTION_NAME, display_name: "Universal-Components", enabled_clients: [dashboardClientId], + options: PASSKEY_CONNECTION_OPTIONS, } const createArgs = [ @@ -104,11 +140,11 @@ export async function applyDatabaseConnectionChanges( if (changePlan.action === ChangeAction.UPDATE) { const spinner = ora({ - text: `Adding missing enabled clients to ${DEFAULT_CONNECTION_NAME} connection`, + text: `Updating ${DEFAULT_CONNECTION_NAME} connection`, }).start() try { - const { existing } = changePlan + const { existing, updates } = changePlan const existingEnabledClients = existing.enabled_clients || [] // Use the actual client IDs instead of the ones from the change plan @@ -117,26 +153,105 @@ export async function applyDatabaseConnectionChanges( clientsToAdd.push(dashboardClientId) } - if (clientsToAdd.length === 0) { - spinner.succeed( - `${DEFAULT_CONNECTION_NAME} connection already has all clients enabled` - ) + // Build the patch: enable the client and/or turn on the passkey method. + const patchData = {} + + if (clientsToAdd.length > 0) { + patchData.enabled_clients = [...existingEnabledClients, ...clientsToAdd] + } + + if (updates?.enablePasskey) { + // Merge with existing options so we don't clobber other settings. + const existingOptions = existing.options || {} + patchData.options = { + ...existingOptions, + authentication_methods: { + ...(existingOptions.authentication_methods || {}), + password: { enabled: true }, + passkey: { enabled: true }, + }, + passkey_options: { + ...PASSKEY_CONNECTION_OPTIONS.passkey_options, + ...(existingOptions.passkey_options || {}), + }, + } + } + + if (Object.keys(patchData).length === 0) { + spinner.succeed(`${DEFAULT_CONNECTION_NAME} connection is already up to date`) return existing } - const updatedClients = [...existingEnabledClients, ...clientsToAdd] + await auth0ApiCall("patch", `connections/${existing.id}`, patchData) + + // auth0ApiCall swallows missing-scope errors (returns null instead of + // throwing), so a "success" here is not proof the change landed. Re-read + // the connection and verify the intended state actually applied; if not, + // treat it as a manual action rather than reporting a false success. + const updated = + (await auth0ApiCall("get", `connections/${existing.id}`)) || existing + + const clientApplied = + !patchData.enabled_clients || + (updated.enabled_clients || []).includes(dashboardClientId) + const passkeyApplied = + !patchData.options || + updated.options?.authentication_methods?.passkey?.enabled === true - await auth0ApiCall("patch", `connections/${existing.id}`, { - enabled_clients: updatedClients, - }) - spinner.succeed( - `Updated ${DEFAULT_CONNECTION_NAME} connection with ${clientsToAdd.length} new enabled client(s)` - ) + if (clientApplied && passkeyApplied) { + const applied = [] + if (patchData.enabled_clients) applied.push(`${clientsToAdd.length} client(s)`) + if (patchData.options) applied.push("passkey method") + spinner.succeed( + `Updated ${DEFAULT_CONNECTION_NAME} connection (${applied.join(", ")})` + ) + return updated + } - // Fetch updated connection - const updated = await auth0ApiCall("get", `connections/${existing.id}`) - return updated || existing + spinner.warn(`Could not fully update ${DEFAULT_CONNECTION_NAME} connection`) + + if (!clientApplied) { + recordManualAction({ + resource: `Connection: ${DEFAULT_CONNECTION_NAME} (enable app)`, + scope: "update:connections", + reason: + "The native app must be an enabled client of this database connection for username/password login to work.", + manualStep: + "Dashboard → Authentication → Database → Applications → enable the app, OR grant update:connections and re-run.", + }) + } + if (!passkeyApplied) { + // Writing the connection `options` object (which is where the passkey + // authentication method lives) requires update:connections_options — + // a scope distinct from update:connections. When it is missing the CLI + // reports an empty "lacks scope: ." because it cannot render the newer + // scope name, so we name it explicitly here. + recordManualAction({ + resource: `Connection: ${DEFAULT_CONNECTION_NAME} (enable passkeys)`, + scope: "update:connections_options", + reason: + "Passkey must be enabled on the connection for the Passkey login option to appear in Universal Login. Writing connection options requires update:connections_options (separate from update:connections).", + manualStep: + "Grant update:connections_options to the M2M app and re-run, OR Dashboard → Authentication → Database → → Authentication Methods → toggle Passkey on.", + }) + } + return updated } catch (e) { + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:connections" + spinner.warn( + `Skipped updating ${DEFAULT_CONNECTION_NAME} — M2M app lacks scope: ${scope}` + ) + recordManualAction({ + resource: `Connection: ${DEFAULT_CONNECTION_NAME}`, + scope, + reason: + "The native app must be an enabled client of this connection and passkeys enabled for the full login experience.", + manualStep: + "Dashboard → Authentication → Database → enable the app + toggle Passkey, OR grant the scope above and re-run.", + }) + return changePlan.existing + } spinner.fail(`Failed to update ${DEFAULT_CONNECTION_NAME} connection`) throw e } diff --git a/scripts/utils/discovery.mjs b/scripts/utils/discovery.mjs index 0a75d8c0..1c6acf6d 100644 --- a/scripts/utils/discovery.mjs +++ b/scripts/utils/discovery.mjs @@ -20,8 +20,12 @@ import { checkTenantSettingsChanges, checkPromptSettingsChanges, } from "./tenant-config.mjs" +import { checkGuardianFactorChanges } from "./guardian-factors.mjs" import { ChangeAction } from "./change-plan.mjs" +// Timeout for direct Auth0 CLI commands (30 seconds) +const CLI_TIMEOUT = 30000 + // ============================================================================ // Resource Discovery // ============================================================================ @@ -36,7 +40,7 @@ export async function discoverExistingResources(domain) { let clients = [] try { const clientsArgs = ["apps", "list", "--json", "--no-input"] - const { stdout } = await $`auth0 ${clientsArgs}` + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${clientsArgs}` clients = stdout ? JSON.parse(stdout) : [] } catch { clients = [] @@ -45,7 +49,7 @@ export async function discoverExistingResources(domain) { let roles = [] try { const rolesArgs = ["roles", "list", "--json", "--no-input"] - const { stdout } = await $`auth0 ${rolesArgs}` + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${rolesArgs}` roles = stdout ? JSON.parse(stdout) : [] } catch { roles = [] @@ -61,7 +65,7 @@ export async function discoverExistingResources(domain) { let resourceServers = [] try { const rsArgs = ["apis", "list", "--json", "--no-input"] - const { stdout } = await $`auth0 ${rsArgs}` + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${rsArgs}` resourceServers = stdout ? JSON.parse(stdout) : [] } catch { resourceServers = [] @@ -90,6 +94,13 @@ export async function discoverExistingResources(domain) { userAttributeProfiles = [] } + let guardianFactors = [] + try { + guardianFactors = (await auth0ApiCall("get", "guardian/factors")) || [] + } catch { + guardianFactors = [] + } + spinner.succeed("Discovered existing resources") return { @@ -100,6 +111,7 @@ export async function discoverExistingResources(domain) { clientGrants, connectionProfiles, userAttributeProfiles, + guardianFactors, } } catch (e) { spinner.fail("Failed to discover existing resources") @@ -124,6 +136,7 @@ export async function buildChangePlan(resources, domain, iosConfig) { settings: null, prompts: null, }, + guardianFactors: null, } // Profiles @@ -164,17 +177,16 @@ export async function buildChangePlan(resources, domain, iosConfig) { dashboardClientId ) - // Roles - plan.roles.admin = await checkAdminRoleChanges( - resources.roles, - domain, - MY_ACCOUNT_API_SCOPES - ) + // Roles (always SKIP in the My-Account-only flow — see roles.mjs) + plan.roles.admin = await checkAdminRoleChanges(resources.roles) // Tenant Config plan.tenantConfig.settings = await checkTenantSettingsChanges() plan.tenantConfig.prompts = await checkPromptSettingsChanges() + // MFA Factors (WebAuthn / Passkey) + plan.guardianFactors = checkGuardianFactorChanges(resources.guardianFactors) + return plan } @@ -195,6 +207,7 @@ export function displayChangePlan(plan) { { name: "Client Grant (My Account)", ...plan.clientGrants.myAccount }, { name: "Database Connection", ...plan.connection }, { name: "Admin Role", ...plan.roles.admin }, + { name: "MFA Factors (WebAuthn/Passkey)", ...plan.guardianFactors }, ] for (const item of items) { @@ -214,8 +227,8 @@ export function displayChangePlan(plan) { let detail = "" if (item.summary) { detail = ` (${item.summary})` - } else if (item.callbackUrl) { - detail = ` (callback: ${item.callbackUrl})` + } else if (item.redirectUrls?.length) { + detail = ` (callbacks: ${item.redirectUrls.join(", ")})` } console.log(` ${icon} [${label}] ${item.name || item.resource}${detail}`) diff --git a/scripts/utils/guardian-factors.mjs b/scripts/utils/guardian-factors.mjs new file mode 100644 index 00000000..333df539 --- /dev/null +++ b/scripts/utils/guardian-factors.mjs @@ -0,0 +1,113 @@ +import ora from "ora" + +import { auth0ApiCall } from "./auth0-api.mjs" +import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" + +// The WebAuthn Guardian factors back the Passkeys / device-biometric MFA +// components in the sample app. `webauthn-platform` = device-bound passkeys +// (Face ID / Touch ID), `webauthn-roaming` = security keys. Enabling them makes +// those MFA options selectable during step-up / enrollment in Universal Login. +const DESIRED_FACTORS = ["webauthn-platform", "webauthn-roaming"] + +// ============================================================================ +// CHECK +// ============================================================================ + +/** + * Determine which WebAuthn MFA factors still need to be enabled. + * @param {Array<{name:string,enabled:boolean}>} existingFactors + * @returns {object} change item + */ +export function checkGuardianFactorChanges(existingFactors) { + const byName = new Map( + (existingFactors || []).map((f) => [f.name, f]) + ) + + const toEnable = DESIRED_FACTORS.filter((name) => { + const factor = byName.get(name) + // If the factor isn't present at all we still attempt to enable it; the API + // will tell us if it is unavailable on this tenant. + return !factor || factor.enabled !== true + }) + + if (toEnable.length === 0) { + return createChangeItem(ChangeAction.SKIP, { + resource: "MFA Factors (WebAuthn/Passkey)", + }) + } + + return createChangeItem(ChangeAction.UPDATE, { + resource: "MFA Factors (WebAuthn/Passkey)", + updates: { toEnable }, + summary: `Enable ${toEnable.join(", ")}`, + }) +} + +// ============================================================================ +// APPLY +// ============================================================================ + +/** + * Enable the WebAuthn MFA factors. Each factor is toggled independently so a + * missing entitlement on one does not block the other. Missing-scope failures + * are recorded as manual actions rather than aborting the bootstrap. + * @param {object} changePlan + */ +export async function applyGuardianFactorChanges(changePlan) { + if (changePlan.action === ChangeAction.SKIP) { + const spinner = ora({ text: `MFA factors are up to date` }).start() + spinner.succeed() + return + } + + for (const factor of changePlan.updates.toEnable) { + const spinner = ora({ text: `Enabling MFA factor: ${factor}` }).start() + try { + await auth0ApiCall("put", `guardian/factors/${factor}`, { + enabled: true, + }) + + // auth0ApiCall swallows missing-scope errors (returns null), so verify. + // NOTE: the per-factor GET (guardian/factors/:name) is not supported and + // returns 404 — only the list endpoint reflects state, so re-list here. + const factors = (await auth0ApiCall("get", "guardian/factors")) || [] + const current = factors.find((f) => f.name === factor) + if (current?.enabled === true) { + spinner.succeed(`Enabled MFA factor: ${factor}`) + } else { + spinner.warn( + `Could not enable ${factor} — likely missing scope: update:guardian_factors` + ) + recordManualAction({ + resource: `MFA Factor: ${factor}`, + scope: "update:guardian_factors", + reason: + "Enables the WebAuthn/Passkey MFA option in Universal Login step-up and enrollment.", + manualStep: + "Dashboard → Security → Multi-factor Auth → enable WebAuthn, OR grant update:guardian_factors and re-run.", + }) + } + } catch (e) { + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:guardian_factors" + spinner.warn(`Skipped ${factor} — M2M app lacks scope: ${scope}`) + recordManualAction({ + resource: `MFA Factor: ${factor}`, + scope, + reason: + "Enables the WebAuthn/Passkey MFA option in Universal Login step-up and enrollment.", + manualStep: + "Dashboard → Security → Multi-factor Auth → enable WebAuthn, OR grant the scope above and re-run.", + }) + continue + } + spinner.fail(`Failed to enable MFA factor: ${factor}`) + throw e + } + } +} diff --git a/scripts/utils/helpers.mjs b/scripts/utils/helpers.mjs index 06b53b90..2af0b5dd 100644 --- a/scripts/utils/helpers.mjs +++ b/scripts/utils/helpers.mjs @@ -1,6 +1,9 @@ import readline from "node:readline/promises" import { select } from "@inquirer/prompts" +/** + * Wait for user confirmation before proceeding + */ export async function confirmWithUser(message) { const rl = readline.createInterface({ input: process.stdin, @@ -13,6 +16,9 @@ export async function confirmWithUser(message) { return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes" } +/** + * Wait for user input + */ export async function getInputFromUser(message) { const rl = readline.createInterface({ input: process.stdin, @@ -25,6 +31,9 @@ export async function getInputFromUser(message) { return answer.toLowerCase() } +/** + * Prompt the user to select one option from a list + */ export async function selectOptionFromList(message, options) { const answer = await select({ message: message, choices: options }) return answer diff --git a/scripts/utils/info-plist-writer.mjs b/scripts/utils/info-plist-writer.mjs new file mode 100644 index 00000000..4e1bb74a --- /dev/null +++ b/scripts/utils/info-plist-writer.mjs @@ -0,0 +1,169 @@ +import { $ } from "execa" +import fs from "node:fs" +import path from "node:path" +import ora from "ora" + +const PLIST_BUDDY = "/usr/libexec/PlistBuddy" + +/** + * Print manual fallback instructions for adding the URL scheme by hand. + */ +function printManualInstructions(scheme) { + console.log("\n Add the following to your app's Info.plist manually:\n") + console.log(` CFBundleURLTypes`) + console.log(` `) + console.log(` `) + console.log(` CFBundleURLSchemes`) + console.log(` ${scheme}`) + console.log(` CFBundleTypeRole`) + console.log(` None`) + console.log(` `) + console.log(` \n`) +} + +/** + * Read and parse an Info.plist file as JSON via plutil. + * @returns {object|null} Parsed plist, or null if it can't be read/parsed. + */ +async function readPlistAsJson(infoPlistPath) { + try { + const { stdout } = await $`plutil -convert json -o - ${infoPlistPath}` + return JSON.parse(stdout) + } catch { + return null + } +} + +/** + * Idempotently add the OAuth callback URL scheme to the sample app's Info.plist. + * + * The Auth0 iOS SDK uses a custom URL scheme to receive the login/logout + * callback. Without a matching `CFBundleURLSchemes` entry the redirect cannot + * return to the app, so this wires it up automatically as part of bootstrap. + * + * Safe to run repeatedly: if the scheme is already present it is a no-op. If + * the Info.plist is missing or has an unexpected shape, it prints manual + * instructions instead of corrupting the file. + * + * @param {string} infoPlistPath - Absolute path to the app's Info.plist + * @param {string} scheme - URL scheme to register (the app's bundle identifier) + * @param {string[]} staleSchemes - Legacy schemes to remove (e.g. old "demo") + */ +export async function writeInfoPlistUrlScheme( + infoPlistPath, + scheme, + staleSchemes = ["demo"] +) { + const spinner = ora({ + text: `Configuring URL scheme "${scheme}" in Info.plist`, + }).start() + + // The file must exist — we never create an Info.plist from scratch. + if (!fs.existsSync(infoPlistPath)) { + spinner.warn(`Could not find Info.plist at ${infoPlistPath}`) + printManualInstructions(scheme) + return + } + + const plist = await readPlistAsJson(infoPlistPath) + if (!plist) { + spinner.warn("Could not read Info.plist (unexpected format)") + printManualInstructions(scheme) + return + } + + const hasUrlTypesKey = Array.isArray(plist.CFBundleURLTypes) + const urlTypes = hasUrlTypesKey ? plist.CFBundleURLTypes : [] + + // Remove any leftover URL-type entries from earlier runs whose schemes are + // ALL stale (e.g. the old hardcoded "demo"). We only delete an entry when + // every scheme in it is stale, so we never disturb a URL type that also + // carries a scheme the app legitimately uses. Delete from the highest index + // down so earlier indices don't shift mid-operation. + const staleIndices = urlTypes + .map((entry, i) => ({ entry, i })) + .filter(({ entry }) => { + const schemes = Array.isArray(entry?.CFBundleURLSchemes) + ? entry.CFBundleURLSchemes + : [] + return ( + schemes.length > 0 && schemes.every((s) => staleSchemes.includes(s)) + ) + }) + .map(({ i }) => i) + + if (staleIndices.length > 0) { + const deleteArgs = staleIndices + .sort((a, b) => b - a) + .flatMap((i) => ["-c", `Delete :CFBundleURLTypes:${i}`]) + try { + await $`${PLIST_BUDDY} ${deleteArgs} ${infoPlistPath}` + // Reflect the removals in our in-memory view so index math below is correct. + for (const i of staleIndices) urlTypes.splice(i, 1) + } catch (e) { + spinner.warn( + `Could not remove stale URL scheme(s) [${staleSchemes.join(", ")}] — continuing` + ) + } + } + + // Idempotency guard: skip if any existing URL type already declares the scheme. + const alreadyConfigured = urlTypes.some((entry) => + Array.isArray(entry?.CFBundleURLSchemes) + ? entry.CFBundleURLSchemes.includes(scheme) + : false + ) + + if (alreadyConfigured) { + const note = + staleIndices.length > 0 + ? ` (removed stale: ${staleSchemes.join(", ")})` + : "" + spinner.succeed( + `URL scheme "${scheme}" already configured in Info.plist${note}` + ) + return + } + + // Build the PlistBuddy commands. Append a new URL type at the next index so + // we never disturb existing entries. + const index = urlTypes.length + const commands = [] + // Only create the array if the key doesn't exist at all. If it exists but is + // now empty (e.g. we just deleted a stale entry), re-adding it would fail. + if (!hasUrlTypesKey) { + commands.push("Add :CFBundleURLTypes array") + } + commands.push(`Add :CFBundleURLTypes:${index} dict`) + commands.push(`Add :CFBundleURLTypes:${index}:CFBundleTypeRole string None`) + commands.push(`Add :CFBundleURLTypes:${index}:CFBundleURLSchemes array`) + commands.push( + `Add :CFBundleURLTypes:${index}:CFBundleURLSchemes:0 string ${scheme}` + ) + + const args = commands.flatMap((c) => ["-c", c]) + + try { + await $`${PLIST_BUDDY} ${args} ${infoPlistPath}` + + // Verify the scheme is now present before reporting success. + const updated = await readPlistAsJson(infoPlistPath) + const verified = (updated?.CFBundleURLTypes || []).some((entry) => + Array.isArray(entry?.CFBundleURLSchemes) + ? entry.CFBundleURLSchemes.includes(scheme) + : false + ) + + if (!verified) { + throw new Error("Scheme not present after write") + } + + spinner.succeed( + `Added URL scheme "${scheme}" to ${path.relative(process.cwd(), infoPlistPath)}` + ) + } catch (e) { + spinner.fail("Failed to update Info.plist") + console.warn(` ${e.message}`) + printManualInstructions(scheme) + } +} diff --git a/scripts/utils/manual-actions.mjs b/scripts/utils/manual-actions.mjs new file mode 100644 index 00000000..60d5a139 --- /dev/null +++ b/scripts/utils/manual-actions.mjs @@ -0,0 +1,60 @@ +// ============================================================================ +// Manual-action tracking + permission-error detection +// +// The bootstrap can authenticate with an M2M app that is missing some +// Management API scopes (e.g. `update:tenant_settings`). Rather than aborting +// the whole run when a single privileged operation is denied, the affected +// apply step records a "manual action" here, warns, and continues. At the end +// of the run the bootstrap prints a consolidated list of what still needs to +// be done by hand (or by granting the missing scope and re-running). +// ============================================================================ + +const pendingManualActions = [] + +/** + * Record something the caller could not complete automatically. + * @param {{ resource: string, reason: string, scope?: string, manualStep?: string }} action + */ +export function recordManualAction(action) { + pendingManualActions.push(action) +} + +/** + * @returns {Array<{resource:string,reason:string,scope?:string,manualStep?:string}>} + */ +export function getManualActions() { + return pendingManualActions +} + +/** + * Detect whether an execa/CLI error was caused by a missing Management API + * scope or an authorization denial (as opposed to a genuine failure we should + * surface). The Auth0 CLI reports these as + * "Request failed because access token lacks scope: ". + * @param {Error & {stderr?: string, stdout?: string}} e + * @returns {boolean} + */ +export function isPermissionError(e) { + const text = `${e?.stderr || ""} ${e?.stdout || ""} ${e?.message || ""}`.toLowerCase() + + return ( + text.includes("lacks scope") || + text.includes("insufficient_scope") || + text.includes("insufficient scope") || + text.includes("forbidden") || + text.includes("access_denied") || + text.includes("403") + ) +} + +/** + * Try to pull the specific scope name out of a "lacks scope: " message + * so the manual-action summary can name exactly what to grant. + * @param {Error & {stderr?: string}} e + * @returns {string | null} + */ +export function extractMissingScope(e) { + const text = `${e?.stderr || ""} ${e?.message || ""}` + const match = text.match(/lacks scope:\s*([a-z0-9_:*-]+)/i) + return match ? match[1] : null +} diff --git a/scripts/utils/roles.mjs b/scripts/utils/roles.mjs index f697d00e..a0a2c561 100644 --- a/scripts/utils/roles.mjs +++ b/scripts/utils/roles.mjs @@ -1,60 +1,35 @@ -import { $ } from "execa" import ora from "ora" -import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" // ============================================================================ // CHECK FUNCTIONS // ============================================================================ -export async function checkAdminRoleChanges( - existingRoles, - domain, - myAccountApiScopes -) { +/** + * In the iOS (My Account only) flow the admin role is always a no-op. + * + * My Account is a System API (identifier `https://{domain}/me/`, scopes like + * `read:me:factors`). The Management API refuses to attach System-API scopes to + * a role — `POST roles/:id/permissions` returns + * `400 operation_not_supported: "System APIs may not be used"`. Since every + * scope this bootstrap manages is a `:me:` System-API scope, there is nothing + * assignable to a role, and the sample app never uses one (My Account access is + * granted via the client grant, not a role). + * + * The check therefore always returns SKIP. It is kept in the plan so the change + * summary and idempotency logic remain uniform across resources; the earlier + * CREATE/UPDATE code paths were dead and have been removed. + */ +export async function checkAdminRoleChanges(existingRoles) { const existingRole = existingRoles.find((r) => r.name === "admin") - if (!existingRole) { - return createChangeItem(ChangeAction.CREATE, { - resource: "Admin Role", - name: "admin", - permissions: myAccountApiScopes, - domain, - }) - } - - // Check if permissions are correct - try { - const permissions = await auth0ApiCall( - "get", - `roles/${existingRole.id}/permissions` - ) - const existingPermissions = (permissions || []).map( - (p) => p.permission_name - ) - const missingPermissions = myAccountApiScopes.filter( - (s) => !existingPermissions.includes(s) - ) - - if (missingPermissions.length > 0) { - return createChangeItem(ChangeAction.UPDATE, { - resource: "Admin Role", - name: "admin", - existing: existingRole, - permissions: missingPermissions, - domain, - summary: `Add ${missingPermissions.length} missing permissions`, - }) - } - } catch { - // If we can't check permissions, skip - } - return createChangeItem(ChangeAction.SKIP, { resource: "Admin Role", name: "admin", existing: existingRole, + reason: + "My Account is a System API; its scopes cannot be assigned to a role", }) } @@ -63,76 +38,8 @@ export async function checkAdminRoleChanges( // ============================================================================ export async function applyAdminRoleChanges(changePlan) { - if (changePlan.action === ChangeAction.SKIP) { - const spinner = ora({ - text: `Admin Role is up to date`, - }).start() - spinner.succeed() - return changePlan.existing - } - - if (changePlan.action === ChangeAction.CREATE) { - const spinner = ora({ - text: `Creating admin role`, - }).start() - - try { - const createRoleArgs = [ - "roles", - "create", - "--name", - "admin", - "--description", - "Manage the tenant configuration.", - "--json", - "--no-input", - ] - - const { stdout } = await $`auth0 ${createRoleArgs}` - const role = JSON.parse(stdout) - - // Add permissions - const permissionsData = { - permissions: changePlan.permissions.map((scope) => ({ - permission_name: scope, - resource_server_identifier: `https://${changePlan.domain}/me/`, - })), - } - - await auth0ApiCall("post", `roles/${role.id}/permissions`, permissionsData) - - spinner.succeed(`Created admin role`) - return role - } catch (e) { - spinner.fail(`Failed to create the admin role`) - throw e - } - } - - if (changePlan.action === ChangeAction.UPDATE) { - const spinner = ora({ - text: `Updating admin role permissions`, - }).start() - - try { - const permissionsData = { - permissions: changePlan.permissions.map((scope) => ({ - permission_name: scope, - resource_server_identifier: `https://${changePlan.domain}/me/`, - })), - } - - await auth0ApiCall( - "post", - `roles/${changePlan.existing.id}/permissions`, - permissionsData - ) - - spinner.succeed(`Updated admin role permissions`) - return changePlan.existing - } catch (e) { - spinner.fail(`Failed to update admin role permissions`) - throw e - } - } + // Only SKIP is ever produced by checkAdminRoleChanges (see note above). + const spinner = ora({ text: `Admin Role is up to date` }).start() + spinner.succeed() + return changePlan.existing } diff --git a/scripts/utils/tenant-config.mjs b/scripts/utils/tenant-config.mjs index dced943f..27e63a86 100644 --- a/scripts/utils/tenant-config.mjs +++ b/scripts/utils/tenant-config.mjs @@ -3,6 +3,11 @@ import ora from "ora" import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" // ============================================================================ // CHECK FUNCTIONS - Determine what changes are needed @@ -116,6 +121,25 @@ export async function applyTenantSettingsChanges(changePlan) { await $`auth0 ${tenantSettingsArgs}` spinner.succeed("Updated tenant settings") } catch (e) { + // Missing update:tenant_settings should not abort the whole bootstrap — + // record it as a manual step and continue. The MFA-customization flag in + // particular is required for the MFA/Passkeys components in the demo, so + // we surface it clearly at the end. + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:tenant_settings" + spinner.warn( + `Skipped tenant settings — M2M app lacks scope: ${scope}` + ) + recordManualAction({ + resource: "Tenant Settings", + scope, + reason: + "Enables MFA customization in the post-login action, disables client connections, and sets the friendly name/logo.", + manualStep: + "Dashboard → Settings → enable required flags, OR grant the scope above to the M2M app and re-run the bootstrap.", + }) + return + } spinner.fail(`Failed to configure tenant settings`) throw e } @@ -151,6 +175,19 @@ export async function applyPromptSettingsChanges(changePlan) { await $`auth0 ${promptSettingsArgs}` spinner.succeed("Updated prompt settings") } catch (e) { + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:prompts" + spinner.warn(`Skipped prompt settings — M2M app lacks scope: ${scope}`) + recordManualAction({ + resource: "Prompt Settings", + scope, + reason: + "Enables the identifier-first login experience for Universal Login.", + manualStep: + "Dashboard → Authentication → Login → enable Identifier First, OR grant the scope above and re-run.", + }) + return + } spinner.fail(`Failed to configure prompt settings`) throw e } diff --git a/scripts/utils/validation.mjs b/scripts/utils/validation.mjs index 2ad027db..528ffbfb 100644 --- a/scripts/utils/validation.mjs +++ b/scripts/utils/validation.mjs @@ -1,8 +1,126 @@ -import { $ } from "execa" +import { $, execaSync } from "execa" import ora from "ora" import fs from "node:fs" import path from "node:path" +import { auth0ApiCall, isSessionValid, isTokenCorrupted } from "./auth0-api.mjs" +import { confirmWithUser } from "./helpers.mjs" +import { MY_ACCOUNT_API_SCOPES } from "./resource-servers.mjs" + +// Timeout for CLI commands (15 seconds) +const CLI_TIMEOUT = 15000 + +// All scopes needed for the iOS bootstrap operations. +// +// Each entry carries a one-line `reason` (surfaced in `--help` usage details) +// and an `important` flag. "Important" scopes are the write permissions that +// gate a user-visible feature or the self-grant capability — the ones most +// likely to be missing on an M2M app and to silently block setup. They are +// highlighted in the pre-login summary so you know why they are requested. +// +// NOTE: Organization-only scopes (create:organization_*) are intentionally +// omitted — the iOS sample app configures the My Account feature only. +const BOOTSTRAP_SCOPE_METADATA = [ + { scope: "read:connection_profiles", reason: "Read connection profiles" }, + { scope: "create:connection_profiles", reason: "Create the connection profile" }, + { scope: "read:user_attribute_profiles", reason: "Read user attribute profiles" }, + { scope: "create:user_attribute_profiles", reason: "Create the user attribute profile" }, + { scope: "read:client_grants", reason: "Read existing client grants" }, + { scope: "create:client_grants", reason: "Grant the app access to the My Account API" }, + { + scope: "update:client_grants", + reason: + "Lets the M2M app grant itself any future scopes — without it, scope changes need the Dashboard (chicken-and-egg).", + important: true, + }, + { scope: "read:connections", reason: "Read database connections" }, + { scope: "create:connections", reason: "Create the database connection" }, + { + scope: "update:connections", + reason: "Enable the native app as a client of the connection (username/password login).", + important: true, + }, + { scope: "read:connections_options", reason: "Read connection options (auth methods)" }, + { + scope: "update:connections_options", + reason: "Enable passkeys on the connection so the Passkey option shows in Universal Login.", + important: true, + }, + { scope: "read:clients", reason: "Read existing applications" }, + { + scope: "create:clients", + reason: "Create the native iOS application the sample app authenticates with.", + important: true, + }, + { + scope: "update:clients", + reason: "Set the app's callback + logout URLs so login/logout redirects resolve.", + important: true, + }, + { scope: "read:resource_servers", reason: "Read existing APIs" }, + { scope: "create:resource_servers", reason: "Register the My Account API resource server" }, + { scope: "update:resource_servers", reason: "Keep the My Account API scopes in sync" }, + { + scope: "update:tenant_settings", + reason: "Enable MFA customization in the post-login action for the MFA components.", + important: true, + }, + { + scope: "update:prompts", + reason: "Turn on identifier-first login, required for the Passkey prompt.", + important: true, + }, + { scope: "read:guardian_factors", reason: "Read enabled MFA factors" }, + { + scope: "update:guardian_factors", + reason: "Enable WebAuthn MFA factors so the MFA components have something to enroll.", + important: true, + }, +] + +// Flat scope-name list for `auth0 login --scopes` and any join operations. +const BOOTSTRAP_SCOPES = BOOTSTRAP_SCOPE_METADATA.map((s) => s.scope) + +/** + * Print the full per-scope rationale. Used by `--help` so a user who wants to + * know why each permission is requested can expand the usage details. Important + * scopes are marked with a star. + */ +export function printScopeUsageDetails() { + console.log( + `\nManagement API scopes requested at login (${BOOTSTRAP_SCOPES.length} total, ★ = key permission):\n` + ) + for (const { scope, reason, important } of BOOTSTRAP_SCOPE_METADATA) { + const marker = important ? "★" : " " + console.log(` ${marker} ${scope.padEnd(30)} ${reason}`) + } + console.log("") +} + +/** + * Print a summary of the scopes the bootstrap will request, shown right before + * an interactive login prompts for consent. Important scopes are flagged with a + * one-line reason so you know why elevated permissions are being requested; the + * full per-scope rationale is available via `npm run auth0:bootstrap --help`. + */ +function printScopeSummary() { + const important = BOOTSTRAP_SCOPE_METADATA.filter((s) => s.important) + + console.log( + `\n📋 This login requests ${BOOTSTRAP_SCOPES.length} Management API scopes to configure your tenant.` + ) + console.log( + ` ${important.length} are key permissions that gate a user-visible feature or self-service setup:\n` + ) + for (const { scope, reason } of important) { + console.log(` • ${scope}`) + console.log(` ↳ ${reason}`) + } + console.log( + "\n Run with --help to see the reason for every requested scope.\n" + ) +} + /** * Check Node.js version */ @@ -24,7 +142,7 @@ export async function checkAuth0CLI() { }).start() try { - await $`auth0 --version` + await $({ timeout: CLI_TIMEOUT })`auth0 --version` cliCheck.succeed() } catch { cliCheck.fail( @@ -35,7 +153,444 @@ export async function checkAuth0CLI() { } /** - * Validate tenant configuration + * Read machine-to-machine (client-credentials) login parameters from the + * environment. When all three are present the script can authenticate the CLI + * non-interactively — no browser, no device code — which makes the bootstrap + * fully standalone (works in CI / headless shells) and sidesteps any tenant + * post-login Actions that only run on interactive logins. + * + * @param {string} domain - The tenant domain being configured (fallback for AUTH0_DOMAIN) + * @returns {{ domain: string, clientId: string, clientSecret: string } | null} + */ +/** + * Whether M2M client-credentials are configured (env or .env). Used by the + * bootstrap to decide if it can safely auto-confirm in a non-interactive run. + * @param {string} domain - The tenant domain being configured + * @returns {boolean} + */ +export function hasMachineCredentials(domain = null) { + return readMachineCredentials(domain) !== null +} + +function readMachineCredentials(domain = null) { + // Merge process env with an optional .env file in the scripts directory. + // Shell `export`s often do not survive into `npm run` child processes, so a + // local .env is the reliable channel for non-interactive credentials. + const fileEnv = readDotEnvFile() + + const clientId = (process.env.AUTH0_CLIENT_ID || fileEnv.AUTH0_CLIENT_ID)?.trim() + const clientSecret = ( + process.env.AUTH0_CLIENT_SECRET || fileEnv.AUTH0_CLIENT_SECRET + )?.trim() + const envDomain = + (process.env.AUTH0_DOMAIN || fileEnv.AUTH0_DOMAIN)?.trim() || domain + + if (clientId && clientSecret && envDomain) { + return { domain: envDomain, clientId, clientSecret } + } + + return null +} + +/** + * Read a minimal KEY=VALUE .env file from the scripts directory, if present. + * Only used to source M2M credentials; values already in process.env win. + * Supports optional surrounding quotes and ignores comments/blank lines. + * @returns {Record} + */ +function readDotEnvFile() { + try { + const envPath = path.resolve(process.cwd(), ".env") + if (!fs.existsSync(envPath)) return {} + + const out = {} + for (const rawLine of fs.readFileSync(envPath, "utf-8").split("\n")) { + const line = rawLine.trim() + if (!line || line.startsWith("#")) continue + const eq = line.indexOf("=") + if (eq === -1) continue + const key = line.slice(0, eq).trim() + let value = line.slice(eq + 1).trim() + // Strip a single pair of surrounding quotes. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + out[key] = value + } + return out + } catch { + return {} + } +} + +/** + * Authenticate the Auth0 CLI using client credentials (machine-to-machine). + * This is non-interactive: it runs `auth0 login --no-input --domain ... + * --client-id ... --client-secret ...`. Requires an M2M application on the + * tenant that is authorized for the Auth0 Management API with the bootstrap + * scopes. + * + * @param {{ domain: string, clientId: string, clientSecret: string }} creds + * @returns {Promise} True if login was successful + */ +async function runAuth0MachineLogin(creds) { + const spinner = ora({ + text: `Authenticating with client credentials (${creds.domain})`, + }).start() + + try { + const args = [ + "login", + "--no-input", + "--domain", + creds.domain, + "--client-id", + creds.clientId, + "--client-secret", + creds.clientSecret, + ] + + // Machine login is a quick token exchange; give it a generous timeout but + // it should return in a second or two. stdio is captured (not inherited) + // so the client secret is never echoed to the terminal. + await execaSync("auth0", args, { timeout: 30000 }) + spinner.succeed(`Authenticated with client credentials (${creds.domain})`) + return true + } catch (e) { + spinner.fail("Client-credentials login failed") + if (e.timedOut) { + console.error("\n❌ Machine login timed out. Please try again.") + } else { + // The CLI prints the useful detail (bad client-id/secret/domain) on + // stderr; surface it without leaking the secret we passed in. + const detail = (e.stderr || e.shortMessage || e.message || "") + .split("\n") + .filter((l) => !l.includes(creds.clientSecret)) + .join("\n") + .trim() + console.error(`\n❌ Login failed: ${detail}`) + console.error( + " Verify AUTH0_CLIENT_ID / AUTH0_CLIENT_SECRET / AUTH0_DOMAIN belong" + ) + console.error( + " to an M2M app authorized for the Management API on this tenant.\n" + ) + } + return false + } +} + +/** + * Ensure the M2M app's Management API client grant holds every bootstrap scope, + * self-granting the missing ones when possible. + * + * The chicken-and-egg: an M2M app can only add scopes to a client grant (its + * own included) if its token already carries `update:client_grants`. Once that + * one scope is granted in the Dashboard, the app can grant itself all the + * others — so this closes the gap automatically on every subsequent run and no + * further Dashboard visits are needed. + * + * Because the CLI's cached access token predates the PATCH, the caller must + * re-authenticate afterwards for the new scopes to take effect — this function + * only performs the grant and reports whether one happened. + * + * @param {{ domain: string, clientId: string }} creds - M2M credentials + * @returns {Promise} True if scopes were added (a re-login is needed) + */ +async function ensureManagementScopes(creds) { + const audience = `https://${creds.domain}/api/v2/` + + // Find this app's Management API client grant. + let grants + try { + grants = await auth0ApiCall( + "get", + `client-grants?client_id=${encodeURIComponent(creds.clientId)}` + ) + } catch { + // Reading grants itself needs read:client_grants; if we can't, stay silent + // and let the individual apply steps report their own missing scopes. + return false + } + + const list = Array.isArray(grants) ? grants : grants?.client_grants || [] + const grant = list + .filter((g) => g.audience === audience) + .sort((a, b) => (b.scope?.length || 0) - (a.scope?.length || 0))[0] + + if (!grant) return false + + const current = new Set(grant.scope || []) + const missing = BOOTSTRAP_SCOPES.filter((s) => !current.has(s)) + if (missing.length === 0) return false + + // We can only patch the grant if the token can write client grants. + if (!current.has("update:client_grants")) { + const spinner = ora({ + text: "Checking M2M Management API scopes", + }).start() + spinner.warn( + `M2M app is missing ${missing.length} scope(s), including the self-grant ` + + `permission (update:client_grants) needed to add them automatically.` + ) + console.log( + "\n Grant update:client_grants once in the Dashboard and the script will\n" + + " self-grant the rest on the next run. Missing scopes:\n" + ) + for (const s of missing) console.log(` • ${s}`) + console.log("") + return false + } + + const spinner = ora({ + text: `Self-granting ${missing.length} missing Management API scope(s)`, + }).start() + + try { + const updatedScopes = [...(grant.scope || []), ...missing] + await auth0ApiCall("patch", `client-grants/${grant.id}`, { + scope: updatedScopes, + }) + + // Verify the grant actually holds the new scopes before claiming success. + const verify = await auth0ApiCall("get", `client-grants/${grant.id}`) + const now = new Set(verify?.scope || []) + const stillMissing = missing.filter((s) => !now.has(s)) + + if (stillMissing.length > 0) { + spinner.warn( + `Could not self-grant: ${stillMissing.join(", ")} — grant them manually.` + ) + return false + } + + spinner.succeed( + `Self-granted ${missing.length} scope(s): ${missing.join(", ")}` + ) + return true + } catch (e) { + spinner.warn(`Could not self-grant missing scopes: ${e.message}`) + return false + } +} + +/** + * Run Auth0 CLI login interactively with the required scopes + * @param {string} domain - Optional tenant domain to login to + * @returns {Promise} True if login was successful + */ +async function runAuth0Login(domain = null) { + // Explain what is being requested before the browser consent screen appears. + printScopeSummary() + + console.log("🔐 Starting Auth0 CLI login...\n") + console.log(" A browser window will open for authentication.") + console.log(" Please complete the login process.\n") + + try { + // Build login args with required scopes + const scopesArg = BOOTSTRAP_SCOPES.join(",") + const args = ["login", "--scopes", scopesArg] + + // Add domain if specified + if (domain) { + args.push("--domain", domain) + } + + // Run login in interactive mode (no --no-input flag). + // Use stdio: 'inherit' to allow interactive browser-based login. + execaSync("auth0", args, { + stdio: "inherit", + timeout: 120000, // 2 minute timeout for login process + }) + return true + } catch (e) { + if (e.timedOut) { + console.error("\n❌ Login timed out. Please try again.") + } else { + console.error(`\n❌ Login failed: ${e.message}`) + } + return false + } +} + +/** + * Clear a corrupted CLI token by running `auth0 logout`. A malformed token can + * only be fixed by logging out first; a subsequent login then succeeds. + * @param {string} domain - Tenant to log out of (falls back to a plain logout) + * @returns {Promise} + */ +async function clearCorruptedToken(domain = null) { + const spinner = ora({ + text: `Clearing corrupted Auth0 CLI token`, + }).start() + try { + const args = domain ? ["logout", domain] : ["logout"] + execaSync("auth0", args, { timeout: CLI_TIMEOUT }) + spinner.succeed("Cleared corrupted token — a fresh login is required") + } catch { + // A logout failure is non-fatal; the subsequent login attempt may still fix it. + spinner.warn("Could not run 'auth0 logout' automatically — continuing") + } +} + +/** + * Validate the Auth0 CLI session and, if it is expired, restore it. + * + * Login strategy (in priority order): + * 1. If a valid session already exists, do nothing. + * 2. If the stored token is corrupted, clear it with `auth0 logout` first. + * 3. If M2M credentials are present (env or scripts/.env), authenticate + * non-interactively via client credentials. This keeps the bootstrap + * standalone (headless / CI) and avoids interactive-only post-login + * Actions on the tenant. + * 4. Otherwise fall back to an interactive browser login (device code). + * + * @param {string} domain - Optional tenant domain (fallback for AUTH0_DOMAIN) + * @returns {Promise} + */ +export async function validateAuth0Session(domain = null) { + const spinner = ora({ + text: `Validating Auth0 CLI session`, + }).start() + + // When M2M credentials are available for the requested domain, authenticate + // that specific tenant non-interactively regardless of whatever session may + // currently be active. A valid session for a *different* tenant must not let + // the run proceed against the wrong tenant, and re-authenticating is cheap. + const machineCreds = readMachineCredentials(domain) + if (machineCreds) { + spinner.info("Using M2M client-credentials login for the requested tenant") + + // A corrupted token blocks even a fresh login until it is cleared. + if (await isTokenCorrupted()) { + console.log( + "\n⚠️ The stored Auth0 CLI token is corrupted; clearing it before re-login.\n" + ) + await clearCorruptedToken(machineCreds.domain) + } + + console.log( + "\n🔐 Authenticating with M2M client credentials (no browser required).\n" + ) + const loginSuccess = await runAuth0MachineLogin(machineCreds) + + if (loginSuccess) { + // A fresh login (or a prior logout) can leave a different tenant active. + // Make the requested tenant active so discovery targets the right one. + await switchToTenant(machineCreds.domain) + + const postLoginValid = await isSessionValid() + if (postLoginValid) { + // If the app can self-grant, top up any missing bootstrap scopes now. + // The freshly issued token predates the grant change, so re-authenticate + // (and re-select the tenant) for the new scopes to take effect. + const grantedMore = await ensureManagementScopes(machineCreds) + if (grantedMore) { + console.log( + "\n🔄 Re-authenticating so the newly granted scopes take effect.\n" + ) + if (await runAuth0MachineLogin(machineCreds)) { + await switchToTenant(machineCreds.domain) + } + } + console.log("\n✅ Successfully authenticated the Auth0 CLI\n") + return + } + console.error("\n❌ Session validation failed after machine login.") + console.error( + " The M2M app may lack the required Management API scopes.\n" + ) + process.exit(1) + } + + // Machine login was attempted but failed — do not silently fall back to an + // interactive prompt in what is meant to be a non-interactive environment. + console.error( + "\n❌ Client-credentials login failed. Fix the credentials/scopes and retry," + ) + console.error( + " or unset AUTH0_CLIENT_ID/SECRET to use interactive browser login.\n" + ) + process.exit(1) + } + + // No M2M credentials available — fall back to the interactive path. If a + // session is already valid, nothing to do (tenant matching is verified later + // in validateTenant). + const sessionValid = await isSessionValid() + if (sessionValid) { + spinner.succeed("Auth0 CLI session is valid") + return + } + + spinner.warn("Auth0 CLI session appears to be expired or invalid") + + // A corrupted token cannot be refreshed — clear it before an interactive login. + if (await isTokenCorrupted()) { + console.log( + "\n⚠️ The stored Auth0 CLI token is corrupted; clearing it before re-login.\n" + ) + await clearCorruptedToken(domain) + } + + const shouldLogin = await confirmWithUser( + "Would you like to login to Auth0 CLI now?" + ) + + if (!shouldLogin) { + console.error("\n❌ Cannot proceed without a valid Auth0 CLI session.") + console.error(" Please run 'auth0 login' manually and try again.\n") + process.exit(1) + } + + const loginSuccess = await runAuth0Login(domain) + + if (!loginSuccess) { + console.error("\n❌ Login was not successful. Please try again.\n") + process.exit(1) + } + + // Verify the session is now valid + const postLoginValid = await isSessionValid() + if (!postLoginValid) { + console.error("\n❌ Session validation failed after login.") + console.error( + " Please check your Auth0 CLI configuration and try again.\n" + ) + process.exit(1) + } + + console.log("\n✅ Successfully logged in to Auth0 CLI\n") +} + +/** + * Switch to a different tenant using `auth0 tenants use` + * @param {string} tenantName - Tenant domain to switch to + * @returns {Promise} True if the switch was successful + */ +async function switchToTenant(tenantName) { + const spinner = ora({ + text: `Switching to tenant: ${tenantName}`, + }).start() + + try { + await $({ timeout: CLI_TIMEOUT })`auth0 tenants use ${tenantName} --no-input` + spinner.succeed(`Switched to tenant: ${tenantName}`) + return true + } catch { + spinner.fail(`Failed to switch to tenant: ${tenantName}`) + return false + } +} + +/** + * Validate tenant configuration. If the requested tenant does not match the + * active CLI tenant, the script offers to switch to it (or login to it) and + * then retries — instead of hard-failing. * @param {string} tenantName - Required tenant name from command line argument */ export async function validateTenant(tenantName) { @@ -55,33 +610,92 @@ export async function validateTenant(tenantName) { }).start() try { - const tenantSettingsArgs = ["tenants", "list", "--csv"] - const { stdout } = await $`auth0 ${tenantSettingsArgs}` + // Get current tenant from CLI + // NOTE: we output CSV here due to a bug in the Auth0 CLI that doesn't + // respect the --json flag: https://github.com/auth0/auth0-cli/pull/1002 + const tenantSettingsArgs = ["tenants", "list", "--csv", "--no-input"] + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${tenantSettingsArgs}` - const cliDomain = stdout + // Parse all available tenants and find the active one + const tenantLines = stdout .split("\n") .slice(1) + .filter((line) => line.trim()) + const availableTenants = tenantLines + .map((line) => line.split(",")[1]?.trim()) + .filter(Boolean) + + // Get the active tenant (marked with →) + const cliDomain = tenantLines .find((line) => line.includes("→")) ?.split(",")[1] ?.trim() if (!cliDomain) { spinner.fail("No active tenant found in Auth0 CLI") - console.error("\n❌ Please login to Auth0 CLI first:") - console.error(" 1. Run: auth0 login") - console.error( - " 2. If you have multiple tenants, run: auth0 tenants use " + console.error("\n❌ No active tenant configured.") + + const shouldLogin = await confirmWithUser( + `Would you like to login to ${tenantName}?` ) + + if (shouldLogin) { + const loginSuccess = await runAuth0Login(tenantName) + if (loginSuccess) { + // Retry tenant validation after login + return validateTenant(tenantName) + } + } + + console.error("\n❌ Cannot proceed without an active tenant.") + console.error(" Please run 'auth0 login' and try again.\n") process.exit(1) } + // Verify the provided tenant name matches the CLI active tenant if (tenantName !== cliDomain) { spinner.fail("Tenant mismatch detected") console.error(`\n❌ Tenant mismatch:`) - console.error(` Requested tenant: ${tenantName}`) - console.error(` CLI is using: ${cliDomain}`) - console.error("\nPlease ensure you're using the correct tenant:") - console.error(` Run: auth0 tenants use ${tenantName}`) + console.error(` Requested tenant: ${tenantName}`) + console.error(` CLI is using: ${cliDomain}`) + + // Check if the requested tenant is in the list of available tenants + const tenantAvailable = availableTenants.includes(tenantName) + + if (tenantAvailable) { + // Tenant exists, offer to switch + console.error(`\n The tenant "${tenantName}" is available in your CLI.`) + const shouldSwitch = await confirmWithUser( + `Would you like to switch to ${tenantName}?` + ) + + if (shouldSwitch) { + const switchSuccess = await switchToTenant(tenantName) + if (switchSuccess) { + // Retry tenant validation after switching + return validateTenant(tenantName) + } + } + } else { + // Tenant not in list, offer to login + console.error( + `\n The tenant "${tenantName}" is not in your CLI's tenant list.` + ) + console.error(` You may need to login to this tenant.`) + const shouldLogin = await confirmWithUser( + `Would you like to login to ${tenantName}?` + ) + + if (shouldLogin) { + const loginSuccess = await runAuth0Login(tenantName) + if (loginSuccess) { + // Retry tenant validation after login + return validateTenant(tenantName) + } + } + } + + console.error("\n❌ Cannot proceed with mismatched tenant.") console.error( "\nThis is a safety measure to prevent accidentally configuring the wrong tenant." ) @@ -91,6 +705,29 @@ export async function validateTenant(tenantName) { spinner.succeed(`Validated tenant: ${cliDomain}`) return cliDomain } catch (e) { + // Handle timeout errors specifically + if (e.timedOut) { + spinner.fail("Auth0 CLI command timed out") + console.error("\n❌ The Auth0 CLI is not responding.") + console.error(" This usually means your session has expired.\n") + + const shouldLogin = await confirmWithUser( + `Would you like to login to ${tenantName}?` + ) + + if (shouldLogin) { + const loginSuccess = await runAuth0Login(tenantName) + if (loginSuccess) { + // Retry tenant validation after login + return validateTenant(tenantName) + } + } + + console.error("\n❌ Cannot proceed without a valid session.") + console.error(" Please run 'auth0 login' and try again.\n") + process.exit(1) + } + spinner.fail("Failed to validate tenant") console.error(e) process.exit(1) @@ -98,8 +735,58 @@ export async function validateTenant(tenantName) { } /** - * Validate iOS project structure and extract configuration - * @returns {{ bundleIdentifier: string, auth0PlistPath: string }} + * Warn (softly) if the tenant's My Account API is missing MFA scopes required + * by the sample app. This is informational only — the bootstrap can still + * create/enable the My Account API, and the missing scopes typically require + * Auth0 support to enable on the tenant. + * @param {object} resources - Discovered resources from the tenant + * @param {string} domain - The tenant domain + */ +export function validateMyAccountScopes(resources, domain) { + const spinner = ora({ + text: `Validating My Account API scopes`, + }).start() + + const myAccountApi = resources.resourceServers.find( + (rs) => rs.identifier === `https://${domain}/me/` + ) + + // If the API doesn't exist yet, the bootstrap will create it — nothing to warn about. + if (!myAccountApi) { + spinner.info( + "My Account API not found — it will be created during bootstrap" + ) + return + } + + const availableScopes = myAccountApi.scopes?.map((s) => s.value) || [] + const missingScopes = MY_ACCOUNT_API_SCOPES.filter( + (scope) => !availableScopes.includes(scope) + ) + + if (missingScopes.length > 0) { + spinner.warn("Some My Account API scopes are not available on this tenant") + console.log("") + console.log("⚠️ My Account API") + console.log(` Missing scope(s):`) + missingScopes.forEach((scope) => console.log(` - ${scope}`)) + console.log( + " Suggestion: Contact Auth0 support to enable these scopes on your tenant." + ) + console.log( + " The bootstrap will continue with the scopes that are available.\n" + ) + return + } + + spinner.succeed("My Account API scopes are available") +} + +/** + * Validate iOS project structure and extract configuration. + * The generated Auth0.plist is written into the runnable sample app target + * (AppUIComponents), which is where the SDK loads it from the main bundle. + * @returns {{ bundleIdentifier: string, auth0PlistPath: string, infoPlistPath: string }} */ export function validateIOSProject() { const spinner = ora({ @@ -107,9 +794,16 @@ export function validateIOSProject() { }).start() const projectRoot = path.resolve(process.cwd(), "..") - const xcodeProjectPath = path.join(projectRoot, "Auth0UniversalComponents.xcodeproj") + const xcodeProjectPath = path.join( + projectRoot, + "Auth0UniversalComponents.xcodeproj" + ) const pbxprojPath = path.join(xcodeProjectPath, "project.pbxproj") - const auth0PlistPath = path.join(projectRoot, "Auth0UniversalComponents", "Auth0.plist") + // The sample app (AppUIComponents) is the runnable target and the bundle the + // SDK reads Auth0.plist / Info.plist from. + const appTargetDir = path.join(projectRoot, "AppUIComponents") + const auth0PlistPath = path.join(appTargetDir, "Auth0.plist") + const infoPlistPath = path.join(appTargetDir, "Info.plist") // Check Xcode project exists if (!fs.existsSync(xcodeProjectPath)) { @@ -133,7 +827,9 @@ export function validateIOSProject() { ) if (!bundleIdMatch) { - spinner.fail("Could not extract PRODUCT_BUNDLE_IDENTIFIER from project.pbxproj") + spinner.fail( + "Could not extract PRODUCT_BUNDLE_IDENTIFIER from project.pbxproj" + ) process.exit(1) } @@ -146,9 +842,7 @@ export function validateIOSProject() { ? "com.auth0.AppUIComponents" : bundleIdMatch[1].trim() - spinner.succeed( - `Validated iOS project (bundle: ${bundleIdentifier})` - ) + spinner.succeed(`Validated iOS project (bundle: ${bundleIdentifier})`) - return { bundleIdentifier, auth0PlistPath } + return { bundleIdentifier, auth0PlistPath, infoPlistPath } }