From 8fd69198ced68ac77d33bab5d1df508736844c58 Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Thu, 15 Oct 2026 18:54:22 +0530 Subject: [PATCH 1/3] feat(quickstart): enhance project detection for new frameworks and build tools --- docs/auth0_quickstarts_setup.md | 41 +- internal/auth0/quickstart.go | 98 ++ internal/auth0/quickstarts_test.go | 106 +++ internal/cli/quickstart_detect.go | 49 +- internal/cli/quickstart_detect_test.go | 312 ++++++- internal/cli/quickstarts.go | 675 +++++++++++-- internal/cli/quickstarts_test.go | 1196 +++++++++++++++++++++++- internal/display/quickstarts.go | 82 ++ 8 files changed, 2418 insertions(+), 141 deletions(-) diff --git a/docs/auth0_quickstarts_setup.md b/docs/auth0_quickstarts_setup.md index 788657d10..9f9d82b53 100644 --- a/docs/auth0_quickstarts_setup.md +++ b/docs/auth0_quickstarts_setup.md @@ -37,29 +37,36 @@ auth0 quickstarts setup [flags] # API with custom settings: auth0 quickstarts setup --api --linked-app-id --identifier https://my-api --scopes "read:data,write:data" + + # API + new app, granting a subset of the API scopes, with a JSON result: + auth0 quickstarts setup --api --app --type regular --framework express --identifier https://my-api --scopes "read:data,write:data" --grant-scopes read:data --json ``` ## Flags ``` - --api Create an Auth0 API resource server - --app Create an Auth0 application (SPA, regular web, or native) - --audience string Unique URL identifier for the API (audience), e.g. https://my-api - --build-tool string Build tool used by the project (vite, webpack, cra, none) (default "none") - --callback-url string Override the allowed callback URL for the application - --framework string Framework to configure (e.g., react, nextjs, vue, express) - --identifier string Unique URL identifier for the API (audience), e.g. https://my-api - --linked-app-id string [API] Client ID of an existing application to link to the API (skips app creation) - --logout-url string Override the allowed logout URL for the application - --name string Name of the Auth0 application - --offline-access Allow offline access (enables refresh tokens) - --port int Local port the application runs on (default varies by framework, e.g. 3000, 5173) - --scopes string [API] Comma-separated list of permission scopes for the API - --signing-alg string [API] Token signing algorithm: RS256, PS256, or HS256 (leave blank to be prompted interactively) - --token-lifetime string [API] Access token lifetime in seconds (default: 86400 = 24 hours) - --type string Application type: spa, regular, native, or m2m - --web-origin-url string Override the allowed web origin URL for the application + --api Create an Auth0 API resource server + --app Create an Auth0 application (SPA, regular web, or native) + --audience string Unique URL identifier for the API (audience), e.g. https://my-api + --build-tool string Build tool used by the project (composer, gradle, maven, none, vite, webpack) (default "none") + --callback-url strings Override the allowed callback URLs for the application. Comma-separate or repeat the flag for multiple values; replaces the default list. + --force Replace an existing config file without prompting (a backup is written first). In agent mode or with --no-input, an existing .env is merged instead, and any other existing config file is only replaced with --force. + --framework string Framework to configure (e.g., react, nextjs, vue, express) + --grant-scopes strings [API] Comma-separated subset of --scopes to grant the app created with --app or given with --linked-app-id (default: all --scopes). Every value must also be listed in --scopes. + --identifier string Unique URL identifier for the API (audience), e.g. https://my-api + --json Output in json format. + --json-compact Output in compact json format. + --linked-app-id string [API] Client ID of an existing application to link to the API (skips app creation) + --logout-url strings Override the allowed logout URLs for the application. Comma-separate or repeat the flag for multiple values; replaces the default list. + --name string Name of the Auth0 application + --offline-access Allow offline access (enables refresh tokens) + --port int Local port the application runs on (default varies by framework, e.g. 3000, 5173) + --scopes string [API] Comma-separated permission scopes the API defines (e.g. read:data,write:data). The app created with --app or given with --linked-app-id is granted all of them unless --grant-scopes is set. + --signing-alg string [API] Token signing algorithm: RS256, PS256, or HS256 (leave blank to be prompted interactively) + --token-lifetime string [API] Access token lifetime in seconds (default: 86400 = 24 hours) + --type string Application type: spa, regular, native, or m2m + --web-origin-url strings Override the allowed web origin URLs for the application. Comma-separate or repeat the flag for multiple values; replaces the default list. ``` diff --git a/internal/auth0/quickstart.go b/internal/auth0/quickstart.go index d8a83bcda..99ad7af8a 100644 --- a/internal/auth0/quickstart.go +++ b/internal/auth0/quickstart.go @@ -277,6 +277,26 @@ func FrameworkBuildToolRequired(typeFramework string) bool { return ok } +// SupportedBuildTools returns the sorted, de-duplicated build-tool segment +// ("type:framework:") of every key in QuickstartConfigs, including "none". +func SupportedBuildTools() []string { + seen := make(map[string]struct{}) + for k := range QuickstartConfigs { + parts := strings.SplitN(k, ":", 3) + if len(parts) != 3 { + continue + } + seen[parts[2]] = struct{}{} + } + + tools := make([]string, 0, len(seen)) + for tool := range seen { + tools = append(tools, tool) + } + sort.Strings(tools) + return tools +} + // FrameworkBundleIDRequired lists native frameworks that need a resolved // bundle/application ID (for example, "com.example.myapp") during project // detection. Keep in sync with DetectProject BundleID assignments. @@ -376,6 +396,36 @@ var QuickstartConfigs = map[string]AppConfig{ Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, AudienceVar: "VITE_AUTH0_AUDIENCE", }, + "spa:react:webpack": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{DetectionSubAsBase}, + AllowedLogoutURLs: []string{DetectionSub}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + AudienceVar: "AUTH0_AUDIENCE", + }, + "spa:vanilla-javascript:webpack": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{DetectionSubAsBase}, + AllowedLogoutURLs: []string{DetectionSub}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + AudienceVar: "AUTH0_AUDIENCE", + }, "spa:flutter-web:none": { EnvValues: map[string]string{ "domain": DetectionSub, @@ -410,6 +460,24 @@ var QuickstartConfigs = map[string]AppConfig{ Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, AudienceVar: "AUTH0_AUDIENCE", }, + "regular:tanstack-start:none": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + "AUTH0_SECRET": DetectionSub, + "APP_BASE_URL": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + CallbackPath: "/auth/callback", + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + AudienceVar: "AUTH0_AUDIENCE", + }, "regular:nuxt:none": { EnvValues: map[string]string{ "NUXT_AUTH0_DOMAIN": DetectionSub, @@ -618,6 +686,36 @@ var QuickstartConfigs = map[string]AppConfig{ }, Strategy: FileOutputStrategy{Path: "src/main/resources/application.yml", Format: "yaml"}, }, + "regular:java-mvc:maven": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + CallbackPath: "/callback", + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:java-mvc:gradle": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + CallbackPath: "/callback", + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, "regular:aspnet-mvc:none": { EnvValues: map[string]string{ "Auth0:Domain": DetectionSub, diff --git a/internal/auth0/quickstarts_test.go b/internal/auth0/quickstarts_test.go index 0fe3f2489..f1b5ecd90 100644 --- a/internal/auth0/quickstarts_test.go +++ b/internal/auth0/quickstarts_test.go @@ -2,9 +2,11 @@ package auth0 import ( "fmt" + "sort" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var mockQuickStarts = Quickstarts{ @@ -71,3 +73,107 @@ func TestFindByStack(t *testing.T) { assert.Equal(t, fmt.Sprintf("failed to find any quickstarts for stack: %q", "some-non-existent-qs-type"), err.Error()) }) } + +func TestSupportedBuildTools(t *testing.T) { + got := SupportedBuildTools() + assert.Equal(t, []string{"composer", "gradle", "maven", "none", "vite", "webpack"}, got) + assert.True(t, sort.StringsAreSorted(got)) + assert.NotContains(t, got, "cra") + + // Every returned tool is the third segment of at least one config key. + for _, tool := range got { + found := false + for key := range QuickstartConfigs { + if len(key) > len(tool) && key[len(key)-len(tool)-1:] == ":"+tool { + found = true + break + } + } + assert.True(t, found, "build tool %q is not used by any QuickstartConfigs key", tool) + } +} + +func TestFrameworkSupportedBuildTools_WebpackVariants(t *testing.T) { + assert.Equal(t, []string{"vite", "webpack"}, FrameworkSupportedBuildTools["spa:react"]) + assert.Equal(t, []string{"vite", "webpack"}, FrameworkSupportedBuildTools["spa:vanilla-javascript"]) + assert.Equal(t, []string{"vite", "webpack"}, FrameworkBuildTools["spa:react"], "react has no :none variant") + assert.True(t, FrameworkBuildToolRequired("spa:react")) + assert.False(t, FrameworkBuildToolRequired("regular:tanstack-start")) + assert.Equal(t, []string{"none"}, FrameworkBuildTools["regular:tanstack-start"]) +} + +func TestJavaMVCQuickstartConfigs(t *testing.T) { + assert.Equal(t, []string{"gradle", "maven"}, FrameworkBuildTools["regular:java-mvc"]) + for _, key := range []string{"regular:java-mvc:maven", "regular:java-mvc:gradle"} { + t.Run(key, func(t *testing.T) { + cfg, ok := QuickstartConfigs[key] + require.True(t, ok, "missing config %q", key) + assert.Equal(t, FileOutputStrategy{Path: ".env", Format: "dotenv"}, cfg.Strategy) + assert.Equal(t, "regular_web", cfg.RequestParams.AppType) + assert.Equal(t, "/callback", cfg.RequestParams.CallbackPath) + assert.Empty(t, cfg.RequestParams.WebOrigins) + keys := make([]string, 0, len(cfg.EnvValues)) + for k := range cfg.EnvValues { + keys = append(keys, k) + } + sort.Strings(keys) + assert.Equal(t, []string{"AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_DOMAIN"}, keys) + }) + } +} + +func TestNewQuickstartConfigs(t *testing.T) { + tests := []struct { + key string + wantAppType string + wantCallbackPath string + wantWebOrigins bool + wantEnvKeys []string + }{ + {"spa:react:webpack", "spa", "", true, []string{"AUTH0_CLIENT_ID", "AUTH0_DOMAIN"}}, + {"spa:vanilla-javascript:webpack", "spa", "", true, []string{"AUTH0_CLIENT_ID", "AUTH0_DOMAIN"}}, + {"regular:tanstack-start:none", "regular_web", "/auth/callback", false, + []string{"APP_BASE_URL", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_DOMAIN", "AUTH0_SECRET"}}, + } + + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + cfg, ok := QuickstartConfigs[tc.key] + require.True(t, ok, "missing config %q", tc.key) + + assert.Equal(t, FileOutputStrategy{Path: ".env", Format: "dotenv"}, cfg.Strategy) + assert.Equal(t, "AUTH0_AUDIENCE", cfg.AudienceVar) + assert.Equal(t, tc.wantAppType, cfg.RequestParams.AppType) + assert.Equal(t, tc.wantCallbackPath, cfg.RequestParams.CallbackPath) + assert.Equal(t, DetectionSub, cfg.RequestParams.Name) + assert.Equal(t, []string{DetectionSub}, cfg.RequestParams.AllowedLogoutURLs) + if tc.wantWebOrigins { + assert.Equal(t, []string{DetectionSubAsBase}, cfg.RequestParams.Callbacks, "SPA callback is the bare base URL") + assert.Equal(t, []string{DetectionSub}, cfg.RequestParams.WebOrigins) + } else { + assert.Equal(t, []string{DetectionSub}, cfg.RequestParams.Callbacks) + assert.Empty(t, cfg.RequestParams.WebOrigins) + } + + keys := make([]string, 0, len(cfg.EnvValues)) + for k, v := range cfg.EnvValues { + keys = append(keys, k) + assert.Equal(t, DetectionSub, v, "env %q must be resolved at setup time", k) + } + sort.Strings(keys) + assert.Equal(t, tc.wantEnvKeys, keys) + }) + } + + t.Run("webpack configs mirror their vite counterparts' RequestParams", func(t *testing.T) { + assert.Equal(t, QuickstartConfigs["spa:react:vite"].RequestParams, QuickstartConfigs["spa:react:webpack"].RequestParams) + assert.Equal(t, QuickstartConfigs["spa:vanilla-javascript:vite"].RequestParams, QuickstartConfigs["spa:vanilla-javascript:webpack"].RequestParams) + }) + + t.Run("out-of-scope configs are absent", func(t *testing.T) { + for _, key := range []string{"spa:react:cra", "regular:java-mvc:none"} { + _, ok := QuickstartConfigs[key] + assert.False(t, ok, "%q must not exist", key) + } + }) +} diff --git a/internal/cli/quickstart_detect.go b/internal/cli/quickstart_detect.go index d2f82d0b8..c3e114bb3 100644 --- a/internal/cli/quickstart_detect.go +++ b/internal/cli/quickstart_detect.go @@ -14,7 +14,7 @@ import ( type DetectionResult struct { Framework string Type string // "spa" | "regular" | "native". - BuildTool string // "vite" | "maven" | "gradle" | "composer" | "" (NA). + BuildTool string // "vite" | "webpack" | "maven" | "gradle" | "composer" | "" (NA). BundleID string // Package/bundle ID for native apps (e.g. "com.example.myapp"); empty if not found. Detected bool // True if any signal file matched. AmbiguousFrameworks []string // Set when >1 package.json dep matched. @@ -129,6 +129,14 @@ func DetectProject(dir string) DetectionResult { return result } + // -- 7b. TanStack Start (@tanstack/react-start dep) — Before vite.config (TanStack Start uses Vite + react). + if hasDep(earlyDeps, "@tanstack/react-start") { + result.Framework = "tanstack-start" + result.Type = "regular" + result.Detected = true + return result + } + // -- 8. Vite.config.[ts|js] + package.json deps --. if fileExistsAny(dir, "vite.config.ts", "vite.config.js") { result.Type = "spa" @@ -179,6 +187,19 @@ func DetectProject(dir string) DetectionResult { return result } + // -- 11b. Webpack.config.[js|ts|cjs|mjs] (no vite config — the vite rule ran first). + if fileExistsAny(dir, "webpack.config.js", "webpack.config.ts", "webpack.config.cjs", "webpack.config.mjs") { + result.Type = "spa" + result.BuildTool = "webpack" + result.Detected = true + if hasDep(earlyDeps, "react") { + result.Framework = "react" + } else { + result.Framework = "vanilla-javascript" + } + return result + } + // -- 12. .csproj --. if content, ok := findCsprojContent(dir); ok { if fw, qsType, found := detectFromCsproj(content); found { @@ -334,21 +355,31 @@ func detectFromCsproj(content string) (framework, qsType string, found bool) { func detectJavaFramework(content string) string { lower := strings.ToLower(content) switch { - case strings.Contains(lower, "spring-boot") || - strings.Contains(lower, "springframework.boot"): + case strings.Contains(lower, "mvc-auth-commons"): + return "java-mvc" + case containsAnyOf(lower, "spring-boot", "springframework.boot"): return "spring-boot" - case strings.Contains(lower, "javax.ee") || - strings.Contains(lower, "jakarta.ee") || - strings.Contains(lower, "javax.servlet") || - strings.Contains(lower, "jakarta.servlet") || - // Jakarta.platform:jakarta.jakartaee-api is the standard BOM for Jakarta EE 9+. - strings.Contains(lower, "jakarta.platform"): + // Full Java EE / Jakarta EE platform or EE-only APIs (the Jakarta EE 9+ BOM is + // jakarta.platform:jakarta.jakartaee-api). + case containsAnyOf(lower, "javaee-api", "jakartaee-api", "jakarta.platform", "javax.ee", "jakarta.ee", + "security.enterprise", "microprofile"): return "java-ee" + case containsAnyOf(lower, "javax.servlet", "jakarta.servlet"): + return "java-mvc" default: return "vanilla-java" } } +func containsAnyOf(s string, substrings ...string) bool { + for _, sub := range substrings { + if strings.Contains(s, sub) { + return true + } + } + return false +} + func isExpoProject(dir string) bool { data, err := os.ReadFile(filepath.Join(dir, "app.json")) if err != nil { diff --git a/internal/cli/quickstart_detect_test.go b/internal/cli/quickstart_detect_test.go index b491e0a21..5036daeb4 100644 --- a/internal/cli/quickstart_detect_test.go +++ b/internal/cli/quickstart_detect_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "io" "os" "path/filepath" @@ -405,14 +406,14 @@ func TestDetectProject_VanillaJava_Maven(t *testing.T) { assert.Equal(t, "maven", got.BuildTool) } -// Auth0 qs setup --app --type regular --framework java-ee. -func TestDetectProject_JavaEE_JaxServlet(t *testing.T) { +// A plain servlet app (servlet API only, no Auth0 SDK) is detected as java-mvc. +func TestDetectProject_JavaMVC_JaxServletOnly(t *testing.T) { dir := t.TempDir() writeTestFile(t, dir, "pom.xml", `javax.servlet`) got := DetectProject(dir) assert.True(t, got.Detected) - assert.Equal(t, "java-ee", got.Framework) + assert.Equal(t, "java-mvc", got.Framework) assert.Equal(t, "regular", got.Type) assert.Equal(t, "maven", got.BuildTool) } @@ -425,12 +426,12 @@ func TestDetectProject_JavaEE_JakartaEE(t *testing.T) { assert.Equal(t, "java-ee", got.Framework) } -func TestDetectProject_JavaEE_JakartaServlet(t *testing.T) { +func TestDetectProject_JavaMVC_JakartaServletOnly(t *testing.T) { dir := t.TempDir() writeTestFile(t, dir, "pom.xml", `jakarta.servlet`) got := DetectProject(dir) - assert.Equal(t, "java-ee", got.Framework) + assert.Equal(t, "java-mvc", got.Framework) } func TestDetectProject_JavaEE_JaxEE(t *testing.T) { @@ -462,6 +463,34 @@ func TestDetectProject_SpringBoot_Gradle(t *testing.T) { assert.Equal(t, "gradle", got.BuildTool) } +// Auth0 qs setup --app --type regular --framework java-mvc (servlet app with mvc-auth-commons). +func TestDetectProject_JavaMVC_Maven(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", ` +javax.servletjavax.servlet-api +com.auth0mvc-auth-commons1.12.0 +`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "java-mvc", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "maven", got.BuildTool) +} + +func TestDetectProject_JavaMVC_Gradle(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "build.gradle", `dependencies { + implementation 'javax.servlet:javax.servlet-api:4.0.1' + implementation 'com.auth0:mvc-auth-commons:1.12.0' +}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "java-mvc", got.Framework) + assert.Equal(t, "gradle", got.BuildTool) +} + func TestDetectProject_VanillaJava_GradleKts(t *testing.T) { dir := t.TempDir() writeTestFile(t, dir, "build.gradle.kts", `plugins { java }`) @@ -1093,15 +1122,48 @@ func TestDetectJavaFramework(t *testing.T) { wantFw: "java-ee", }, { - name: "javax_servlet", - content: `javax.servlet`, + name: "javax_servlet_only", + content: `javax.servletjavax.servlet-api`, + wantFw: "java-mvc", + }, + { + name: "jakarta_servlet_only", + content: `jakarta.servletjakarta.servlet-api`, + wantFw: "java-mvc", + }, + { + name: "servlet_only_gradle", + content: `dependencies { compileOnly 'jakarta.servlet:jakarta.servlet-api:6.0.0' }`, + wantFw: "java-mvc", + }, + { + name: "javaee_api", + content: `javaxjavaee-api`, wantFw: "java-ee", }, { - name: "jakarta_servlet", - content: `jakarta.servlet`, + name: "jakarta_platform_with_servlet_stays_java_ee", + content: `jakarta.servletjakarta.servlet-api +jakarta.platformjakarta.jakartaee-api`, + wantFw: "java-ee", + }, + { + name: "servlet_with_ee_security_api_stays_java_ee", + content: `javax.servlet +javax.security.enterprisejavax.security.enterprise-api`, + wantFw: "java-ee", + }, + { + name: "microprofile", + content: `org.eclipse.microprofile.config`, wantFw: "java-ee", }, + { + name: "mvc_auth_commons_beats_ee_platform", + content: `jakarta.platformjakarta.jakartaee-api +com.auth0mvc-auth-commons`, + wantFw: "java-mvc", + }, { name: "vanilla_java_plain_pom", content: `plain-java`, @@ -1112,6 +1174,23 @@ func TestDetectJavaFramework(t *testing.T) { content: `implementation("org.springframework.boot:spring-boot-starter-web")`, wantFw: "spring-boot", }, + { + name: "java_mvc_pom", + content: `com.auth0mvc-auth-commons1.12.0`, + wantFw: "java-mvc", + }, + { + name: "java_mvc_gradle", + content: `implementation 'com.auth0:mvc-auth-commons:1.12.0'`, + wantFw: "java-mvc", + }, + { + // Servlet apps always declare the servlet API; mvc-auth-commons must win over java-ee. + name: "java_mvc_beats_servlet_dependency", + content: `javax.servletjavax.servlet-api +com.auth0mvc-auth-commons`, + wantFw: "java-mvc", + }, } for _, tc := range tests { @@ -1219,6 +1298,7 @@ func TestDefaultPortForFramework(t *testing.T) { {"spring-boot", 3000}, {"java-ee", 8080}, {"vanilla-java", 8080}, + {"java-mvc", 3000}, // Regular - default 3000. {"nextjs", 3000}, {"nuxt", 3000}, @@ -1226,6 +1306,7 @@ func TestDefaultPortForFramework(t *testing.T) { {"fastify", 3000}, {"hono", 3000}, {"sveltekit", 5173}, + {"tanstack-start", 3000}, {"rails", 3000}, {"vanilla-go", 3000}, {"django", 8000}, @@ -1264,6 +1345,7 @@ func TestFrameworksForType(t *testing.T) { t.Run("regular", func(t *testing.T) { fws := frameworksForType("regular") assert.Contains(t, fws, "nextjs") + assert.Contains(t, fws, "tanstack-start") assert.Contains(t, fws, "nuxt") assert.Contains(t, fws, "fastify") assert.Contains(t, fws, "sveltekit") @@ -1275,6 +1357,7 @@ func TestFrameworksForType(t *testing.T) { assert.Contains(t, fws, "vanilla-java") assert.Contains(t, fws, "java-ee") assert.Contains(t, fws, "spring-boot") + assert.Contains(t, fws, "java-mvc") assert.Contains(t, fws, "aspnet-mvc") assert.Contains(t, fws, "aspnet-blazor") assert.Contains(t, fws, "aspnet-owin") @@ -1313,6 +1396,7 @@ func TestGetQuickstartConfigKey(t *testing.T) { wantKey string wantBuildTool string wantAutoSelect bool + wantPort int // Asserted only when non-zero. }{ // SPA . // Auth0 qs setup --app --type spa --framework react --build-tool vite. @@ -1329,6 +1413,38 @@ func TestGetQuickstartConfigKey(t *testing.T) { wantBuildTool: "vite", wantAutoSelect: true, }, + // Auth0 qs setup --app --type spa --framework react --build-tool webpack. + { + name: "spa react webpack", + inputs: SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "webpack"}, + wantKey: "spa:react:webpack", + wantBuildTool: "webpack", + wantPort: 8080, // Webpack-dev-server default, resolved after the build tool. + }, + // Auth0 qs setup --app --type spa --framework vanilla-javascript --build-tool webpack. + { + name: "spa vanilla-javascript webpack", + inputs: SetupInputs{App: true, Type: "spa", Framework: "vanilla-javascript", BuildTool: "webpack"}, + wantKey: "spa:vanilla-javascript:webpack", + wantBuildTool: "webpack", + wantPort: 8080, + }, + // React now has two build tools (vite, webpack); "none" must still auto-select vite (first, sorted). + { + name: "spa react build-tool none auto-selects vite with port 5173", + inputs: SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "none"}, + wantKey: "spa:react:vite", + wantBuildTool: "vite", + wantAutoSelect: true, + wantPort: 5173, + }, + { + name: "spa vanilla-javascript build-tool none auto-selects vite", + inputs: SetupInputs{App: true, Type: "spa", Framework: "vanilla-javascript", BuildTool: "none"}, + wantKey: "spa:vanilla-javascript:vite", + wantBuildTool: "vite", + wantAutoSelect: true, + }, // Auth0 qs setup --app --type spa --framework angular. { name: "spa angular none", @@ -1373,6 +1489,14 @@ func TestGetQuickstartConfigKey(t *testing.T) { wantKey: "regular:nextjs:none", wantBuildTool: "none", }, + // Auth0 qs setup --app --type regular --framework tanstack-start. + { + name: "regular tanstack-start none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "tanstack-start", BuildTool: "none"}, + wantKey: "regular:tanstack-start:none", + wantBuildTool: "none", + wantPort: 3000, + }, // Auth0 qs setup --app --type regular --framework nuxt. { name: "regular nuxt none", @@ -1436,6 +1560,19 @@ func TestGetQuickstartConfigKey(t *testing.T) { wantKey: "regular:vanilla-java:maven", wantBuildTool: "maven", }, + // Auth0 qs setup --app --type regular --framework java-mvc --build-tool maven|gradle. + { + name: "regular java-mvc maven", + inputs: SetupInputs{App: true, Type: "regular", Framework: "java-mvc", BuildTool: "maven", Port: 3000}, + wantKey: "regular:java-mvc:maven", + wantBuildTool: "maven", + }, + { + name: "regular java-mvc gradle", + inputs: SetupInputs{App: true, Type: "regular", Framework: "java-mvc", BuildTool: "gradle", Port: 3000}, + wantKey: "regular:java-mvc:gradle", + wantBuildTool: "gradle", + }, // Auth0 qs setup --app --type regular --framework java-ee. { name: "regular java-ee maven", @@ -1575,6 +1712,9 @@ func TestGetQuickstartConfigKey(t *testing.T) { if tc.inputs.App { assert.Equal(t, tc.wantBuildTool, tc.inputs.BuildTool) } + if tc.wantPort != 0 { + assert.Equal(t, tc.wantPort, tc.inputs.Port) + } }) } } @@ -2324,9 +2464,12 @@ func TestGetSupportedQuickstartTypes(t *testing.T) { "spa:vue:vite", "spa:svelte:vite", "spa:vanilla-javascript:vite", + "spa:react:webpack", + "spa:vanilla-javascript:webpack", "spa:flutter-web:none", // Regular. "regular:nextjs:none", + "regular:tanstack-start:none", "regular:nuxt:none", "regular:fastify:none", "regular:sveltekit:none", @@ -2437,11 +2580,13 @@ func TestSetupQuickstartCmd_SPAFrameworks(t *testing.T) { {"vue", "vite", "5173"}, {"svelte", "vite", "5173"}, {"vanilla-javascript", "vite", "5173"}, + {"react", "webpack", "8080"}, + {"vanilla-javascript", "webpack", "8080"}, {"flutter-web", "none", "3000"}, } for _, tc := range spaTests { - t.Run(tc.framework, func(t *testing.T) { + t.Run(tc.framework+"-"+tc.buildTool, func(t *testing.T) { cliObj := &cli{} cmd := setupQuickstartCmd(cliObj) cmd.SetArgs([]string{ @@ -2469,6 +2614,7 @@ func TestSetupQuickstartCmd_RegularFrameworks(t *testing.T) { port string }{ {"nextjs", "none", "3000"}, + {"tanstack-start", "none", "3000"}, {"nuxt", "none", "3000"}, {"fastify", "none", "3000"}, {"sveltekit", "none", "3000"}, @@ -3029,3 +3175,149 @@ func TestReadIOSBundleID_RootXcodeproj(t *testing.T) { `PRODUCT_BUNDLE_IDENTIFIER = com.example.native;`) assert.Equal(t, "com.example.native", readIOSBundleID(dir)) } + +// -- DetectProject - webpack SPAs (#2) --. + +// Auth0 qs setup --app --type spa --framework react --build-tool webpack. +func TestDetectProject_ReactWebpack(t *testing.T) { + for _, cfg := range []string{"webpack.config.js", "webpack.config.ts", "webpack.config.cjs", "webpack.config.mjs"} { + t.Run(cfg, func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, cfg, "") + writeTestFile(t, dir, "package.json", `{"name":"my-app","dependencies":{"react":"^18","react-dom":"^18"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "spa", got.Type) + assert.Equal(t, "react", got.Framework) + assert.Equal(t, "webpack", got.BuildTool) + }) + } +} + +// Auth0 qs setup --app --type spa --framework vanilla-javascript --build-tool webpack. +func TestDetectProject_VanillaJavaScriptWebpack(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "webpack.config.js", "") + writeTestFile(t, dir, "package.json", `{"name":"my-app","devDependencies":{"webpack":"^5"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "spa", got.Type) + assert.Equal(t, "vanilla-javascript", got.Framework) + assert.Equal(t, "webpack", got.BuildTool) +} + +// The vite.config rule runs before the webpack rule. +func TestDetectProject_ViteBeatsWebpack(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "webpack.config.js", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"react":"^18"}}`) + + got := DetectProject(dir) + assert.Equal(t, "react", got.Framework) + assert.Equal(t, "vite", got.BuildTool) +} + +// D4: legacy Expo-web projects ship webpack.config.js; the Expo rule must win. +func TestDetectProject_ExpoWithWebpackConfigStaysExpo(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"name":"my-app","slug":"my-app"}}`) + writeTestFile(t, dir, "webpack.config.js", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"expo":"~49.0.0","react":"18.2.0","react-native":"0.72.0"}}`) + + got := DetectProject(dir) + assert.Equal(t, "expo", got.Framework) + assert.Equal(t, "native", got.Type) + assert.NotEqual(t, "webpack", got.BuildTool) +} + +// Next.js config wins over a webpack config. +func TestDetectProject_NextJSWithWebpackConfigStaysNextJS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "next.config.js", "") + writeTestFile(t, dir, "webpack.config.js", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"next":"^14","react":"^18"}}`) + + got := DetectProject(dir) + assert.Equal(t, "nextjs", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// -- DetectProject - TanStack Start (#7) --. + +// Auth0 qs setup --app --type regular --framework tanstack-start. +func TestDetectProject_TanStackStart(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@tanstack/react-start":"^1.120.0","react":"^19"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "tanstack-start", got.Framework) + assert.Empty(t, got.BuildTool, "tanstack-start must not set a build tool (key is :none)") + assert.Empty(t, got.AmbiguousFrameworks) +} + +// TanStack Start projects have vite.config + react; the TanStack rule must run first. +func TestDetectProject_TanStackStartBeatsVite(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"@tanstack/react-start":"^1.120.0","react":"^19","react-dom":"^19"}}`) + + got := DetectProject(dir) + require.True(t, got.Detected) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "tanstack-start", got.Framework) + assert.Empty(t, got.BuildTool) + + inputs := SetupInputs{App: true} + applyDetectionToInputs(&inputs, got) + inputs.Framework = got.Framework + key, _, err := getQuickstartConfigKey(&cobra.Command{}, &inputs) + require.NoError(t, err) + assert.Equal(t, "regular:tanstack-start:none", key) + _, ok := auth0.QuickstartConfigs[key] + assert.True(t, ok) + assert.Equal(t, 3000, inputs.Port) +} + +// -- handleProjectDetection - multiple build tools (react/vanilla-js: vite + webpack) --. + +func TestHandleProjectDetection_MultipleBuildTools_NoInputPicksFirst(t *testing.T) { + for _, fw := range []string{"react", "vanilla-javascript"} { + t.Run(fw, func(t *testing.T) { + t.Chdir(t.TempDir()) + + msgs := &bytes.Buffer{} + c := &cli{renderer: &display.Renderer{MessageWriter: msgs, ResultWriter: io.Discard}} + cmd := setupQuickstartCmd(c) + require.NoError(t, cmd.ParseFlags([]string{"--app", "--type", "spa", "--framework", fw})) + + inputs := SetupInputs{App: true, Type: "spa", Framework: fw, BuildTool: "none"} + require.NoError(t, handleProjectDetection(cmd, c, &inputs, false)) + + assert.Equal(t, "vite", inputs.BuildTool) + assert.Contains(t, msgs.String(), "Multiple build tools available") + assert.Contains(t, msgs.String(), "--build-tool") + }) + } +} + +// An explicit --build-tool webpack is never overridden by the auto-select. +func TestHandleProjectDetection_ExplicitWebpackKept(t *testing.T) { + t.Chdir(t.TempDir()) + + c := &cli{renderer: &display.Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard}} + cmd := setupQuickstartCmd(c) + require.NoError(t, cmd.ParseFlags([]string{"--app", "--type", "spa", "--framework", "react", "--build-tool", "webpack"})) + + inputs := SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "webpack"} + require.NoError(t, handleProjectDetection(cmd, c, &inputs, false)) + assert.Equal(t, "webpack", inputs.BuildTool) +} + +func TestFrameworkDisplayName_TanStackStart(t *testing.T) { + assert.Equal(t, "TanStack Start", frameworkDisplayName("tanstack-start")) +} diff --git a/internal/cli/quickstarts.go b/internal/cli/quickstarts.go index 2a1ba9180..a11ff1059 100644 --- a/internal/cli/quickstarts.go +++ b/internal/cli/quickstarts.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" _ "embed" "encoding/json" @@ -24,6 +25,7 @@ import ( "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/prompt" ) @@ -449,7 +451,7 @@ var ( setupBuildTool = Flag{ Name: "Build Tool", LongForm: "build-tool", - Help: "Build tool used by the project (vite, webpack, cra, none)", + Help: "Build tool used by the project (" + strings.Join(auth0.SupportedBuildTools(), ", ") + ")", } setupPort = Flag{ Name: "Port", @@ -459,17 +461,17 @@ var ( setupCallbackURL = Flag{ Name: "Callback URL", LongForm: "callback-url", - Help: "Override the allowed callback URL for the application", + Help: "Override the allowed callback URLs for the application. Comma-separate or repeat the flag for multiple values; replaces the default list.", } setupLogoutURL = Flag{ Name: "Logout URL", LongForm: "logout-url", - Help: "Override the allowed logout URL for the application", + Help: "Override the allowed logout URLs for the application. Comma-separate or repeat the flag for multiple values; replaces the default list.", } setupWebOriginURL = Flag{ Name: "Web Origin URL", LongForm: "web-origin-url", - Help: "Override the allowed web origin URL for the application", + Help: "Override the allowed web origin URLs for the application. Comma-separate or repeat the flag for multiple values; replaces the default list.", } setupAPI = Flag{ Name: "API", @@ -490,7 +492,12 @@ var ( setupScopes = Flag{ Name: "Scopes", LongForm: "scopes", - Help: "[API] Comma-separated list of permission scopes for the API", + Help: "[API] Comma-separated permission scopes the API defines (e.g. read:data,write:data). The app created with --app or given with --linked-app-id is granted all of them unless --grant-scopes is set.", + } + setupGrantScopes = Flag{ + Name: "Grant Scopes", + LongForm: "grant-scopes", + Help: "[API] Comma-separated subset of --scopes to grant the app created with --app or given with --linked-app-id (default: all --scopes). Every value must also be listed in --scopes.", } setupTokenLifetime = Flag{ Name: "Token Lifetime", @@ -518,13 +525,14 @@ type SetupInputs struct { BuildTool string Port int BundleID string // Package/bundle ID for native apps, populated from detection. - CallbackURL string - LogoutURL string - WebOriginURL string + CallbackURLs []string + LogoutURLs []string + WebOriginURLs []string API bool Identifier string SigningAlg string Scopes string + GrantScopes []string // Nil = default to Scopes; non-nil (even empty) = explicit. TokenLifetime string OfflineAccess bool LinkedAppID string // Client ID of an existing app to link to the API. @@ -560,7 +568,10 @@ Workflows: auth0 quickstarts setup --api --linked-app-id --identifier https://my-api # API with custom settings: - auth0 quickstarts setup --api --linked-app-id --identifier https://my-api --scopes "read:data,write:data"`, + auth0 quickstarts setup --api --linked-app-id --identifier https://my-api --scopes "read:data,write:data" + + # API + new app, granting a subset of the API scopes, with a JSON result: + auth0 quickstarts setup --api --app --type regular --framework express --identifier https://my-api --scopes "read:data,write:data" --grant-scopes read:data --json`, RunE: func(cmd *cobra.Command, args []string) error { return runSetupQuickstart(cmd, cli, &inputs) }, @@ -569,6 +580,11 @@ Workflows: // App flags. registerSetupFlags(cmd, &inputs) + cmd.Flags().BoolVar(&cli.force, "force", false, "Replace an existing config file without prompting (a backup is written first). In agent mode or with --no-input, an existing .env is merged instead, and any other existing config file is only replaced with --force.") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + cmd.MarkFlagsMutuallyExclusive("app", "linked-app-id") return cmd @@ -583,15 +599,16 @@ func registerSetupFlags(cmd *cobra.Command, inputs *SetupInputs) { setupFramework.RegisterString(cmd, &inputs.Framework, "") setupBuildTool.RegisterString(cmd, &inputs.BuildTool, "none") setupPort.RegisterInt(cmd, &inputs.Port, 0) - setupCallbackURL.RegisterString(cmd, &inputs.CallbackURL, "") - setupLogoutURL.RegisterString(cmd, &inputs.LogoutURL, "") - setupWebOriginURL.RegisterString(cmd, &inputs.WebOriginURL, "") + setupCallbackURL.RegisterStringSlice(cmd, &inputs.CallbackURLs, nil) + setupLogoutURL.RegisterStringSlice(cmd, &inputs.LogoutURLs, nil) + setupWebOriginURL.RegisterStringSlice(cmd, &inputs.WebOriginURLs, nil) // API flags. setupAPI.RegisterBool(cmd, &inputs.API, false) setupIdentifier.RegisterString(cmd, &inputs.Identifier, "") setupSigningAlg.RegisterString(cmd, &inputs.SigningAlg, "") setupScopes.RegisterString(cmd, &inputs.Scopes, "") + setupGrantScopes.RegisterStringSlice(cmd, &inputs.GrantScopes, nil) setupTokenLifetime.RegisterString(cmd, &inputs.TokenLifetime, "") setupOfflineAccess.RegisterBool(cmd, &inputs.OfflineAccess, false) setupLinkedAppID.RegisterString(cmd, &inputs.LinkedAppID, "") @@ -599,18 +616,26 @@ func registerSetupFlags(cmd *cobra.Command, inputs *SetupInputs) { // runSetupQuickstart is the orchestration entry point for `quickstarts setup`. func runSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error { - ctx := cmd.Context() - - if err := cli.setupWithAuthentication(ctx); err != nil { + if err := cli.setupWithAuthentication(cmd.Context()); err != nil { return fmt.Errorf("authentication required: %w", err) } + return executeSetupQuickstart(cmd, cli, inputs, canPrompt(cmd)) +} + +// executeSetupQuickstart runs every setup step after authentication. +func executeSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs, canPromptFlag bool) error { + ctx := cmd.Context() + result := &display.QuickstartSetupResult{} // Validate that --linked-app-id is only used with --api. if inputs.LinkedAppID != "" && !inputs.API { return fmt.Errorf("--linked-app-id requires --api") } - canPromptFlag := canPrompt(cmd) + inputs.GrantScopes = normalizeScopeSlice(inputs.GrantScopes) + if err := validateGrantScopes(*inputs, setupGrantScopes.IsSet(cmd)); err != nil { + return err + } // -- Step 1: Decide what to create (App / API) --. if err := resolveSetupTargets(inputs, canPromptFlag); err != nil { @@ -640,6 +665,19 @@ func runSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error cli.renderer.Infof("Auto-selected build tool %q for %s/%s", inputs.BuildTool, inputs.Type, inputs.Framework) } + // -- Step 4b: Check the config file before any API call, so a refusal never leaves an orphaned app. + var configPlan *qsConfigWritePlan + if inputs.App { + appConfig, err := lookupQuickstartConfig(qsConfigKey) + if err != nil { + return err + } + configPlan, err = planQuickstartConfigWrite(normalizeStrategy(&appConfig.Strategy), cli.force, canPromptFlag) + if err != nil { + return err + } + } + // -- Step 5: Collect application/API name --. if err = collectName(cmd, inputs); err != nil { return err @@ -663,9 +701,14 @@ func runSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error } } + // -- Step 7b: Confirm replacing an existing config file (interactive only). + if configPlan != nil && configPlan.NeedsConfirm { + confirmQuickstartConfigOverwrite(configPlan) + } + // -- Step 8: Create the Auth0 application client --. if inputs.App { - clientID, err := createQuickstartApp(cmd, cli, *inputs, qsConfigKey) + clientID, err := createQuickstartApp(cmd, cli, *inputs, qsConfigKey, configPlan, result) if err != nil { return err } @@ -675,11 +718,12 @@ func runSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error // -- Step 9: Create the Auth0 API resource server --. if inputs.API { - if err := createQuickstartAPI(ctx, cli, *inputs); err != nil { + if err := createQuickstartAPI(ctx, cli, *inputs, result); err != nil { return err } } + cli.renderer.QuickstartSetup(result) return nil } @@ -975,7 +1019,7 @@ func renderClearDetectionSummary(cli *cli, detection DetectionResult) { } const labelWidth = 9 - cli.renderer.Output("Detected in current directory:") + printDetectionHeader(cli) cli.renderer.Detailf("%-*s : %s", labelWidth, "Framework", frameworkDisplay) cli.renderer.Detailf("%-*s : %s", labelWidth, "App type", detectionFriendlyAppType(detection.Type)) } @@ -990,11 +1034,20 @@ func renderAmbiguousDetectionSummary(cli *cli, detection DetectionResult) { } const labelWidth = 19 - cli.renderer.Output("Detected in current directory:") + printDetectionHeader(cli) cli.renderer.Detailf("%-*s : %s", labelWidth, "Possible frameworks", strings.Join(candidates, ", ")) cli.renderer.Detailf("%-*s : %s", labelWidth, "App type", detectionFriendlyAppType(detection.Type)) } +// printDetectionHeader keeps stdout reserved for the JSON result in JSON / agent mode. +func printDetectionHeader(cli *cli) { + if cli.renderer.Format == display.OutputFormatJSON || cli.renderer.Format == display.OutputFormatJSONCompact { + cli.renderer.Infof("Detected in current directory:") // Stderr; suppressed in agent mode. + return + } + cli.renderer.Output("Detected in current directory:") // Human output unchanged. +} + // validateNonInteractiveRequirements enforces flag requirements that apply only when prompts are disabled. func validateNonInteractiveRequirements(inputs SetupInputs, canPromptFlag bool) error { if canPromptFlag { @@ -1053,11 +1106,11 @@ func printAPIDetails(cli *cli, rs *management.ResourceServer) { } // createQuickstartApp creates an Auth0 application client for the given quickstart config key, -// writes the env config file, and prints setup guidance. It returns the newly created client ID. -func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsConfigKey string) (string, error) { - config, exists := auth0.QuickstartConfigs[qsConfigKey] - if !exists { - return "", usageError{err: fmt.Errorf("unsupported quickstart arguments: %s. Supported types: %v", qsConfigKey, getSupportedQuickstartTypes()), reason: "invalid_flag_value"} +// writes the config file, and prints setup guidance. It returns the newly created client ID. +func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsConfigKey string, plan *qsConfigWritePlan, result *display.QuickstartSetupResult) (string, error) { + config, err := lookupQuickstartConfig(qsConfigKey) + if err != nil { + return "", err } expoScheme := readExpoScheme(inputs.Framework) @@ -1094,12 +1147,32 @@ func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsCon return "", err } - if err := cli.WriteQuickstartConfig(cmd, resolvedEnv, &config.Strategy); err != nil { + cfgOut, err := cli.writeQuickstartConfig(cmd, resolvedEnv, &config.Strategy, plan) + if err != nil { return "", fmt.Errorf("failed to generate config file: %w", err) } printClientDetails(cli, client) - printNativeGuidance(cli, inputs.Framework, expoScheme, nativeBundleID) + steps := printNativeGuidance(cli, inputs.Framework, expoScheme, nativeBundleID) + + if result != nil { + result.App = &display.QuickstartSetupApp{ + ClientID: client.GetClientID(), + Name: client.GetName(), + Type: client.GetAppType(), + Callbacks: client.GetCallbacks(), + LogoutURLs: client.GetAllowedLogoutURLs(), + WebOrigins: client.GetWebOrigins(), + } + result.ConfigFile = cfgOut + if cfgOut.Action == qsConfigSkipped { + result.NextSteps = append(result.NextSteps, + fmt.Sprintf("Create %s manually with your Auth0 configuration (it was not written)", cfgOut.Path)) + } else { + result.KeysWritten = sortedKeys(resolvedEnv) // Names only, never values. + } + result.NextSteps = append(result.NextSteps, steps...) + } return client.GetClientID(), nil } @@ -1153,43 +1226,75 @@ func resolveNativeBundleID(inputs SetupInputs) string { return "" } -// printNativeGuidance prints post-setup callback URL guidance for native frameworks. -func printNativeGuidance(cli *cli, framework, expoScheme, bundleID string) { +type guidanceLine struct { + text string + warn bool +} + +func nativeGuidance(framework, expoScheme, bundleID, tenant string) []guidanceLine { switch framework { case "expo": if expoScheme != "" { - cli.renderer.Infof("Note: exp://localhost:19000 is registered for Expo Go development.") - cli.renderer.Infof("For EAS/production builds, add %s:// to Allowed Callback URLs in the Auth0 Dashboard.", expoScheme) - } else { - cli.renderer.Infof("Note: exp://localhost:19000 is for Expo Go development only.") - cli.renderer.Infof("For EAS/production builds, add your custom scheme URI (e.g., myapp://) to Allowed Callback URLs in the Auth0 Dashboard.") + return []guidanceLine{ + {text: "Note: exp://localhost:19000 is registered for Expo Go development."}, + {text: fmt.Sprintf("For EAS/production builds, add %s:// to Allowed Callback URLs in the Auth0 Dashboard.", expoScheme)}, + } + } + return []guidanceLine{ + {text: "Note: exp://localhost:19000 is for Expo Go development only."}, + {text: "For EAS/production builds, add your custom scheme URI (e.g., myapp://) to Allowed Callback URLs in the Auth0 Dashboard."}, } case "flutter", "react-native": if bundleID != "" { - cli.renderer.Infof("Add these Allowed Callback URLs in the Auth0 Dashboard:") - cli.renderer.Infof(" Android: %s://%s/android/%s/callback", bundleID, cli.tenant, bundleID) - cli.renderer.Infof(" iOS: %s://%s/ios/%s/callback", bundleID, cli.tenant, bundleID) + return []guidanceLine{ + {text: "Add these Allowed Callback URLs in the Auth0 Dashboard:"}, + {text: fmt.Sprintf(" Android: %s://%s/android/%s/callback", bundleID, tenant, bundleID)}, + {text: fmt.Sprintf(" iOS: %s://%s/ios/%s/callback", bundleID, tenant, bundleID)}, + } } case "maui", "dotnet-mobile": if bundleID != "" { - cli.renderer.Infof("Registered %s://callback as the Allowed Callback URL.", bundleID) + return []guidanceLine{ + {text: fmt.Sprintf("Registered %s://callback as the Allowed Callback URL.", bundleID)}, + } } case "ionic-angular", "ionic-react", "ionic-vue": if bundleID != "" { - cli.renderer.Infof("Capacitor app ID: %s", bundleID) - cli.renderer.Infof("http://localhost is registered as the Allowed Callback URL (Capacitor WebView).") - } else { - cli.renderer.Warnf("Could not read Capacitor app ID. Ensure capacitor.config.json or capacitor.config.ts is present in your project root.") - cli.renderer.Infof("http://localhost is registered as the Allowed Callback URL (Capacitor WebView).") + return []guidanceLine{ + {text: fmt.Sprintf("Capacitor app ID: %s", bundleID)}, + {text: "http://localhost is registered as the Allowed Callback URL (Capacitor WebView)."}, + } + } + return []guidanceLine{ + {text: "Could not read Capacitor app ID. Ensure capacitor.config.json or capacitor.config.ts is present in your project root.", warn: true}, + {text: "http://localhost is registered as the Allowed Callback URL (Capacitor WebView)."}, } case "jhipster": - cli.renderer.Infof("Refer to JHipster documentation to complete the setup: https://www.jhipster.tech/security/#auth0") + return []guidanceLine{ + {text: "Refer to JHipster documentation to complete the setup: https://www.jhipster.tech/security/#auth0"}, + } + } + return nil +} + +// printNativeGuidance prints post-setup callback URL guidance for native frameworks. +func printNativeGuidance(cli *cli, framework, expoScheme, bundleID string) []string { + lines := nativeGuidance(framework, expoScheme, bundleID, cli.tenant) + steps := make([]string, 0, len(lines)) + for _, l := range lines { + if l.warn { + cli.renderer.Warnf("%s", l.text) + } else { + cli.renderer.Infof("%s", l.text) + } + steps = append(steps, strings.TrimSpace(l.text)) } + return steps } // createQuickstartAPI creates an Auth0 API resource server and optionally links it to an // existing application client via a client grant. -func createQuickstartAPI(ctx context.Context, cli *cli, inputs SetupInputs) error { +func createQuickstartAPI(ctx context.Context, cli *cli, inputs SetupInputs, result *display.QuickstartSetupResult) error { // API name = "-API", fallback to identifier. apiName := inputs.Identifier if inputs.Name != "" { @@ -1212,16 +1317,9 @@ func createQuickstartAPI(ctx context.Context, cli *cli, inputs SetupInputs) erro AllowOfflineAccess: &inputs.OfflineAccess, } - if inputs.Scopes != "" { - var scopeList []string - for _, s := range strings.Split(inputs.Scopes, ",") { - if s = strings.TrimSpace(s); s != "" { - scopeList = append(scopeList, s) - } - } - if len(scopeList) > 0 { - rs.Scopes = apiScopesFor(scopeList) - } + scopeList := parseScopeList(inputs.Scopes) + if len(scopeList) > 0 { + rs.Scopes = apiScopesFor(scopeList) } if err := ansi.Waiting(func() error { @@ -1231,24 +1329,105 @@ func createQuickstartAPI(ctx context.Context, cli *cli, inputs SetupInputs) erro } printAPIDetails(cli, rs) + if result != nil { + result.API = &display.QuickstartSetupAPI{ + Identifier: rs.GetIdentifier(), + Name: rs.GetName(), + Scopes: scopeList, + } + } + // Link the app to the API via a client grant if an app was selected/created. if inputs.LinkedAppID != "" { + grantScopes := inputs.GrantScopes + if grantScopes == nil { + grantScopes = scopeList + } + grant := &managementv3.CreateClientGrantRequestContent{ ClientID: &inputs.LinkedAppID, Audience: inputs.Identifier, - Scope: []string{}, } - if grantErr := ansi.Waiting(func() error { - _, err := cli.apiv3.ClientGrant.Create(ctx, grant) + grant.SetScope(grantScopes) // Always explicit, even when empty: Scope is omitempty otherwise. + + var resp *managementv3.CreateClientGrantResponseContent + if err := ansi.Waiting(func() (err error) { + resp, err = cli.apiv3.ClientGrant.Create(ctx, grant) return err - }); grantErr != nil { - cli.renderer.Warnf("Failed to link application to API: %v", grantErr) + }); err != nil { + return fmt.Errorf( + "created API %q but failed to link application %q to it with a client grant; create it manually with "+ + "'auth0 client-grants create --client-id %s --audience %s --scopes %s': %w", + inputs.Identifier, inputs.LinkedAppID, inputs.LinkedAppID, inputs.Identifier, strings.Join(grantScopes, ","), err) + } + + grantedDisplay := "(none)" + if len(grantScopes) > 0 { + grantedDisplay = strings.Join(grantScopes, ", ") + } + cli.renderer.Successf("Application %s linked to the API with scopes: %s", ansi.Magenta(inputs.LinkedAppID), grantedDisplay) + + if result != nil { + result.ClientGrant = &display.QuickstartSetupClientGrant{ + ID: resp.GetID(), + ClientID: inputs.LinkedAppID, + Audience: inputs.Identifier, + Scopes: grantScopes, + } } } return nil } +// parseScopeList never returns nil, so the client grant always sends an explicit scope list. +func parseScopeList(s string) []string { + scopes := []string{} + for _, scope := range strings.Split(s, ",") { + if scope = strings.TrimSpace(scope); scope != "" { + scopes = append(scopes, scope) + } + } + return scopes +} + +// normalizeScopeSlice keeps nil (flag unset, so the grant defaults to --scopes) distinct from empty. +func normalizeScopeSlice(in []string) []string { + if in == nil { + return nil + } + out := []string{} + for _, scope := range in { + if scope = strings.TrimSpace(scope); scope != "" { + out = append(out, scope) + } + } + return out +} + +func validateGrantScopes(inputs SetupInputs, grantScopesSet bool) error { + if !grantScopesSet && inputs.GrantScopes == nil { + return nil + } + if !inputs.API { + return usageError{err: fmt.Errorf("--grant-scopes requires --api"), reason: "invalid_flag_value"} + } + + apiScopes := parseScopeList(inputs.Scopes) + for _, scope := range inputs.GrantScopes { + if !slices.Contains(apiScopes, scope) { + return usageError{ + err: fmt.Errorf( + "--grant-scopes value %q is not one of the API scopes (--scopes: %q); every grant scope must also be in --scopes", + scope, strings.Join(apiScopes, ","), + ), + reason: "invalid_flag_value", + } + } + } + return nil +} + func getSupportedQuickstartTypes() []string { var types []string for key := range auth0.QuickstartConfigs { @@ -1335,12 +1514,6 @@ func resolveSetupInputs(cmd *cobra.Command, inputs *SetupInputs) (bool, error) { } } - // Resolve port from framework default before prompting. - // Port stays 0 for native apps (react-native, expo, flutter) - no port needed. - if inputs.Port == 0 { - inputs.Port = defaultPortForFramework(inputs.Framework) - } - // If no explicit build tool and the "none" variant doesn't exist, resolve the best // supported build tool from the pre-built FrameworkBuildTools map. wasAutoSelected := false @@ -1357,6 +1530,11 @@ func resolveSetupInputs(cmd *cobra.Command, inputs *SetupInputs) (bool, error) { } } + // Runs after the build tool is resolved so webpack projects get 8080, not the Vite port. + if inputs.Port == 0 { + inputs.Port = defaultPortForSetup(inputs.Framework, inputs.BuildTool) + } + return wasAutoSelected, nil } @@ -1390,12 +1568,21 @@ func frameworkDisplayName(framework string) string { return "Vanilla JS" case "vanilla-java": return "Java" + case "tanstack-start": + return "TanStack Start" default: titleCaser := cases.Title(language.English) return titleCaser.String(framework) } } +func defaultPortForSetup(framework, buildTool string) int { + if buildTool == "webpack" { + return 8080 // Webpack-dev-server default. + } + return defaultPortForFramework(framework) +} + // defaultPortForFramework returns the conventional port for a given framework name. func defaultPortForFramework(framework string) int { switch framework { @@ -1430,6 +1617,16 @@ func validateAPIIdentifier(identifier string) error { return nil } +func cleanURLList(in []string) []string { + var out []string + for _, u := range in { + if u = strings.TrimSpace(u); u != "" { + out = append(out, u) + } + } + return out +} + func generateClient(input SetupInputs, reqParams auth0.RequestParams) (*management.Client, error) { if input.Name == "" { input.Name = "My App" @@ -1443,15 +1640,15 @@ func generateClient(input SetupInputs, reqParams auth0.RequestParams) (*manageme resolved := resolveRequestParams(reqParams, input.Name, input.Port) - // Override URL fields with explicit flag values when provided. - if input.CallbackURL != "" { - resolved.Callbacks = []string{input.CallbackURL} + // Each explicit URL list replaces the resolved default list. + if urls := cleanURLList(input.CallbackURLs); len(urls) > 0 { + resolved.Callbacks = urls } - if input.LogoutURL != "" { - resolved.AllowedLogoutURLs = []string{input.LogoutURL} + if urls := cleanURLList(input.LogoutURLs); len(urls) > 0 { + resolved.AllowedLogoutURLs = urls } - if input.WebOriginURL != "" { - resolved.WebOrigins = []string{input.WebOriginURL} + if urls := cleanURLList(input.WebOriginURLs); len(urls) > 0 { + resolved.WebOrigins = urls } algorithm := "RS256" @@ -1657,24 +1854,212 @@ func sortedKeys(m map[string]string) []string { return keys } -func (c *cli) WriteQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]string, strategy *auth0.FileOutputStrategy) error { - if strategy == nil { - strategy = &auth0.FileOutputStrategy{Path: ".env", Format: "dotenv"} +const ( + qsConfigCreated = "created" + qsConfigMerged = "merged" + qsConfigOverwritten = "overwritten" + qsConfigSkipped = "skipped" +) + +const qsConfigBackupSuffix = ".auth0-backup" + +// qsConfigWritePlan is decided before any API call. +type qsConfigWritePlan struct { + Path string + Format string + Perm os.FileMode // Existing file's permission bits; 0600 for a new file. + Action string // One of the qsConfig* constants; "" while NeedsConfirm. + NeedsConfirm bool // Non-dotenv file exists and the user can be prompted. +} + +var qsConfirmOverwrite = prompt.Confirm + +var dotenvKeyRegexp = regexp.MustCompile(`^\s*(export\s+)?([A-Za-z_][A-Za-z0-9_.\-]*)\s*=`) + +func lookupQuickstartConfig(qsConfigKey string) (auth0.AppConfig, error) { + config, exists := auth0.QuickstartConfigs[qsConfigKey] + if !exists { + return auth0.AppConfig{}, usageError{err: fmt.Errorf("unsupported quickstart arguments: %s. Supported types: %v", qsConfigKey, getSupportedQuickstartTypes()), reason: "invalid_flag_value"} + } + return config, nil +} + +func normalizeStrategy(strategy *auth0.FileOutputStrategy) auth0.FileOutputStrategy { + if strategy == nil || strategy.Path == "" { + return auth0.FileOutputStrategy{Path: ".env", Format: "dotenv"} + } + return *strategy +} + +func planQuickstartConfigWrite(strategy auth0.FileOutputStrategy, force, canPromptFlag bool) (*qsConfigWritePlan, error) { + plan := &qsConfigWritePlan{Path: strategy.Path, Format: strategy.Format} + + info, err := os.Stat(strategy.Path) + if os.IsNotExist(err) { + plan.Action = qsConfigCreated + plan.Perm = 0600 + return plan, nil + } + if err != nil { + return nil, fmt.Errorf("failed to check %s: %w", strategy.Path, err) + } + if info.IsDir() { + return nil, validationError{err: fmt.Errorf("%s is a directory", strategy.Path), reason: "invalid_project_dir"} + } + + plan.Perm = info.Mode().Perm() + + // Non-interactive runs (agent mode, --no-input, no TTY) merge .env so unrelated keys survive; + // interactive runs keep the explicit overwrite prompt (with a backup) for every format. + switch { + case strategy.Format == "dotenv" && !canPromptFlag: + plan.Action = qsConfigMerged + case force: + plan.Action = qsConfigOverwritten + case !canPromptFlag: + return nil, usageError{ + err: fmt.Errorf( + "%s already exists; re-run with --force to replace it (the current file will be backed up to %s)", + strategy.Path, strategy.Path+qsConfigBackupSuffix, + ), + reason: "file_exists", + } + default: + plan.NeedsConfirm = true + } + return plan, nil +} + +func confirmQuickstartConfigOverwrite(plan *qsConfigWritePlan) { + if plan == nil || !plan.NeedsConfirm { + return + } + message := fmt.Sprintf("Proceed to overwrite '%s' file? The current file will be backed up first.", ansi.Bold(plan.Path)) + if qsConfirmOverwrite(message) { + plan.Action = qsConfigOverwritten + } else { + plan.Action = qsConfigSkipped + } + plan.NeedsConfirm = false +} + +func dotenvLine(key, value string) string { + return fmt.Sprintf("%s=\"%s\"", key, value) +} + +// mergeDotenv replaces the first assignment of each owned key in place (keeping `export `), drops +// later duplicates, keeps every other line as is, and appends missing keys. Continuation lines of +// a multi-line quoted value of an owned key are left in place. +func mergeDotenv(existing []byte, values map[string]string) []byte { + var out strings.Builder + + if len(existing) == 0 { + for _, key := range sortedKeys(values) { + out.WriteString(dotenvLine(key, values[key])) + out.WriteString("\n") + } + return []byte(out.String()) + } + + lines := strings.Split(string(existing), "\n") + if bytes.HasSuffix(existing, []byte("\n")) { + lines = lines[:len(lines)-1] + } + + seen := make(map[string]bool, len(values)) + merged := make([]string, 0, len(lines)+len(values)) + for _, line := range lines { + m := dotenvKeyRegexp.FindStringSubmatch(line) + if m == nil { + merged = append(merged, line) + continue + } + key := m[2] + value, owned := values[key] + if !owned { + merged = append(merged, line) + continue + } + if seen[key] { + continue // Drop later duplicates of an owned key. + } + seen[key] = true + prefix := "" + if m[1] != "" { + prefix = "export " + } + merged = append(merged, prefix+dotenvLine(key, value)) + } + + for _, key := range sortedKeys(values) { + if !seen[key] { + merged = append(merged, dotenvLine(key, values[key])) + } } - dir := filepath.Dir(strategy.Path) - if dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory structure %s: %w", dir, err) + out.WriteString(strings.Join(merged, "\n")) + out.WriteString("\n") + return []byte(out.String()) +} + +// nextBackupPath returns .auth0-backup, or the first free .auth0-backup.N (N=1,2,…). +func nextBackupPath(path string) (string, error) { + const maxBackups = 1000 + + candidate := path + qsConfigBackupSuffix + for n := 0; n <= maxBackups; n++ { + if n > 0 { + candidate = fmt.Sprintf("%s%s.%d", path, qsConfigBackupSuffix, n) + } + _, err := os.Lstat(candidate) + if os.IsNotExist(err) { + return candidate, nil + } + if err != nil { + return "", fmt.Errorf("failed to check backup path %s: %w", candidate, err) } } + return "", fmt.Errorf("failed to find a free backup path for %s after %d attempts", path, maxBackups) +} +// backupQuickstartConfigFile creates the backup exclusively, so an existing backup is never overwritten. +func backupQuickstartConfigFile(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("failed to read %s for backup: %w", path, err) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read %s for backup: %w", path, err) + } + + backup, err := nextBackupPath(path) + if err != nil { + return "", err + } + + f, err := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) + if err != nil { + return "", fmt.Errorf("failed to create backup %s: %w", backup, err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return "", fmt.Errorf("failed to write backup %s: %w", backup, err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("failed to write backup %s: %w", backup, err) + } + return backup, nil +} + +func renderQuickstartConfigContent(resolvedEnv map[string]string, strategy auth0.FileOutputStrategy) ([]byte, error) { var contentBuilder strings.Builder switch strategy.Format { case "dotenv": for _, key := range sortedKeys(resolvedEnv) { - fmt.Fprintf(&contentBuilder, "%s=\"%s\"\n", key, resolvedEnv[key]) + contentBuilder.WriteString(dotenvLine(key, resolvedEnv[key])) + contentBuilder.WriteString("\n") } case "properties": @@ -1687,7 +2072,7 @@ func (c *cli) WriteQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s nested := buildNestedMap(resolvedEnv) yamlBytes, err := yaml.Marshal(nested) if err != nil { - return fmt.Errorf("failed to marshal YAML for %s: %w", strategy.Path, err) + return nil, fmt.Errorf("failed to marshal YAML for %s: %w", strategy.Path, err) } contentBuilder.Write(yamlBytes) @@ -1700,7 +2085,7 @@ func (c *cli) WriteQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s wrapped := map[string]interface{}{"development": devSection} yamlBytes, err := yaml.Marshal(wrapped) if err != nil { - return fmt.Errorf("failed to marshal YAML for %s: %w", strategy.Path, err) + return nil, fmt.Errorf("failed to marshal YAML for %s: %w", strategy.Path, err) } contentBuilder.Write(yamlBytes) @@ -1735,7 +2120,7 @@ func (c *cli) WriteQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s auth0Section := make(map[string]string) for key, val := range resolvedEnv { if !strings.HasPrefix(key, "Auth0:") { - return fmt.Errorf("json formatter: key %q is missing required \"Auth0:\" prefix", key) + return nil, fmt.Errorf("json formatter: key %q is missing required \"Auth0:\" prefix", key) } cleanKey := strings.TrimPrefix(key, "Auth0:") auth0Section[cleanKey] = val @@ -1743,7 +2128,7 @@ func (c *cli) WriteQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s jsonBody := map[string]interface{}{"Auth0": auth0Section} jsonBytes, err := json.MarshalIndent(jsonBody, "", " ") if err != nil { - return fmt.Errorf("failed to marshal JSON for %s: %w", strategy.Path, err) + return nil, fmt.Errorf("failed to marshal JSON for %s: %w", strategy.Path, err) } contentBuilder.Write(jsonBytes) @@ -1799,28 +2184,132 @@ func (c *cli) WriteQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s } contentBuilder.WriteString("\n") contentBuilder.WriteString("\n") + + default: + return nil, fmt.Errorf("unsupported config format %q for %s", strategy.Format, strategy.Path) + } + + return []byte(contentBuilder.String()), nil +} + +func (c *cli) WriteQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]string, strategy *auth0.FileOutputStrategy) error { + _, err := c.writeQuickstartConfig(cmd, resolvedEnv, strategy, nil) + return err +} + +func (c *cli) writeQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]string, strategy *auth0.FileOutputStrategy, plan *qsConfigWritePlan) (*display.QuickstartSetupConfigFile, error) { + s := normalizeStrategy(strategy) + + if plan == nil { + var err error + plan, err = planQuickstartConfigWrite(s, c.force, canPrompt(cmd)) + if err != nil { + return nil, err + } + } + if plan.NeedsConfirm { + confirmQuickstartConfigOverwrite(plan) } - message := fmt.Sprintf("Proceed to overwrite '%s' file?", ansi.Bold(strategy.Path)) - if shouldCancelOverwrite(c, cmd, strategy.Path, message) { - c.renderer.Warnf("Aborted creating %s. Please create it manually using the following content:", ansi.Cyan(strategy.Path)) + content, err := renderQuickstartConfigContent(resolvedEnv, s) + if err != nil { + return nil, err + } + + outcome := &display.QuickstartSetupConfigFile{Path: s.Path, Format: s.Format, Action: plan.Action} + + switch plan.Action { + case qsConfigSkipped: + c.renderer.Warnf("Aborted creating %s. Please create it manually using the following content:", ansi.Cyan(s.Path)) c.renderer.Newline() const border = "─────────────────────────────────────────────────────────────" c.renderer.Detailf("%s", border) - for _, line := range strings.Split(strings.TrimRight(contentBuilder.String(), "\n"), "\n") { + for _, line := range strings.Split(strings.TrimRight(string(content), "\n"), "\n") { c.renderer.Detailf("%s", line) } c.renderer.Detailf("%s", border) c.renderer.Newline() - } else { - if err := os.WriteFile(strategy.Path, []byte(contentBuilder.String()), 0600); err != nil { - return fmt.Errorf("failed to write .env file: %w", err) + return outcome, nil + + case qsConfigMerged: + action, err := c.mergeQuickstartDotenv(s.Path, resolvedEnv, plan.Perm, content) + if err != nil { + return nil, err } + outcome.Action = action + return outcome, nil + case qsConfigOverwritten: + backup, err := backupQuickstartConfigFile(s.Path) + if err != nil { + return nil, err + } + if err := os.WriteFile(s.Path, content, plan.Perm); err != nil { + return nil, fmt.Errorf("failed to write %s: %w", s.Path, err) + } c.renderer.Newline() - c.renderer.Successf("%s file created successfully with your Auth0 configuration", ansi.Cyan(strategy.Path)) + c.renderer.Successf("%s file overwritten with your Auth0 configuration", ansi.Cyan(s.Path)) + c.renderer.Detailf("Backup of the previous file written to %s", ansi.Cyan(backup)) + outcome.Backup = &backup + return outcome, nil + + case qsConfigCreated: + if dir := filepath.Dir(s.Path); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory structure %s: %w", dir, err) + } + } + // The file may have appeared since the pre-flight; never clobber a dotenv file. + if info, err := os.Stat(s.Path); err == nil && s.Format == "dotenv" { + action, err := c.mergeQuickstartDotenv(s.Path, resolvedEnv, info.Mode().Perm(), content) + if err != nil { + return nil, err + } + outcome.Action = action + return outcome, nil + } + if err := c.createQuickstartConfigFile(s.Path, content); err != nil { + return nil, err + } + return outcome, nil + + default: + return nil, fmt.Errorf("unexpected config write action %q for %s", plan.Action, s.Path) } +} +func (c *cli) createQuickstartConfigFile(path string, content []byte) error { + if err := os.WriteFile(path, content, 0600); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + c.renderer.Newline() + c.renderer.Successf("%s file created successfully with your Auth0 configuration", ansi.Cyan(path)) return nil } + +// mergeQuickstartDotenv creates the file from content if it vanished since the pre-flight. +func (c *cli) mergeQuickstartDotenv(path string, resolvedEnv map[string]string, perm os.FileMode, content []byte) (string, error) { + existing, err := os.ReadFile(path) + if os.IsNotExist(err) { + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create directory structure %s: %w", dir, err) + } + } + if err := c.createQuickstartConfigFile(path, content); err != nil { + return "", err + } + return qsConfigCreated, nil + } + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", path, err) + } + + if err := os.WriteFile(path, mergeDotenv(existing, resolvedEnv), perm); err != nil { + return "", fmt.Errorf("failed to write %s: %w", path, err) + } + c.renderer.Newline() + c.renderer.Successf("%s updated: merged your Auth0 configuration into the existing file", ansi.Cyan(path)) + return qsConfigMerged, nil +} diff --git a/internal/cli/quickstarts_test.go b/internal/cli/quickstarts_test.go index f7bf685d0..aa4a76418 100644 --- a/internal/cli/quickstarts_test.go +++ b/internal/cli/quickstarts_test.go @@ -3,13 +3,18 @@ package cli import ( "bytes" "context" + "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "strings" "testing" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/option" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -119,6 +124,15 @@ func TestResolveRequestParams_AllQuickstartConfigs(t *testing.T) { []string{"http://localhost:5173"}, []string{"http://localhost:5173"}, []string{"http://localhost:5173"}, "spa"}, + // Webpack SPAs: webpack-dev-server default port 8080. + {"spa:react:webpack", 8080, + []string{"http://localhost:8080"}, + []string{"http://localhost:8080"}, + []string{"http://localhost:8080"}, "spa"}, + {"spa:vanilla-javascript:webpack", 8080, + []string{"http://localhost:8080"}, + []string{"http://localhost:8080"}, + []string{"http://localhost:8080"}, "spa"}, {"spa:angular:none", 4200, []string{"http://localhost:4200"}, []string{"http://localhost:4200"}, @@ -134,6 +148,9 @@ func TestResolveRequestParams_AllQuickstartConfigs(t *testing.T) { {"regular:fastify:none", 3000, []string{"http://localhost:3000/auth/callback"}, []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:tanstack-start:none", 3000, + []string{"http://localhost:3000/auth/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, {"regular:nuxt:none", 3000, []string{"http://localhost:3000/auth/callback"}, []string{"http://localhost:3000"}, nil, "regular_web"}, @@ -300,6 +317,12 @@ func TestGenerateAndWriteQuickstartConfig_AllQuickstartConfigs(t *testing.T) { {"spa:vanilla-javascript:vite", 5173, ".env", []string{"VITE_AUTH0_DOMAIN", "VITE_AUTH0_CLIENT_ID"}, map[string]string{"VITE_AUTH0_DOMAIN": domain, "VITE_AUTH0_CLIENT_ID": cidVal}}, + {"spa:react:webpack", 8080, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal}}, + {"spa:vanilla-javascript:webpack", 8080, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal}}, {"spa:angular:none", 4200, "environment.ts", // Angular-ts wraps values under auth0:{} matching the official Angular quickstart. []string{"auth0:", "domain:", "clientId:", "production:"}, @@ -311,6 +334,9 @@ func TestGenerateAndWriteQuickstartConfig_AllQuickstartConfigs(t *testing.T) { {"regular:nextjs:none", 3000, ".env", []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_SECRET", "APP_BASE_URL"}, map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal, "AUTH0_CLIENT_SECRET": csecVal, "APP_BASE_URL": "http://localhost:3000"}}, + {"regular:tanstack-start:none", 3000, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_SECRET", "APP_BASE_URL"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal, "AUTH0_CLIENT_SECRET": csecVal, "APP_BASE_URL": "http://localhost:3000"}}, {"regular:fastify:none", 3000, ".env", []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "SESSION_SECRET", "APP_BASE_URL"}, map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal, "AUTH0_CLIENT_SECRET": csecVal, "APP_BASE_URL": "http://localhost:3000"}}, @@ -473,11 +499,14 @@ func TestGenerateClient_AllQuickstartConfigs(t *testing.T) { {"spa:vue:vite", 5173, "spa", 1, "http://localhost:5173", 1, 1}, {"spa:svelte:vite", 5173, "spa", 1, "http://localhost:5173", 1, 1}, {"spa:vanilla-javascript:vite", 5173, "spa", 1, "http://localhost:5173", 1, 1}, + {"spa:react:webpack", 8080, "spa", 1, "http://localhost:8080", 1, 1}, + {"spa:vanilla-javascript:webpack", 8080, "spa", 1, "http://localhost:8080", 1, 1}, {"spa:angular:none", 4200, "spa", 1, "http://localhost:4200", 1, 1}, {"spa:flutter-web:none", 3000, "spa", 1, "http://localhost:3000", 1, 1}, // Regular web: framework-specific paths. {"regular:nextjs:none", 3000, "regular_web", 1, "http://localhost:3000/auth/callback", 1, 0}, {"regular:fastify:none", 3000, "regular_web", 1, "http://localhost:3000/auth/callback", 1, 0}, + {"regular:tanstack-start:none", 3000, "regular_web", 1, "http://localhost:3000/auth/callback", 1, 0}, {"regular:nuxt:none", 3000, "regular_web", 1, "http://localhost:3000/auth/callback", 1, 0}, {"regular:express:none", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, {"regular:hono:none", 3000, "regular_web", 1, "http://localhost:3000/auth/callback", 1, 0}, @@ -552,7 +581,7 @@ func TestGenerateClient_AllQuickstartConfigs(t *testing.T) { func TestGenerateAndWriteQuickstartConfig_PortInBaseURL(t *testing.T) { t.Parallel() - for _, configKey := range []string{"regular:nextjs:none", "regular:fastify:none", "regular:express:none"} { + for _, configKey := range []string{"regular:nextjs:none", "regular:fastify:none", "regular:express:none", "regular:tanstack-start:none"} { t.Run(configKey, func(t *testing.T) { t.Parallel() @@ -581,7 +610,7 @@ func TestGenerateAndWriteQuickstartConfig_SecretsNonEmpty(t *testing.T) { cid, csec := "cid", "csec" client := &management.Client{ClientID: &cid, ClientSecret: &csec} - for _, configKey := range []string{"regular:nextjs:none", "regular:fastify:none", "regular:sveltekit:none", "regular:sveltekit:vite"} { + for _, configKey := range []string{"regular:nextjs:none", "regular:fastify:none", "regular:sveltekit:none", "regular:sveltekit:vite", "regular:tanstack-start:none"} { t.Run(configKey, func(t *testing.T) { t.Parallel() @@ -784,17 +813,13 @@ func TestCreateQuickstartApp_SPA_React(t *testing.T) { Port: 5173, } - // Write a temp env file location. - inputs.CallbackURL = "" - inputs.LogoutURL = "" - // Override the working directory so generateAndWriteQuickstartConfig writes to a temp dir. oldWD, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Chdir(dir)) defer func() { _ = os.Chdir(oldWD) }() - clientID, err := createQuickstartApp(&cobra.Command{}, testCLI, inputs, "spa:react:vite") + clientID, err := createQuickstartApp(&cobra.Command{}, testCLI, inputs, "spa:react:vite", nil, nil) require.NoError(t, err) assert.Equal(t, "test-client-id", clientID) @@ -808,7 +833,7 @@ func TestCreateQuickstartApp_UnsupportedKey(t *testing.T) { t.Parallel() testCLI := &cli{renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}} - _, err := createQuickstartApp(&cobra.Command{}, testCLI, SetupInputs{}, "unknown:framework:none") + _, err := createQuickstartApp(&cobra.Command{}, testCLI, SetupInputs{}, "unknown:framework:none", nil, nil) assert.ErrorContains(t, err, "unsupported quickstart arguments") } @@ -921,7 +946,7 @@ func TestAmbiguousDetection_NoInput_IntegrationFlow(t *testing.T) { require.NoError(t, os.Chdir(dir)) defer func() { _ = os.Chdir(origDir) }() - clientID, err := createQuickstartApp(&cobra.Command{}, testCLI, inputs, qsConfigKey) + clientID, err := createQuickstartApp(&cobra.Command{}, testCLI, inputs, qsConfigKey, nil, nil) require.NoError(t, err) assert.Equal(t, "test-client-id", clientID) } @@ -943,10 +968,16 @@ func TestCreateQuickstartAPI_CreatesResourceServerAndGrant(t *testing.T) { return nil }) + var grantBody map[string]json.RawMessage grantAPI := mock.NewMockClientGrantAPIV3(ctrl) grantAPI.EXPECT(). Create(gomock.Any(), gomock.Any()). - Return(nil, nil) + DoAndReturn(func(_ context.Context, req *managementv3.CreateClientGrantRequestContent, _ ...option.RequestOption) (*managementv3.CreateClientGrantResponseContent, error) { + b, err := json.Marshal(req) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(b, &grantBody)) + return nil, nil + }) testCLI := &cli{ renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, @@ -967,8 +998,14 @@ func TestCreateQuickstartAPI_CreatesResourceServerAndGrant(t *testing.T) { LinkedAppID: "linked-app-client-id", } - err := createQuickstartAPI(context.Background(), testCLI, inputs) + err := createQuickstartAPI(context.Background(), testCLI, inputs, nil) assert.NoError(t, err) + + // SetScope must be called explicitly so an empty scope list is still sent. + require.Contains(t, grantBody, "scope", "grant request must always carry the scope field") + assert.JSONEq(t, `[]`, string(grantBody["scope"])) + assert.JSONEq(t, `"linked-app-client-id"`, string(grantBody["client_id"])) + assert.JSONEq(t, `"https://my-api"`, string(grantBody["audience"])) } func TestCreateQuickstartAPI_NoLinkedApp_SkipsGrant(t *testing.T) { @@ -1005,6 +1042,1141 @@ func TestCreateQuickstartAPI_NoLinkedApp_SkipsGrant(t *testing.T) { SigningAlg: "RS256", } - err := createQuickstartAPI(context.Background(), testCLI, inputs) + err := createQuickstartAPI(context.Background(), testCLI, inputs, nil) assert.NoError(t, err) } + +// -- qs setup test helpers --. + +const qsTestTenant = "test.auth0.com" + +// newQSSetupCmd builds the real `qs setup` command bound to c and parses args so +// flag-presence checks (IsSet) behave as in a real run. It is never executed. +func newQSSetupCmd(t *testing.T, c *cli, args ...string) *cobra.Command { + t.Helper() + cmd := setupQuickstartCmd(c) + require.NoError(t, cmd.ParseFlags(args)) + cmd.SetContext(context.Background()) + return cmd +} + +// newQSTestCLI returns a cli wired to the given mocks with buffered writers. +func newQSTestCLI(format display.OutputFormat, agentMode bool, api *auth0.API, apiv3 *auth0.APIV3) (*cli, *bytes.Buffer, *bytes.Buffer) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + return &cli{ + renderer: &display.Renderer{ + MessageWriter: stderr, + ResultWriter: stdout, + Format: format, + AgentMode: agentMode, + }, + api: api, + apiv3: apiv3, + tenant: qsTestTenant, + }, stdout, stderr +} + +// expectClientCreate stubs exactly one Client.Create that assigns an ID and secret. +func expectClientCreate(clientAPI *mock.MockClientAPI, clientID, secret string) { + clientAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, c *management.Client, _ ...management.RequestOption) error { + c.ClientID = &clientID + c.ClientSecret = &secret + return nil + }). + Times(1) +} + +// decodeSingleJSON asserts out holds exactly one JSON document and returns it raw. +func decodeSingleJSON(t *testing.T, out []byte) map[string]json.RawMessage { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(out)) + var doc map[string]json.RawMessage + require.NoError(t, dec.Decode(&doc), "stdout must be a JSON object, got: %q", string(out)) + var extra interface{} + assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout must contain exactly one JSON document") + return doc +} + +func writeQSFile(t *testing.T, path, content string, perm os.FileMode) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(content), perm)) + require.NoError(t, os.Chmod(path, perm)) // Defeat umask so mode assertions are exact. +} + +func fileMode(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + require.NoError(t, err) + return info.Mode().Perm() +} + +// -- #9 config write: mergeDotenv --. + +func TestMergeDotenv(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existing string + values map[string]string + want string + }{ + { + name: "replaces owned key in place and keeps unrelated lines, comments and blanks in order", + existing: "# app config\nFOO=bar\n# AUTH0_DOMAIN=commented\nAUTH0_DOMAIN=old\n\nAUTH0_DOMAIN_EXTRA=keep\nBAZ=qux\n", + values: map[string]string{"AUTH0_DOMAIN": "new.auth0.com"}, + want: "# app config\nFOO=bar\n# AUTH0_DOMAIN=commented\nAUTH0_DOMAIN=\"new.auth0.com\"\n\nAUTH0_DOMAIN_EXTRA=keep\nBAZ=qux\n", + }, + { + name: "appends missing owned keys at the end, sorted", + existing: "FOO=bar\n", + values: map[string]string{"B_KEY": "2", "A_KEY": "1"}, + want: "FOO=bar\nA_KEY=\"1\"\nB_KEY=\"2\"\n", + }, + { + name: "replaces and appends together", + existing: "FOO=bar\nAUTH0_CLIENT_ID=old\n", + values: map[string]string{"AUTH0_CLIENT_ID": "cid", "AUTH0_DOMAIN": "d"}, + want: "FOO=bar\nAUTH0_CLIENT_ID=\"cid\"\nAUTH0_DOMAIN=\"d\"\n", + }, + { + // D2: the export prefix is kept on replaced owned keys. + name: "export prefix is preserved on a replaced owned key", + existing: "export AUTH0_DOMAIN=old\nexport FOO=bar\n", + values: map[string]string{"AUTH0_DOMAIN": "new"}, + want: "export AUTH0_DOMAIN=\"new\"\nexport FOO=bar\n", + }, + { + name: "later duplicates of an owned key are dropped", + existing: "AUTH0_DOMAIN=a\nFOO=bar\nAUTH0_DOMAIN=b\nexport AUTH0_DOMAIN=c\n", + values: map[string]string{"AUTH0_DOMAIN": "new"}, + want: "AUTH0_DOMAIN=\"new\"\nFOO=bar\n", + }, + { + name: "duplicate unrelated keys are left untouched", + existing: "FOO=1\nFOO=2\n", + values: map[string]string{"A": "x"}, + want: "FOO=1\nFOO=2\nA=\"x\"\n", + }, + { + name: "existing content without trailing newline gets a separator", + existing: "FOO=bar", + values: map[string]string{"A": "1"}, + want: "FOO=bar\nA=\"1\"\n", + }, + { + name: "empty existing file", + existing: "", + values: map[string]string{"B": "2", "A": "1"}, + want: "A=\"1\"\nB=\"2\"\n", + }, + { + name: "unrelated CRLF lines are preserved byte-for-byte", + existing: "FOO=bar\r\nBAZ=qux\r\n", + values: map[string]string{"A": "1"}, + want: "FOO=bar\r\nBAZ=qux\r\nA=\"1\"\n", + }, + { + name: "owned key with spaces around = is matched", + existing: "AUTH0_DOMAIN = old\nFOO=bar\n", + values: map[string]string{"AUTH0_DOMAIN": "new"}, + want: "AUTH0_DOMAIN=\"new\"\nFOO=bar\n", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := mergeDotenv([]byte(tc.existing), tc.values) + assert.Equal(t, tc.want, string(got)) + }) + } +} + +// -- #9 config write: pre-flight plan --. + +func TestPlanQuickstartConfigWrite(t *testing.T) { + t.Parallel() + + t.Run("missing file is created with 0600", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), ".env") + for _, force := range []bool{false, true} { + for _, canPromptFlag := range []bool{false, true} { + plan, err := planQuickstartConfigWrite(auth0.FileOutputStrategy{Path: path, Format: "dotenv"}, force, canPromptFlag) + require.NoError(t, err) + assert.Equal(t, qsConfigCreated, plan.Action) + assert.Equal(t, os.FileMode(0600), plan.Perm) + assert.False(t, plan.NeedsConfirm) + } + } + }) + + t.Run("missing non-dotenv file is created without refusal", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "src", "environments", "environment.ts") + plan, err := planQuickstartConfigWrite(auth0.FileOutputStrategy{Path: path, Format: "angular-ts"}, false, false) + require.NoError(t, err) + assert.Equal(t, qsConfigCreated, plan.Action) + }) + + t.Run("existing dotenv is merged only when prompting is unavailable", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), ".env") + writeQSFile(t, path, "FOO=bar\n", 0640) + tests := []struct { + force, canPrompt bool + wantAction string + wantConfirm bool + }{ + {force: false, canPrompt: false, wantAction: qsConfigMerged}, + {force: true, canPrompt: false, wantAction: qsConfigMerged}, + {force: false, canPrompt: true, wantConfirm: true}, + {force: true, canPrompt: true, wantAction: qsConfigOverwritten}, + } + for _, tt := range tests { + plan, err := planQuickstartConfigWrite(auth0.FileOutputStrategy{Path: path, Format: "dotenv"}, tt.force, tt.canPrompt) + require.NoError(t, err, "force=%v canPrompt=%v", tt.force, tt.canPrompt) + assert.Equal(t, tt.wantConfirm, plan.NeedsConfirm, "force=%v canPrompt=%v", tt.force, tt.canPrompt) + if !tt.wantConfirm { + assert.Equal(t, tt.wantAction, plan.Action, "force=%v canPrompt=%v", tt.force, tt.canPrompt) + } + assert.Equal(t, os.FileMode(0640), plan.Perm) + } + }) + + t.Run("existing non-dotenv without force and no prompt refuses with file_exists", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "environment.ts") + writeQSFile(t, path, "old", 0644) + _, err := planQuickstartConfigWrite(auth0.FileOutputStrategy{Path: path, Format: "angular-ts"}, false, false) + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Equal(t, "file_exists", errorReason(err)) + assert.Contains(t, err.Error(), path) + assert.Contains(t, err.Error(), "--force") + }) + + t.Run("existing non-dotenv with force is overwritten", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "environment.ts") + writeQSFile(t, path, "old", 0644) + for _, canPromptFlag := range []bool{false, true} { + plan, err := planQuickstartConfigWrite(auth0.FileOutputStrategy{Path: path, Format: "angular-ts"}, true, canPromptFlag) + require.NoError(t, err) + assert.Equal(t, qsConfigOverwritten, plan.Action) + assert.False(t, plan.NeedsConfirm) + assert.Equal(t, os.FileMode(0644), plan.Perm) + } + }) + + t.Run("existing non-dotenv when prompting is possible needs confirmation", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "Auth0.plist") + writeQSFile(t, path, "old", 0644) + plan, err := planQuickstartConfigWrite(auth0.FileOutputStrategy{Path: path, Format: "plist"}, false, true) + require.NoError(t, err) + assert.True(t, plan.NeedsConfirm) + assert.Equal(t, path, plan.Path) + assert.Equal(t, "plist", plan.Format) + }) + + t.Run("target path is a directory", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), ".env") + require.NoError(t, os.MkdirAll(path, 0755)) + _, err := planQuickstartConfigWrite(auth0.FileOutputStrategy{Path: path, Format: "dotenv"}, true, false) + assert.Error(t, err) + }) +} + +// TestConfirmQuickstartConfigOverwrite stubs the package-level prompt; not parallel. +func TestConfirmQuickstartConfigOverwrite(t *testing.T) { + orig := qsConfirmOverwrite + t.Cleanup(func() { qsConfirmOverwrite = orig }) + + for _, tc := range []struct { + answer bool + wantAction string + }{ + {true, qsConfigOverwritten}, + {false, qsConfigSkipped}, + } { + var gotMsg string + qsConfirmOverwrite = func(msg string) bool { + gotMsg = msg + return tc.answer + } + plan := &qsConfigWritePlan{Path: "src/environments/environment.ts", Format: "angular-ts", NeedsConfirm: true} + confirmQuickstartConfigOverwrite(plan) + + assert.Equal(t, tc.wantAction, plan.Action) + assert.False(t, plan.NeedsConfirm, "NeedsConfirm must be cleared") + assert.Contains(t, gotMsg, "environment.ts") + assert.Contains(t, gotMsg, "backed up") + } +} + +func TestNextBackupPath(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "environment.ts") + writeQSFile(t, path, "x", 0644) + + got, err := nextBackupPath(path) + require.NoError(t, err) + assert.Equal(t, path+".auth0-backup", got) + + writeQSFile(t, path+".auth0-backup", "b0", 0644) + got, err = nextBackupPath(path) + require.NoError(t, err) + assert.Equal(t, path+".auth0-backup.1", got) + + writeQSFile(t, path+".auth0-backup.1", "b1", 0644) + got, err = nextBackupPath(path) + require.NoError(t, err) + assert.Equal(t, path+".auth0-backup.2", got) +} + +func TestBackupQuickstartConfigFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "Web.config") + writeQSFile(t, path, "", 0640) + + backup, err := backupQuickstartConfigFile(path) + require.NoError(t, err) + assert.Equal(t, path+".auth0-backup", backup) + content, err := os.ReadFile(backup) + require.NoError(t, err) + assert.Equal(t, "", string(content)) + assert.Equal(t, os.FileMode(0640), fileMode(t, backup), "backup keeps the source permission bits") + + backup2, err := backupQuickstartConfigFile(path) + require.NoError(t, err) + assert.Equal(t, path+".auth0-backup.1", backup2) +} + +// -- #9 config write: writeQuickstartConfig --. + +func TestWriteQuickstartConfig_DotenvMerge(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), ".env") + writeQSFile(t, path, "DATABASE_URL=postgres://localhost/db\n# keep me\nAUTH0_DOMAIN=old.auth0.com\n", 0644) + + c, _, _ := newQSTestCLI("", false, nil, nil) + out, err := c.writeQuickstartConfig(&cobra.Command{}, map[string]string{ + "AUTH0_DOMAIN": "new.auth0.com", + "AUTH0_CLIENT_ID": "cid", + }, &auth0.FileOutputStrategy{Path: path, Format: "dotenv"}, nil) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, qsConfigMerged, out.Action) + assert.Equal(t, path, out.Path) + assert.Equal(t, "dotenv", out.Format) + assert.Nil(t, out.Backup) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, + "DATABASE_URL=postgres://localhost/db\n# keep me\nAUTH0_DOMAIN=\"new.auth0.com\"\nAUTH0_CLIENT_ID=\"cid\"\n", + string(content)) + assert.NotContains(t, string(content), "old.auth0.com") + assert.Equal(t, os.FileMode(0644), fileMode(t, path), "merge must keep the existing file mode") + + // No backup is written for a dotenv merge. + _, statErr := os.Stat(path + ".auth0-backup") + assert.True(t, os.IsNotExist(statErr)) +} + +func TestWriteQuickstartConfig_NewFilePerm0600(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "nested", ".env") + c, _, _ := newQSTestCLI("", false, nil, nil) + out, err := c.writeQuickstartConfig(&cobra.Command{}, map[string]string{"AUTH0_DOMAIN": "d"}, + &auth0.FileOutputStrategy{Path: path, Format: "dotenv"}, nil) + require.NoError(t, err) + assert.Equal(t, qsConfigCreated, out.Action) + assert.Nil(t, out.Backup) + assert.Equal(t, os.FileMode(0600), fileMode(t, path)) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "AUTH0_DOMAIN=\"d\"\n", string(content)) +} + +func TestWriteQuickstartConfig_NonDotenvForceWritesBackup(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "src", "environments", "environment.ts") + const original = "export const environment = { production: true };\n" + writeQSFile(t, path, original, 0644) + + c, _, _ := newQSTestCLI("", false, nil, nil) + c.force = true + env := map[string]string{"domain": "d.auth0.com", "clientId": "cid"} + strategy := &auth0.FileOutputStrategy{Path: path, Format: "angular-ts"} + + out, err := c.writeQuickstartConfig(&cobra.Command{}, env, strategy, nil) + require.NoError(t, err) + assert.Equal(t, qsConfigOverwritten, out.Action) + require.NotNil(t, out.Backup) + assert.Equal(t, path+".auth0-backup", *out.Backup) + + backup, err := os.ReadFile(*out.Backup) + require.NoError(t, err) + assert.Equal(t, original, string(backup), "backup must hold the original content") + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), "clientId: 'cid'") + assert.Contains(t, string(content), "domain: 'd.auth0.com'") + + // A second forced run never clobbers the first backup. + out2, err := c.writeQuickstartConfig(&cobra.Command{}, env, strategy, nil) + require.NoError(t, err) + require.NotNil(t, out2.Backup) + assert.Equal(t, path+".auth0-backup.1", *out2.Backup) + first, err := os.ReadFile(path + ".auth0-backup") + require.NoError(t, err) + assert.Equal(t, original, string(first)) +} + +func TestWriteQuickstartConfig_SkippedLeavesFileUntouched(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "Auth0.plist") + writeQSFile(t, path, "original", 0644) + + c, _, stderr := newQSTestCLI("", false, nil, nil) + plan := &qsConfigWritePlan{Path: path, Format: "plist", Perm: 0644, Action: qsConfigSkipped} + out, err := c.writeQuickstartConfig(&cobra.Command{}, map[string]string{"ClientId": "cid", "Domain": "d"}, + &auth0.FileOutputStrategy{Path: path, Format: "plist"}, plan) + require.NoError(t, err) + assert.Equal(t, qsConfigSkipped, out.Action) + assert.Nil(t, out.Backup) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "original", string(content)) + _, statErr := os.Stat(path + ".auth0-backup") + assert.True(t, os.IsNotExist(statErr), "skip must not write a backup") + // Human output still dumps the content for manual creation. + assert.Contains(t, stderr.String(), "ClientId") +} + +// -- #9 pre-flight through the full setup flow --. + +func TestExecuteSetupQuickstart_FileExistsRefusesBeforeAPICall(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeTestFile(t, dir, "angular.json", `{}`) + envPath := filepath.Join("src", "environments", "environment.ts") + writeQSFile(t, envPath, "original", 0644) + + ctrl := gomock.NewController(t) + // No EXPECT: any Client/ResourceServer call fails the test. + clientAPI := mock.NewMockClientAPI(ctrl) + rsAPI := mock.NewMockResourceServerAPI(ctrl) + grantAPI := mock.NewMockClientGrantAPIV3(ctrl) + + c, stdout, _ := newQSTestCLI(display.OutputFormatJSON, true, + &auth0.API{Client: clientAPI, ResourceServer: rsAPI}, &auth0.APIV3{ClientGrant: grantAPI}) + cmd := newQSSetupCmd(t, c, "--app", "--type", "spa", "--framework", "angular", "--port", "4200") + inputs := &SetupInputs{App: true, Type: "spa", Framework: "angular", Port: 4200} + + err := executeSetupQuickstart(cmd, c, inputs, false) + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Equal(t, "file_exists", errorReason(err)) + assert.Contains(t, err.Error(), "--force") + + content, readErr := os.ReadFile(envPath) + require.NoError(t, readErr) + assert.Equal(t, "original", string(content)) + assert.Empty(t, stdout.String(), "no result is printed on failure") +} + +func TestExecuteSetupQuickstart_FileExistsRefusesBeforeAPIRead(t *testing.T) { + // With --api too, the refusal must come before collectAPIInputs' ResourceServer.Read. + dir := t.TempDir() + t.Chdir(dir) + writeQSFile(t, filepath.Join("src", "environments", "environment.ts"), "original", 0644) + + ctrl := gomock.NewController(t) + c, _, _ := newQSTestCLI(display.OutputFormatJSON, true, + &auth0.API{Client: mock.NewMockClientAPI(ctrl), ResourceServer: mock.NewMockResourceServerAPI(ctrl)}, + &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}) + cmd := newQSSetupCmd(t, c, "--app", "--api", "--type", "spa", "--framework", "angular", "--port", "4200", + "--identifier", "https://my-api") + inputs := &SetupInputs{App: true, API: true, Type: "spa", Framework: "angular", Port: 4200, + Identifier: "https://my-api", SigningAlg: "RS256", TokenLifetime: "86400"} + + err := executeSetupQuickstart(cmd, c, inputs, false) + require.Error(t, err) + assert.Equal(t, "file_exists", errorReason(err)) +} + +func TestExecuteSetupQuickstart_ForceOverwritesWithBackup(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + envPath := filepath.Join("src", "environments", "environment.ts") + writeQSFile(t, envPath, "original", 0644) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + + c, stdout, _ := newQSTestCLI(display.OutputFormatJSON, true, &auth0.API{Client: clientAPI}, nil) + cmd := newQSSetupCmd(t, c, "--app", "--type", "spa", "--framework", "angular", "--port", "4200", "--force") + require.True(t, c.force, "--force must bind to cli.force") + inputs := &SetupInputs{App: true, Type: "spa", Framework: "angular", Port: 4200} + + require.NoError(t, executeSetupQuickstart(cmd, c, inputs, false)) + + backup, err := os.ReadFile(envPath + ".auth0-backup") + require.NoError(t, err) + assert.Equal(t, "original", string(backup)) + content, err := os.ReadFile(envPath) + require.NoError(t, err) + assert.Contains(t, string(content), "app-cid") + + doc := decodeSingleJSON(t, stdout.Bytes()) + var cfg display.QuickstartSetupConfigFile + require.NoError(t, json.Unmarshal(doc["config_file"], &cfg)) + assert.Equal(t, qsConfigOverwritten, cfg.Action) + require.NotNil(t, cfg.Backup) + assert.Equal(t, envPath+".auth0-backup", *cfg.Backup) +} + +func TestExecuteSetupQuickstart_InteractiveDeclineSkips(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + envPath := filepath.Join("src", "environments", "environment.ts") + writeQSFile(t, envPath, "original", 0644) + + orig := qsConfirmOverwrite + t.Cleanup(func() { qsConfirmOverwrite = orig }) + asked := 0 + qsConfirmOverwrite = func(string) bool { asked++; return false } + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + + c, stdout, _ := newQSTestCLI(display.OutputFormatJSON, false, &auth0.API{Client: clientAPI}, nil) + cmd := newQSSetupCmd(t, c, "--app", "--name", "My App", "--type", "spa", "--framework", "angular", "--port", "4200") + inputs := &SetupInputs{App: true, Name: "My App", Type: "spa", Framework: "angular", Port: 4200} + + require.NoError(t, executeSetupQuickstart(cmd, c, inputs, true)) + assert.Equal(t, 1, asked, "the overwrite prompt must be asked exactly once") + + content, err := os.ReadFile(envPath) + require.NoError(t, err) + assert.Equal(t, "original", string(content)) + + doc := decodeSingleJSON(t, stdout.Bytes()) + var cfg display.QuickstartSetupConfigFile + require.NoError(t, json.Unmarshal(doc["config_file"], &cfg)) + assert.Equal(t, qsConfigSkipped, cfg.Action) + assert.Nil(t, cfg.Backup) + assert.JSONEq(t, `[]`, string(doc["keys_written"]), "nothing was written") +} + +func TestExecuteSetupQuickstart_DotenvMergeNoForceInNoInput(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeQSFile(t, ".env", "DATABASE_URL=postgres://x\nVITE_AUTH0_DOMAIN=old.example\n", 0644) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + + c, stdout, _ := newQSTestCLI(display.OutputFormatJSON, true, &auth0.API{Client: clientAPI}, nil) + cmd := newQSSetupCmd(t, c, "--app", "--type", "spa", "--framework", "react", "--build-tool", "vite", "--port", "5173") + inputs := &SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "vite", Port: 5173} + + require.NoError(t, executeSetupQuickstart(cmd, c, inputs, false)) + + content, err := os.ReadFile(".env") + require.NoError(t, err) + assert.Equal(t, + "DATABASE_URL=postgres://x\nVITE_AUTH0_DOMAIN=\""+qsTestTenant+"\"\nVITE_AUTH0_CLIENT_ID=\"app-cid\"\n", + string(content)) + assert.Equal(t, os.FileMode(0644), fileMode(t, ".env")) + + doc := decodeSingleJSON(t, stdout.Bytes()) + var cfg display.QuickstartSetupConfigFile + require.NoError(t, json.Unmarshal(doc["config_file"], &cfg)) + assert.Equal(t, qsConfigMerged, cfg.Action) + assert.Equal(t, ".env", cfg.Path) + assert.Equal(t, "dotenv", cfg.Format) +} + +// -- #10 JSON result --. + +func TestExecuteSetupQuickstart_JSONResult_AppAndAPI(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "super-secret") + + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Read(gomock.Any(), gomock.Any()).Return(nil, errors.New("404 Not Found")) + rsAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, rs *management.ResourceServer, _ ...management.RequestOption) error { + id := "rs-1" + rs.ID = &id + return nil + }) + + var grantBody map[string]json.RawMessage + grantAPI := mock.NewMockClientGrantAPIV3(ctrl) + grantAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.CreateClientGrantRequestContent, _ ...option.RequestOption) (*managementv3.CreateClientGrantResponseContent, error) { + b, err := json.Marshal(req) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(b, &grantBody)) + return &managementv3.CreateClientGrantResponseContent{ID: auth0.String("cgr_1")}, nil + }) + + c, stdout, _ := newQSTestCLI(display.OutputFormatJSON, true, + &auth0.API{Client: clientAPI, ResourceServer: rsAPI}, &auth0.APIV3{ClientGrant: grantAPI}) + cmd := newQSSetupCmd(t, c, + "--app", "--api", "--name", "My SPA", "--type", "spa", "--framework", "react", "--build-tool", "vite", + "--port", "5173", "--identifier", "https://my-api", "--scopes", "read:x,write:x") + inputs := &SetupInputs{ + App: true, API: true, Name: "My SPA", Type: "spa", Framework: "react", BuildTool: "vite", Port: 5173, + Identifier: "https://my-api", SigningAlg: "RS256", TokenLifetime: "86400", Scopes: "read:x,write:x", + } + + require.NoError(t, executeSetupQuickstart(cmd, c, inputs, false)) + + out := stdout.Bytes() + doc := decodeSingleJSON(t, out) + assert.NotContains(t, string(out), "super-secret", "secrets must never reach stdout") + assert.NotContains(t, string(out), qsTestTenant, "config values must never reach stdout") + + var res display.QuickstartSetupResult + require.NoError(t, json.Unmarshal(out, &res)) + + require.NotNil(t, res.App) + assert.Equal(t, "app-cid", res.App.ClientID) + assert.Equal(t, "My SPA", res.App.Name) + assert.Equal(t, "spa", res.App.Type) + assert.Equal(t, []string{"http://localhost:5173"}, res.App.Callbacks) + assert.Equal(t, []string{"http://localhost:5173"}, res.App.LogoutURLs) + assert.Equal(t, []string{"http://localhost:5173"}, res.App.WebOrigins) + + require.NotNil(t, res.API) + assert.Equal(t, "https://my-api", res.API.Identifier) + assert.Equal(t, []string{"read:x", "write:x"}, res.API.Scopes) + + require.NotNil(t, res.ClientGrant) + assert.Equal(t, "cgr_1", res.ClientGrant.ID) + assert.Equal(t, "app-cid", res.ClientGrant.ClientID) + assert.Equal(t, "https://my-api", res.ClientGrant.Audience) + assert.Equal(t, []string{"read:x", "write:x"}, res.ClientGrant.Scopes) + assert.JSONEq(t, `["read:x","write:x"]`, string(grantBody["scope"]), "grant scopes default to --scopes") + + require.NotNil(t, res.ConfigFile) + assert.Equal(t, qsConfigCreated, res.ConfigFile.Action) + assert.Equal(t, ".env", res.ConfigFile.Path) + assert.Equal(t, "dotenv", res.ConfigFile.Format) + + var cfgRaw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(doc["config_file"], &cfgRaw)) + assert.Equal(t, "null", string(cfgRaw["backup"])) + + assert.Equal(t, []string{"VITE_AUTH0_AUDIENCE", "VITE_AUTH0_CLIENT_ID", "VITE_AUTH0_DOMAIN"}, res.KeysWritten) + assert.JSONEq(t, `[]`, string(doc["next_steps"]), "next_steps must be [] not null") +} + +func TestExecuteSetupQuickstart_HumanModeNoStdoutJSON(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + + c, stdout, stderr := newQSTestCLI("", false, &auth0.API{Client: clientAPI}, nil) + cmd := newQSSetupCmd(t, c, "--app", "--type", "spa", "--framework", "react", "--build-tool", "vite", "--port", "5173") + inputs := &SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "vite", Port: 5173} + + require.NoError(t, executeSetupQuickstart(cmd, c, inputs, false)) + assert.Empty(t, stdout.String(), "human mode prints no JSON result") + assert.Contains(t, stderr.String(), "file created successfully") +} + +func TestPrintDetectionHeader(t *testing.T) { + t.Parallel() + + for _, format := range []display.OutputFormat{display.OutputFormatJSON, display.OutputFormatJSONCompact} { + c, stdout, _ := newQSTestCLI(format, false, nil, nil) + printDetectionHeader(c) + assert.Empty(t, stdout.String(), "format %q must keep stdout clean", format) + } + + c, stdout, _ := newQSTestCLI("", false, nil, nil) + printDetectionHeader(c) + assert.Contains(t, stdout.String(), "Detected in current directory:") +} + +func TestNativeGuidance(t *testing.T) { + t.Parallel() + + const bundle = "com.example.app" + tests := []struct { + name string + framework string + scheme string + bundleID string + wantLen int + wantSubstr []string + wantWarn []bool + }{ + {"expo with scheme", "expo", "myapp", "", 2, []string{"exp://localhost:19000", "myapp://"}, []bool{false, false}}, + {"expo without scheme", "expo", "", "", 2, []string{"exp://localhost:19000", "custom scheme URI"}, []bool{false, false}}, + {"flutter with bundle", "flutter", "", bundle, 3, []string{ + "Allowed Callback URLs", + bundle + "://" + qsTestTenant + "/android/" + bundle + "/callback", + bundle + "://" + qsTestTenant + "/ios/" + bundle + "/callback", + }, []bool{false, false, false}}, + {"flutter without bundle", "flutter", "", "", 0, nil, nil}, + {"react-native with bundle", "react-native", "", bundle, 3, []string{"", "/android/", "/ios/"}, []bool{false, false, false}}, + {"react-native without bundle", "react-native", "", "", 0, nil, nil}, + {"maui with bundle", "maui", "", bundle, 1, []string{bundle + "://callback"}, []bool{false}}, + {"maui without bundle", "maui", "", "", 0, nil, nil}, + {"dotnet-mobile with bundle", "dotnet-mobile", "", bundle, 1, []string{bundle + "://callback"}, []bool{false}}, + {"ionic with app id", "ionic-react", "", bundle, 2, []string{"Capacitor app ID: " + bundle, "http://localhost"}, []bool{false, false}}, + {"ionic without app id warns", "ionic-angular", "", "", 2, []string{"Could not read Capacitor app ID", "http://localhost"}, []bool{true, false}}, + {"jhipster", "jhipster", "", "", 1, []string{"jhipster.tech"}, []bool{false}}, + {"react has no guidance", "react", "", "", 0, nil, nil}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lines := nativeGuidance(tc.framework, tc.scheme, tc.bundleID, qsTestTenant) + require.Len(t, lines, tc.wantLen) + for i, line := range lines { + assert.Contains(t, line.text, tc.wantSubstr[i]) + assert.Equal(t, tc.wantWarn[i], line.warn, "line %d warn flag", i) + } + }) + } +} + +func TestPrintNativeGuidance_ReturnsTrimmedNextSteps(t *testing.T) { + t.Parallel() + + c, stdout, stderr := newQSTestCLI("", false, nil, nil) + steps := printNativeGuidance(c, "flutter", "", "com.example.app") + require.Len(t, steps, 3) + for _, s := range steps { + assert.Equal(t, strings.TrimSpace(s), s, "next_steps entries must be trimmed") + } + assert.True(t, strings.HasPrefix(steps[1], "Android:")) + assert.True(t, strings.HasPrefix(steps[2], "iOS:")) + assert.Contains(t, stderr.String(), " Android:", "human output keeps the indentation") + assert.Empty(t, stdout.String()) + + assert.Empty(t, printNativeGuidance(c, "react", "", "")) +} + +func TestCreateQuickstartApp_FillsResult(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "super-secret") + + c, _, _ := newQSTestCLI("", false, &auth0.API{Client: clientAPI}, nil) + inputs := SetupInputs{App: true, Name: "My App", Type: "regular", Framework: "nextjs", BuildTool: "none", Port: 3000} + result := &display.QuickstartSetupResult{} + + clientID, err := createQuickstartApp(&cobra.Command{}, c, inputs, "regular:nextjs:none", nil, result) + require.NoError(t, err) + assert.Equal(t, "app-cid", clientID) + + require.NotNil(t, result.App) + assert.Equal(t, "app-cid", result.App.ClientID) + assert.Equal(t, "My App", result.App.Name) + assert.Equal(t, "regular_web", result.App.Type) + assert.Equal(t, []string{"http://localhost:3000/auth/callback"}, result.App.Callbacks) + assert.Equal(t, []string{"http://localhost:3000"}, result.App.LogoutURLs) + + require.NotNil(t, result.ConfigFile) + assert.Equal(t, qsConfigCreated, result.ConfigFile.Action) + assert.Equal(t, ".env", result.ConfigFile.Path) + + assert.Equal(t, []string{"APP_BASE_URL", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_DOMAIN", "AUTH0_SECRET"}, result.KeysWritten) + for _, k := range result.KeysWritten { + assert.NotContains(t, k, "super-secret") + assert.NotContains(t, k, "app-cid") + assert.NotContains(t, k, qsTestTenant) + assert.NotContains(t, k, "=") + } + assert.Empty(t, result.NextSteps) +} + +func TestCreateQuickstartApp_SkippedResultHasNoKeysAndANextStep(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + + c, _, _ := newQSTestCLI("", false, &auth0.API{Client: clientAPI}, nil) + inputs := SetupInputs{App: true, Name: "My App", Type: "native", Framework: "ios-swift", BuildTool: "none"} + plan := &qsConfigWritePlan{Path: "Auth0.plist", Format: "plist", Perm: 0600, Action: qsConfigSkipped} + result := &display.QuickstartSetupResult{} + + _, err := createQuickstartApp(&cobra.Command{}, c, inputs, "native:ios-swift:none", plan, result) + require.NoError(t, err) + require.NotNil(t, result.ConfigFile) + assert.Equal(t, qsConfigSkipped, result.ConfigFile.Action) + assert.Empty(t, result.KeysWritten) + require.NotEmpty(t, result.NextSteps) + assert.Contains(t, strings.Join(result.NextSteps, "\n"), "Auth0.plist") + _, statErr := os.Stat(filepath.Join(dir, "Auth0.plist")) + assert.True(t, os.IsNotExist(statErr)) +} + +// -- #8 grant scopes --. + +func TestValidateGrantScopes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputs SetupInputs + set bool + wantErr bool + wantSubstr string + }{ + {"unset", SetupInputs{API: true, Scopes: "read:x"}, false, false, ""}, + {"unset without api", SetupInputs{App: true}, false, false, ""}, + {"subset", SetupInputs{API: true, Scopes: "read:x, write:x", GrantScopes: []string{"read:x"}}, true, false, ""}, + {"equal set", SetupInputs{API: true, Scopes: "read:x,write:x", GrantScopes: []string{"write:x", "read:x"}}, true, false, ""}, + {"scope not in --scopes", SetupInputs{API: true, Scopes: "read:x", GrantScopes: []string{"read:x", "delete:x"}}, true, true, "delete:x"}, + {"set without --api (D3)", SetupInputs{App: true, GrantScopes: []string{"read:x"}}, true, true, "--api"}, + {"empty --scopes with grant scopes", SetupInputs{API: true, GrantScopes: []string{"read:x"}}, true, true, "read:x"}, + {"explicit empty grant scopes", SetupInputs{API: true, Scopes: "read:x", GrantScopes: []string{}}, true, false, ""}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validateGrantScopes(tc.inputs, tc.set) + if !tc.wantErr { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Equal(t, "invalid_flag_value", errorReason(err)) + assert.Contains(t, err.Error(), tc.wantSubstr) + }) + } +} + +func TestParseScopeList(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{}, parseScopeList("")) + assert.NotNil(t, parseScopeList(""), "never nil") + assert.Equal(t, []string{}, parseScopeList(" , ,")) + assert.Equal(t, []string{"read:x", "write:x"}, parseScopeList(" read:x, write:x ,")) +} + +func TestNormalizeScopeSlice(t *testing.T) { + t.Parallel() + + assert.Nil(t, normalizeScopeSlice(nil), "nil stays nil (flag unset)") + got := normalizeScopeSlice([]string{}) + assert.NotNil(t, got, "explicit empty stays non-nil") + assert.Empty(t, got) + got = normalizeScopeSlice([]string{" "}) + assert.NotNil(t, got) + assert.Empty(t, got) + assert.Equal(t, []string{"read:x", "write:x"}, normalizeScopeSlice([]string{" read:x ", "", "write:x"})) +} + +// runGrantScopesCase runs createQuickstartAPI with a linked app and returns the +// marshalled grant request body. +func runGrantScopesCase(t *testing.T, inputs SetupInputs, resp *managementv3.CreateClientGrantResponseContent, result *display.QuickstartSetupResult) map[string]json.RawMessage { + t.Helper() + ctrl := gomock.NewController(t) + + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + var body map[string]json.RawMessage + grantAPI := mock.NewMockClientGrantAPIV3(ctrl) + grantAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.CreateClientGrantRequestContent, _ ...option.RequestOption) (*managementv3.CreateClientGrantResponseContent, error) { + b, err := json.Marshal(req) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(b, &body)) + return resp, nil + }) + + c, _, _ := newQSTestCLI("", false, &auth0.API{ResourceServer: rsAPI}, &auth0.APIV3{ClientGrant: grantAPI}) + require.NoError(t, createQuickstartAPI(context.Background(), c, inputs, result)) + return body +} + +func grantTestInputs() SetupInputs { + return SetupInputs{ + API: true, Name: "My App", Identifier: "https://my-api", SigningAlg: "RS256", + TokenLifetime: "86400", LinkedAppID: "linked-app-client-id", + } +} + +func TestCreateQuickstartAPI_GrantScopesDefaultToScopes(t *testing.T) { + t.Parallel() + in := grantTestInputs() + in.Scopes = "read:x, write:x" + body := runGrantScopesCase(t, in, nil, nil) + assert.JSONEq(t, `["read:x","write:x"]`, string(body["scope"])) +} + +func TestCreateQuickstartAPI_GrantScopesOverride(t *testing.T) { + t.Parallel() + in := grantTestInputs() + in.Scopes = "read:x,write:x" + in.GrantScopes = []string{"read:x"} + body := runGrantScopesCase(t, in, nil, nil) + assert.JSONEq(t, `["read:x"]`, string(body["scope"])) +} + +func TestCreateQuickstartAPI_GrantScopesExplicitEmpty(t *testing.T) { + t.Parallel() + in := grantTestInputs() + in.Scopes = "read:x" + in.GrantScopes = []string{} + body := runGrantScopesCase(t, in, nil, nil) + require.Contains(t, body, "scope", "an explicit empty list must still be sent") + assert.JSONEq(t, `[]`, string(body["scope"])) +} + +func TestCreateQuickstartAPI_GrantFailureIsError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + boom := errors.New("boom") + grantAPI := mock.NewMockClientGrantAPIV3(ctrl) + grantAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil, boom) + + c, _, _ := newQSTestCLI("", false, &auth0.API{ResourceServer: rsAPI}, &auth0.APIV3{ClientGrant: grantAPI}) + result := &display.QuickstartSetupResult{} + err := createQuickstartAPI(context.Background(), c, grantTestInputs(), result) + require.Error(t, err) + assert.Contains(t, err.Error(), "linked-app-client-id") + assert.Contains(t, err.Error(), "https://my-api") + assert.Contains(t, err.Error(), "boom") + assert.ErrorIs(t, err, boom) + assert.Nil(t, result.ClientGrant, "no grant section on failure") +} + +func TestCreateQuickstartAPI_FillsResult(t *testing.T) { + t.Parallel() + + t.Run("nil grant response leaves ID empty", func(t *testing.T) { + t.Parallel() + in := grantTestInputs() + in.Scopes = "read:x" + result := &display.QuickstartSetupResult{} + runGrantScopesCase(t, in, nil, result) + + require.NotNil(t, result.API) + assert.Equal(t, "https://my-api", result.API.Identifier) + assert.Equal(t, "My App-API", result.API.Name) + assert.Equal(t, []string{"read:x"}, result.API.Scopes) + + require.NotNil(t, result.ClientGrant) + assert.Equal(t, "", result.ClientGrant.ID) + assert.Equal(t, "linked-app-client-id", result.ClientGrant.ClientID) + assert.Equal(t, "https://my-api", result.ClientGrant.Audience) + assert.Equal(t, []string{"read:x"}, result.ClientGrant.Scopes) + }) + + t.Run("grant ID from response", func(t *testing.T) { + t.Parallel() + result := &display.QuickstartSetupResult{} + runGrantScopesCase(t, grantTestInputs(), &managementv3.CreateClientGrantResponseContent{ID: auth0.String("cgr_9")}, result) + require.NotNil(t, result.ClientGrant) + assert.Equal(t, "cgr_9", result.ClientGrant.ID) + assert.NotNil(t, result.ClientGrant.Scopes) + assert.Empty(t, result.ClientGrant.Scopes) + }) + + t.Run("no linked app leaves client_grant nil", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + c, _, _ := newQSTestCLI("", false, &auth0.API{ResourceServer: rsAPI}, + &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}) + in := grantTestInputs() + in.LinkedAppID = "" + result := &display.QuickstartSetupResult{} + require.NoError(t, createQuickstartAPI(context.Background(), c, in, result)) + require.NotNil(t, result.API) + assert.Nil(t, result.ClientGrant) + }) +} + +// -- #3 multi-value URL flags --. + +func TestGenerateClient_URLOverrides(t *testing.T) { + t.Parallel() + + const def = "http://localhost:5173" + tests := []struct { + name string + callbacks, logout, origins []string + wantCB, wantLogout, wantOrig []string + }{ + { + name: "multi-value lists replace the defaults", + callbacks: []string{"http://a", "http://b"}, + logout: []string{"http://l1", "http://l2"}, + origins: []string{"http://w"}, + wantCB: []string{"http://a", "http://b"}, wantLogout: []string{"http://l1", "http://l2"}, wantOrig: []string{"http://w"}, + }, + { + name: "nil keeps the defaults", + wantCB: []string{def}, wantLogout: []string{def}, wantOrig: []string{def}, + }, + { + name: "blanks are trimmed and dropped; an all-blank list keeps the default", + callbacks: []string{" http://a ", ""}, + logout: []string{"", " "}, + wantCB: []string{"http://a"}, wantLogout: []string{def}, wantOrig: []string{def}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := auth0.QuickstartConfigs["spa:react:vite"] + c, err := generateClient(SetupInputs{ + Name: "App", Port: 5173, + CallbackURLs: tc.callbacks, LogoutURLs: tc.logout, WebOriginURLs: tc.origins, + }, cfg.RequestParams) + require.NoError(t, err) + assert.Equal(t, tc.wantCB, c.GetCallbacks()) + assert.Equal(t, tc.wantLogout, c.GetAllowedLogoutURLs()) + assert.Equal(t, tc.wantOrig, c.GetWebOrigins()) + }) + } +} + +func TestCleanURLList(t *testing.T) { + t.Parallel() + + assert.Nil(t, cleanURLList(nil)) + assert.Nil(t, cleanURLList([]string{"", " "})) + assert.Equal(t, []string{"http://a", "http://b"}, cleanURLList([]string{" http://a", "", "http://b "})) +} + +func TestSetupQuickstartCmd_URLFlagsMultiValue(t *testing.T) { + t.Parallel() + + cmd := setupQuickstartCmd(&cli{}) + require.NoError(t, cmd.ParseFlags([]string{ + "--callback-url", "http://a,http://b", "--callback-url", "http://c", + "--logout-url", "http://l", + "--web-origin-url", "http://w1,http://w2", + "--grant-scopes", "read:x,write:x", "--grant-scopes", "admin", + })) + + got, err := cmd.Flags().GetStringSlice("callback-url") + require.NoError(t, err) + assert.Equal(t, []string{"http://a", "http://b", "http://c"}, got) + + got, err = cmd.Flags().GetStringSlice("logout-url") + require.NoError(t, err) + assert.Equal(t, []string{"http://l"}, got) + + got, err = cmd.Flags().GetStringSlice("web-origin-url") + require.NoError(t, err) + assert.Equal(t, []string{"http://w1", "http://w2"}, got) + + got, err = cmd.Flags().GetStringSlice("grant-scopes") + require.NoError(t, err) + assert.Equal(t, []string{"read:x", "write:x", "admin"}, got) +} + +// -- #2 help / flags --. + +func TestSetupBuildToolHelp(t *testing.T) { + t.Parallel() + + assert.Equal(t, "Build tool used by the project (composer, gradle, maven, none, vite, webpack)", setupBuildTool.Help) + assert.Equal(t, "Build tool used by the project ("+strings.Join(auth0.SupportedBuildTools(), ", ")+")", setupBuildTool.Help) + assert.NotContains(t, setupBuildTool.Help, "cra") +} + +func TestSetupQuickstartCmd_RegistersNewFlags(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + cmd := setupQuickstartCmd(&cli{}) + for _, name := range []string{"force", "grant-scopes", "json", "json-compact", "callback-url", "logout-url", "web-origin-url"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "flag --%s must be registered", name) + } + assert.Equal(t, "stringSlice", cmd.Flags().Lookup("callback-url").Value.Type()) + assert.Equal(t, "stringSlice", cmd.Flags().Lookup("grant-scopes").Value.Type()) + assert.Equal(t, "bool", cmd.Flags().Lookup("force").Value.Type()) + + cmd = setupQuickstartCmd(&cli{}) + cmd.SetArgs([]string{"--app", "--json", "--json-compact"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "json") + assert.Contains(t, err.Error(), "json-compact") + assert.Contains(t, err.Error(), "none of the others can be") +} + +func TestDefaultPortForSetup(t *testing.T) { + t.Parallel() + + tests := []struct { + framework, buildTool string + want int + }{ + {"react", "webpack", 8080}, + {"vanilla-javascript", "webpack", 8080}, + {"react", "vite", 5173}, + {"vanilla-javascript", "vite", 5173}, + {"tanstack-start", "none", 3000}, + {"angular", "none", 4200}, + {"nextjs", "", 3000}, + } + for _, tc := range tests { + assert.Equal(t, tc.want, defaultPortForSetup(tc.framework, tc.buildTool), "%s/%s", tc.framework, tc.buildTool) + } +} diff --git a/internal/display/quickstarts.go b/internal/display/quickstarts.go index 0b25639a6..281a341be 100644 --- a/internal/display/quickstarts.go +++ b/internal/display/quickstarts.go @@ -62,3 +62,85 @@ func qsAppTypeFor(s string) string { return "" } } + +// QuickstartSetupResult is the single machine-readable result of `auth0 qs setup`. +// Sections that don't apply are null; list fields are always arrays. +type QuickstartSetupResult struct { + App *QuickstartSetupApp `json:"app"` + API *QuickstartSetupAPI `json:"api"` + ClientGrant *QuickstartSetupClientGrant `json:"client_grant"` + ConfigFile *QuickstartSetupConfigFile `json:"config_file"` + KeysWritten []string `json:"keys_written"` // Names only, never values. + NextSteps []string `json:"next_steps"` +} + +// QuickstartSetupApp describes the application created by `auth0 qs setup`. +type QuickstartSetupApp struct { + ClientID string `json:"client_id"` + Name string `json:"name"` + Type string `json:"type"` // Client app type: spa | regular_web | native | non_interactive. + Callbacks []string `json:"callbacks"` + LogoutURLs []string `json:"logout_urls"` + WebOrigins []string `json:"web_origins"` +} + +// QuickstartSetupAPI describes the API (resource server) created by `auth0 qs setup`. +type QuickstartSetupAPI struct { + Identifier string `json:"identifier"` + Name string `json:"name"` + Scopes []string `json:"scopes"` +} + +// QuickstartSetupClientGrant describes the client grant linking the app to the API. +type QuickstartSetupClientGrant struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + Audience string `json:"audience"` + Scopes []string `json:"scopes"` +} + +// QuickstartSetupConfigFile describes what happened to the generated config file. +type QuickstartSetupConfigFile struct { + Path string `json:"path"` + Format string `json:"format"` + Action string `json:"action"` // Created | merged | overwritten | skipped. + Backup *string `json:"backup"` // Null unless overwritten. +} + +// QuickstartSetup is a no-op in the human format, whose output is already printed. +func (r *Renderer) QuickstartSetup(res *QuickstartSetupResult) { + if res == nil { + return + } + if r.Format != OutputFormatJSON && r.Format != OutputFormatJSONCompact { + return + } + + res.KeysWritten = nonNilStrings(res.KeysWritten) + res.NextSteps = nonNilStrings(res.NextSteps) + if res.App != nil { + res.App.Callbacks = nonNilStrings(res.App.Callbacks) + res.App.LogoutURLs = nonNilStrings(res.App.LogoutURLs) + res.App.WebOrigins = nonNilStrings(res.App.WebOrigins) + } + if res.API != nil { + res.API.Scopes = nonNilStrings(res.API.Scopes) + } + if res.ClientGrant != nil { + res.ClientGrant.Scopes = nonNilStrings(res.ClientGrant.Scopes) + } + + if r.Format == OutputFormatJSONCompact { + r.JSONCompactResult(res) + return + } + r.JSONResult(res) +} + +// nonNilStrings keeps JSON list fields as [] instead of null. +func nonNilStrings(in []string) []string { + if in == nil { + return []string{} + } + return in +} From 319214e014c300808b29e59005fbdb754521d70e Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Mon, 19 Oct 2026 18:35:35 +0530 Subject: [PATCH 2/3] feat(quickstart): improve error messages for application creation and config generation failures --- internal/cli/quickstarts.go | 12 ++- internal/cli/quickstarts_test.go | 62 ++++++++++++++ internal/display/quickstarts_test.go | 118 +++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 internal/display/quickstarts_test.go diff --git a/internal/cli/quickstarts.go b/internal/cli/quickstarts.go index a11ff1059..067df439b 100644 --- a/internal/cli/quickstarts.go +++ b/internal/cli/quickstarts.go @@ -1149,7 +1149,8 @@ func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsCon cfgOut, err := cli.writeQuickstartConfig(cmd, resolvedEnv, &config.Strategy, plan) if err != nil { - return "", fmt.Errorf("failed to generate config file: %w", err) + return "", fmt.Errorf("created application %q (client ID %s) but failed to generate config file: %w", + client.GetName(), client.GetClientID(), err) } printClientDetails(cli, client) @@ -1171,6 +1172,11 @@ func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsCon } else { result.KeysWritten = sortedKeys(resolvedEnv) // Names only, never values. } + if cfgOut.Backup != nil { + result.NextSteps = append(result.NextSteps, fmt.Sprintf( + "Review the backup %s, then delete it or add it to .gitignore: it is a copy of the previous file and may contain secrets", + *cfgOut.Backup)) + } result.NextSteps = append(result.NextSteps, steps...) } @@ -1325,6 +1331,10 @@ func createQuickstartAPI(ctx context.Context, cli *cli, inputs SetupInputs, resu if err := ansi.Waiting(func() error { return cli.api.ResourceServer.Create(ctx, rs) }); err != nil { + if inputs.App && inputs.LinkedAppID != "" { + return fmt.Errorf("failed to create API with name %q and identifier %q (application %s was already created): %w", + apiName, inputs.Identifier, inputs.LinkedAppID, err) + } return fmt.Errorf("failed to create API with name %q and identifier %q: %w", apiName, inputs.Identifier, err) } printAPIDetails(cli, rs) diff --git a/internal/cli/quickstarts_test.go b/internal/cli/quickstarts_test.go index aa4a76418..b47027db2 100644 --- a/internal/cli/quickstarts_test.go +++ b/internal/cli/quickstarts_test.go @@ -1553,6 +1553,10 @@ func TestExecuteSetupQuickstart_ForceOverwritesWithBackup(t *testing.T) { assert.Equal(t, qsConfigOverwritten, cfg.Action) require.NotNil(t, cfg.Backup) assert.Equal(t, envPath+".auth0-backup", *cfg.Backup) + + var steps []string + require.NoError(t, json.Unmarshal(doc["next_steps"], &steps)) + assert.Contains(t, strings.Join(steps, "\n"), envPath+".auth0-backup", "next_steps must point at the backup") } func TestExecuteSetupQuickstart_InteractiveDeclineSkips(t *testing.T) { @@ -1617,6 +1621,11 @@ func TestExecuteSetupQuickstart_DotenvMergeNoForceInNoInput(t *testing.T) { assert.Equal(t, qsConfigMerged, cfg.Action) assert.Equal(t, ".env", cfg.Path) assert.Equal(t, "dotenv", cfg.Format) + assert.Nil(t, cfg.Backup) + + var steps []string + require.NoError(t, json.Unmarshal(doc["next_steps"], &steps)) + assert.NotContains(t, strings.ToLower(strings.Join(steps, "\n")), "backup", "a merge writes no backup") } // -- #10 JSON result --. @@ -1998,6 +2007,59 @@ func TestCreateQuickstartAPI_GrantFailureIsError(t *testing.T) { assert.Nil(t, result.ClientGrant, "no grant section on failure") } +func TestCreateQuickstartAPI_APICreateFailureNamesCreatedApp(t *testing.T) { + t.Parallel() + + boom := errors.New("boom") + run := func(t *testing.T, appCreatedInThisRun bool) error { + ctrl := gomock.NewController(t) + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(boom) + + c, _, _ := newQSTestCLI("", false, &auth0.API{ResourceServer: rsAPI}, &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}) + inputs := grantTestInputs() + inputs.App = appCreatedInThisRun + return createQuickstartAPI(context.Background(), c, inputs, nil) + } + + t.Run("app created in this run is named in the error", func(t *testing.T) { + t.Parallel() + err := run(t, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "linked-app-client-id") + assert.Contains(t, err.Error(), "already created") + assert.ErrorIs(t, err, boom) + }) + + t.Run("linking an existing app does not claim it was created", func(t *testing.T) { + t.Parallel() + err := run(t, false) + require.Error(t, err) + assert.NotContains(t, err.Error(), "already created") + assert.ErrorIs(t, err, boom) + }) +} + +func TestCreateQuickstartApp_ConfigWriteFailureNamesCreatedApp(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + // A directory where the config file should go: the write fails after the pre-flight passed. + require.NoError(t, os.Mkdir(".env", 0755)) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + c, _, _ := newQSTestCLI("", false, &auth0.API{Client: clientAPI}, nil) + + plan := &qsConfigWritePlan{Path: ".env", Format: "dotenv", Action: qsConfigCreated, Perm: 0600} + inputs := SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "vite", Name: "My App", Port: 5173} + _, err := createQuickstartApp(&cobra.Command{}, c, inputs, "spa:react:vite", plan, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "app-cid", "the error must name the application that now exists") + assert.Contains(t, err.Error(), "failed to generate config file") +} + func TestCreateQuickstartAPI_FillsResult(t *testing.T) { t.Parallel() diff --git a/internal/display/quickstarts_test.go b/internal/display/quickstarts_test.go new file mode 100644 index 000000000..66b2c6785 --- /dev/null +++ b/internal/display/quickstarts_test.go @@ -0,0 +1,118 @@ +package display + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func qsSetupAppOnlyResult() *QuickstartSetupResult { + return &QuickstartSetupResult{ + App: &QuickstartSetupApp{ + ClientID: "cid", + Name: "My App", + Type: "regular_web", + Callbacks: []string{"http://localhost:3000/callback"}, + // LogoutURLs and WebOrigins intentionally nil. + }, + ConfigFile: &QuickstartSetupConfigFile{Path: ".env", Format: "dotenv", Action: "created"}, + // KeysWritten and NextSteps intentionally nil. + } +} + +func TestRenderer_QuickstartSetup_JSON(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + r := &Renderer{ResultWriter: stdout, MessageWriter: stderr, Format: OutputFormatJSON} + + r.QuickstartSetup(qsSetupAppOnlyResult()) + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal(stdout.Bytes(), &doc), "output: %s", stdout.String()) + + assert.Equal(t, "null", string(doc["api"])) + assert.Equal(t, "null", string(doc["client_grant"])) + assert.JSONEq(t, `[]`, string(doc["keys_written"])) + assert.JSONEq(t, `[]`, string(doc["next_steps"])) + + var cfg map[string]json.RawMessage + require.NoError(t, json.Unmarshal(doc["config_file"], &cfg)) + assert.Equal(t, "null", string(cfg["backup"])) + assert.JSONEq(t, `".env"`, string(cfg["path"])) + assert.JSONEq(t, `"dotenv"`, string(cfg["format"])) + assert.JSONEq(t, `"created"`, string(cfg["action"])) + + var app map[string]json.RawMessage + require.NoError(t, json.Unmarshal(doc["app"], &app)) + assert.JSONEq(t, `"cid"`, string(app["client_id"])) + assert.JSONEq(t, `"My App"`, string(app["name"])) + assert.JSONEq(t, `"regular_web"`, string(app["type"])) + assert.JSONEq(t, `["http://localhost:3000/callback"]`, string(app["callbacks"])) + assert.JSONEq(t, `[]`, string(app["logout_urls"])) + assert.JSONEq(t, `[]`, string(app["web_origins"])) + + assert.Empty(t, stderr.String()) +} + +func TestRenderer_QuickstartSetup_JSON_FullShape(t *testing.T) { + stdout := &bytes.Buffer{} + r := &Renderer{ResultWriter: stdout, MessageWriter: &bytes.Buffer{}, Format: OutputFormatJSON} + + backup := "src/environments/environment.ts.auth0-backup" + r.QuickstartSetup(&QuickstartSetupResult{ + App: &QuickstartSetupApp{ClientID: "cid", Name: "n", Type: "spa"}, + API: &QuickstartSetupAPI{Identifier: "https://my-api", Name: "n-API"}, + ClientGrant: &QuickstartSetupClientGrant{ID: "cgr_1", ClientID: "cid", Audience: "https://my-api"}, + ConfigFile: &QuickstartSetupConfigFile{Path: "src/environments/environment.ts", Format: "angular-ts", Action: "overwritten", Backup: &backup}, + KeysWritten: []string{"clientId", "domain"}, + NextSteps: []string{"do x"}, + }) + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal(stdout.Bytes(), &doc)) + for _, k := range []string{"app", "api", "client_grant", "config_file", "keys_written", "next_steps"} { + assert.Contains(t, doc, k) + } + var api, grant, cfg map[string]json.RawMessage + require.NoError(t, json.Unmarshal(doc["api"], &api)) + require.NoError(t, json.Unmarshal(doc["client_grant"], &grant)) + require.NoError(t, json.Unmarshal(doc["config_file"], &cfg)) + assert.JSONEq(t, `[]`, string(api["scopes"]), "nil nested scopes normalize to []") + assert.JSONEq(t, `[]`, string(grant["scopes"])) + assert.JSONEq(t, `"cgr_1"`, string(grant["id"])) + assert.JSONEq(t, `"`+backup+`"`, string(cfg["backup"])) + assert.JSONEq(t, `["clientId","domain"]`, string(doc["keys_written"])) + assert.JSONEq(t, `["do x"]`, string(doc["next_steps"])) +} + +func TestRenderer_QuickstartSetup_JSONCompact(t *testing.T) { + stdout := &bytes.Buffer{} + r := &Renderer{ResultWriter: stdout, MessageWriter: &bytes.Buffer{}, Format: OutputFormatJSONCompact} + + r.QuickstartSetup(qsSetupAppOnlyResult()) + + out := stdout.String() + assert.True(t, strings.HasSuffix(out, "\n"), "compact output ends with a newline") + assert.Equal(t, 1, strings.Count(out, "\n"), "compact output is a single line") + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(out), &doc)) + assert.JSONEq(t, `[]`, string(doc["keys_written"])) +} + +func TestRenderer_QuickstartSetup_HumanNoOutput(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + r := &Renderer{ResultWriter: stdout, MessageWriter: stderr} + + r.QuickstartSetup(qsSetupAppOnlyResult()) + assert.Empty(t, stdout.String(), "human format prints nothing; output was already rendered") + assert.Empty(t, stderr.String()) + + for _, f := range []OutputFormat{"", OutputFormatJSON, OutputFormatJSONCompact} { + r := &Renderer{ResultWriter: stdout, MessageWriter: stderr, Format: f} + assert.NotPanics(t, func() { r.QuickstartSetup(nil) }) + } + assert.Empty(t, stdout.String(), "nil result prints nothing") +} From b402c58fb93b2ac0b512df551635a4a7ec53ee48 Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Mon, 19 Oct 2026 20:10:03 +0530 Subject: [PATCH 3/3] feat(quickstart): enhance webpack detection and improve grant scopes documentation --- docs/auth0_quickstarts_setup.md | 2 +- internal/cli/quickstart_detect.go | 39 ++-- internal/cli/quickstart_detect_test.go | 91 +++++++- internal/cli/quickstarts.go | 219 +++++++++++++----- internal/cli/quickstarts_test.go | 294 ++++++++++++++++++++++++- internal/display/quickstarts.go | 24 +- internal/display/quickstarts_test.go | 22 ++ 7 files changed, 619 insertions(+), 72 deletions(-) diff --git a/docs/auth0_quickstarts_setup.md b/docs/auth0_quickstarts_setup.md index 9f9d82b53..d23c5407f 100644 --- a/docs/auth0_quickstarts_setup.md +++ b/docs/auth0_quickstarts_setup.md @@ -53,7 +53,7 @@ auth0 quickstarts setup [flags] --callback-url strings Override the allowed callback URLs for the application. Comma-separate or repeat the flag for multiple values; replaces the default list. --force Replace an existing config file without prompting (a backup is written first). In agent mode or with --no-input, an existing .env is merged instead, and any other existing config file is only replaced with --force. --framework string Framework to configure (e.g., react, nextjs, vue, express) - --grant-scopes strings [API] Comma-separated subset of --scopes to grant the app created with --app or given with --linked-app-id (default: all --scopes). Every value must also be listed in --scopes. + --grant-scopes strings [API] Comma-separated subset of --scopes to grant the app created with --app or given with --linked-app-id (default: all --scopes). Every value must also be listed in --scopes. An empty value grants no scopes. --identifier string Unique URL identifier for the API (audience), e.g. https://my-api --json Output in json format. --json-compact Output in compact json format. diff --git a/internal/cli/quickstart_detect.go b/internal/cli/quickstart_detect.go index c3e114bb3..219d3842b 100644 --- a/internal/cli/quickstart_detect.go +++ b/internal/cli/quickstart_detect.go @@ -187,19 +187,6 @@ func DetectProject(dir string) DetectionResult { return result } - // -- 11b. Webpack.config.[js|ts|cjs|mjs] (no vite config — the vite rule ran first). - if fileExistsAny(dir, "webpack.config.js", "webpack.config.ts", "webpack.config.cjs", "webpack.config.mjs") { - result.Type = "spa" - result.BuildTool = "webpack" - result.Detected = true - if hasDep(earlyDeps, "react") { - result.Framework = "react" - } else { - result.Framework = "vanilla-javascript" - } - return result - } - // -- 12. .csproj --. if content, ok := findCsprojContent(dir); ok { if fw, qsType, found := detectFromCsproj(content); found { @@ -305,9 +292,35 @@ func DetectProject(dir string) DetectionResult { } } + // -- 20. Webpack.config.[js|ts|cjs|mjs] — last, because many non-SPA projects ship a webpack config. + if !result.Detected && fileExistsAny(dir, "webpack.config.js", "webpack.config.ts", "webpack.config.cjs", "webpack.config.mjs") { + if framework, ok := detectWebpackFramework(earlyDeps); ok { + result.Framework = framework + result.Type = "spa" + result.BuildTool = "webpack" + result.Detected = true + } + } + return result } +// detectWebpackFramework maps client-side dependencies to a webpack SPA framework. A webpack +// config alone is not enough: other frameworks (Vue, Svelte, Angular) have no webpack quickstart, +// and server bundles also use webpack. +func detectWebpackFramework(deps map[string]bool) (string, bool) { + switch { + case hasDep(deps, "react"): + return "react", true + case hasDep(deps, "vue"), hasDep(deps, "svelte"), hasDep(deps, "@angular/core"): + return "", false + case hasDep(deps, "webpack-dev-server"): + return "vanilla-javascript", true + default: + return "", false + } +} + // collectPackageJSONCandidates returns framework candidates from package.json deps. // Ionic deps are handled earlier in DetectProject (step 2) and excluded here. func collectPackageJSONCandidates(deps map[string]bool) []detectionCandidate { diff --git a/internal/cli/quickstart_detect_test.go b/internal/cli/quickstart_detect_test.go index 5036daeb4..6c91b5039 100644 --- a/internal/cli/quickstart_detect_test.go +++ b/internal/cli/quickstart_detect_test.go @@ -3199,7 +3199,7 @@ func TestDetectProject_ReactWebpack(t *testing.T) { func TestDetectProject_VanillaJavaScriptWebpack(t *testing.T) { dir := t.TempDir() writeTestFile(t, dir, "webpack.config.js", "") - writeTestFile(t, dir, "package.json", `{"name":"my-app","devDependencies":{"webpack":"^5"}}`) + writeTestFile(t, dir, "package.json", `{"name":"my-app","devDependencies":{"webpack":"^5","webpack-dev-server":"^5"}}`) got := DetectProject(dir) assert.True(t, got.Detected) @@ -3208,6 +3208,95 @@ func TestDetectProject_VanillaJavaScriptWebpack(t *testing.T) { assert.Equal(t, "webpack", got.BuildTool) } +// A webpack config alone is not an SPA signal: it needs react or webpack-dev-server, and the +// framework-specific rules that run earlier keep their projects. +func TestDetectProject_WebpackConfigDoesNotOverrideOtherFrameworks(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantFw string + wantFound bool + }{ + { + name: "aspnet csproj", + files: map[string]string{ + "webpack.config.js": "", + "app.csproj": ``, + }, + wantFw: "aspnet-mvc", wantFound: true, + }, + { + name: "spring boot pom", + files: map[string]string{ + "webpack.config.js": "", + "pom.xml": `spring-boot-starter-parent`, + }, + wantFw: "spring-boot", wantFound: true, + }, + { + name: "node server bundle with express", + files: map[string]string{ + "webpack.config.js": "module.exports = { target: 'node' };", + "package.json": `{"dependencies":{"express":"^4"},"devDependencies":{"webpack":"^5","webpack-dev-server":"^5"}}`, + }, + wantFw: "express", wantFound: true, + }, + { + name: "go module", + files: map[string]string{ + "webpack.config.js": "", + "go.mod": "module example.com/app\n", + }, + wantFw: "vanilla-go", wantFound: true, + }, + { + name: "vue has no webpack quickstart", + files: map[string]string{ + "webpack.config.js": "", + "package.json": `{"dependencies":{"vue":"^3"},"devDependencies":{"webpack-dev-server":"^5"}}`, + }, + wantFound: false, + }, + { + name: "angular has no webpack quickstart", + files: map[string]string{ + "webpack.config.js": "", + "package.json": `{"dependencies":{"@angular/core":"^17"},"devDependencies":{"webpack-dev-server":"^5"}}`, + }, + wantFound: false, + }, + { + name: "config with no client-side dependency", + files: map[string]string{ + "webpack.config.js": "", + "package.json": `{"devDependencies":{"webpack":"^5"}}`, + }, + wantFound: false, + }, + { + name: "config with no package.json", + files: map[string]string{"webpack.config.js": ""}, + wantFound: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tc.files { + writeTestFile(t, dir, name, content) + } + + got := DetectProject(dir) + assert.Equal(t, tc.wantFound, got.Detected) + assert.NotEqual(t, "webpack", got.BuildTool) + if tc.wantFound { + assert.Equal(t, tc.wantFw, got.Framework) + } + }) + } +} + // The vite.config rule runs before the webpack rule. func TestDetectProject_ViteBeatsWebpack(t *testing.T) { dir := t.TempDir() diff --git a/internal/cli/quickstarts.go b/internal/cli/quickstarts.go index 067df439b..dd0f622a3 100644 --- a/internal/cli/quickstarts.go +++ b/internal/cli/quickstarts.go @@ -497,7 +497,7 @@ var ( setupGrantScopes = Flag{ Name: "Grant Scopes", LongForm: "grant-scopes", - Help: "[API] Comma-separated subset of --scopes to grant the app created with --app or given with --linked-app-id (default: all --scopes). Every value must also be listed in --scopes.", + Help: "[API] Comma-separated subset of --scopes to grant the app created with --app or given with --linked-app-id (default: all --scopes). Every value must also be listed in --scopes. An empty value grants no scopes.", } setupTokenLifetime = Flag{ Name: "Token Lifetime", @@ -622,6 +622,35 @@ func runSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error return executeSetupQuickstart(cmd, cli, inputs, canPrompt(cmd)) } +// qsCreatedResourcesError carries what a failed setup had already created into the JSON error +// envelope's "details", so an agent can find the app or API without parsing the message. +type qsCreatedResourcesError struct { + err error + result *display.QuickstartSetupResult +} + +func (e qsCreatedResourcesError) Error() string { return e.err.Error() } + +func (e qsCreatedResourcesError) Unwrap() error { return e.err } + +func (e qsCreatedResourcesError) ErrorDetails() json.RawMessage { + e.result.Normalize() + raw, err := json.Marshal(map[string]interface{}{"created": e.result}) + if err != nil { + return nil + } + return raw +} + +// withCreatedResources wraps err when the setup already created an app or API; otherwise it +// returns err unchanged. +func withCreatedResources(err error, result *display.QuickstartSetupResult) error { + if result == nil || (result.App == nil && result.API == nil) { + return err + } + return qsCreatedResourcesError{err: err, result: result} +} + // executeSetupQuickstart runs every setup step after authentication. func executeSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs, canPromptFlag bool) error { ctx := cmd.Context() @@ -710,7 +739,7 @@ func executeSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs, c if inputs.App { clientID, err := createQuickstartApp(cmd, cli, *inputs, qsConfigKey, configPlan, result) if err != nil { - return err + return withCreatedResources(err, result) } // Store the new client ID so Step 9 can link it to the API via a client grant. inputs.LinkedAppID = clientID @@ -719,7 +748,7 @@ func executeSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs, c // -- Step 9: Create the Auth0 API resource server --. if inputs.API { if err := createQuickstartAPI(ctx, cli, *inputs, result); err != nil { - return err + return withCreatedResources(err, result) } } @@ -886,7 +915,10 @@ func collectAPIInputs(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error { // Fail fast if the (possibly user-overridden) identifier is already taken — avoids creating an orphaned app. if _, err := cli.api.ResourceServer.Read(cmd.Context(), url.PathEscape(inputs.Identifier)); err == nil { - return fmt.Errorf("an API with identifier %q already exists; use a different identifier or delete the existing API first", inputs.Identifier) + return usageError{ + err: fmt.Errorf("an API with identifier %q already exists; use a different identifier or delete the existing API first", inputs.Identifier), + reason: "api_exists", + } } // Token lifetime. @@ -1132,6 +1164,17 @@ func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsCon return "", fmt.Errorf("failed to create application: %w", err) } + if result != nil { + result.App = &display.QuickstartSetupApp{ + ClientID: client.GetClientID(), + Name: client.GetName(), + Type: client.GetAppType(), + Callbacks: client.GetCallbacks(), + LogoutURLs: client.GetAllowedLogoutURLs(), + WebOrigins: client.GetWebOrigins(), + } + } + // Inject the audience variable when an API is also being created. envValues := config.EnvValues if inputs.API && inputs.Identifier != "" && config.AudienceVar != "" { @@ -1157,14 +1200,6 @@ func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsCon steps := printNativeGuidance(cli, inputs.Framework, expoScheme, nativeBundleID) if result != nil { - result.App = &display.QuickstartSetupApp{ - ClientID: client.GetClientID(), - Name: client.GetName(), - Type: client.GetAppType(), - Callbacks: client.GetCallbacks(), - LogoutURLs: client.GetAllowedLogoutURLs(), - WebOrigins: client.GetWebOrigins(), - } result.ConfigFile = cfgOut if cfgOut.Action == qsConfigSkipped { result.NextSteps = append(result.NextSteps, @@ -1177,6 +1212,7 @@ func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsCon "Review the backup %s, then delete it or add it to .gitignore: it is a copy of the previous file and may contain secrets", *cfgOut.Backup)) } + result.NextSteps = append(result.NextSteps, cfgOut.Notes...) result.NextSteps = append(result.NextSteps, steps...) } @@ -1958,17 +1994,23 @@ func dotenvLine(key, value string) string { } // mergeDotenv replaces the first assignment of each owned key in place (keeping `export `), drops -// later duplicates, keeps every other line as is, and appends missing keys. Continuation lines of -// a multi-line quoted value of an owned key are left in place. -func mergeDotenv(existing []byte, values map[string]string) []byte { - var out strings.Builder +// later duplicates, keeps every other line as is, and appends missing keys. An assignment owns +// all the lines of its value, so lines inside a multi-line quoted value are never matched against +// the owned keys. Replaced and appended lines use the file's line ending (CRLF or LF). It also +// returns the sorted names of the owned keys that were already present. +func mergeDotenv(existing []byte, values map[string]string) (merged []byte, replaced []string) { + eol := "" + if bytes.Contains(existing, []byte("\r\n")) { + eol = "\r" + } if len(existing) == 0 { + var out strings.Builder for _, key := range sortedKeys(values) { out.WriteString(dotenvLine(key, values[key])) out.WriteString("\n") } - return []byte(out.String()) + return []byte(out.String()), nil } lines := strings.Split(string(existing), "\n") @@ -1977,39 +2019,85 @@ func mergeDotenv(existing []byte, values map[string]string) []byte { } seen := make(map[string]bool, len(values)) - merged := make([]string, 0, len(lines)+len(values)) - for _, line := range lines { - m := dotenvKeyRegexp.FindStringSubmatch(line) - if m == nil { - merged = append(merged, line) + out := make([]string, 0, len(lines)+len(values)) + for i := 0; i < len(lines); { + loc := dotenvKeyRegexp.FindStringSubmatchIndex(lines[i]) + if loc == nil { + out = append(out, lines[i]) + i++ continue } - key := m[2] + + key := lines[i][loc[4]:loc[5]] + last := dotenvValueEnd(lines, i, loc[1]) value, owned := values[key] if !owned { - merged = append(merged, line) + out = append(out, lines[i:last+1]...) + i = last + 1 continue } - if seen[key] { - continue // Drop later duplicates of an owned key. - } - seen[key] = true - prefix := "" - if m[1] != "" { - prefix = "export " + + if !seen[key] { // Later duplicates of an owned key are dropped. + seen[key] = true + prefix := "" + if loc[2] >= 0 { + prefix = "export " + } + out = append(out, prefix+dotenvLine(key, value)+eol) } - merged = append(merged, prefix+dotenvLine(key, value)) + i = last + 1 } for _, key := range sortedKeys(values) { if !seen[key] { - merged = append(merged, dotenvLine(key, values[key])) + out = append(out, dotenvLine(key, values[key])+eol) } } - out.WriteString(strings.Join(merged, "\n")) - out.WriteString("\n") - return []byte(out.String()) + for key := range seen { + replaced = append(replaced, key) + } + sort.Strings(replaced) + + return []byte(strings.Join(out, "\n") + "\n"), replaced +} + +// dotenvValueEnd returns the index of the last line of the value assigned on lines[i]; offset is +// where the value starts in that line. A quoted value that does not close on its own line runs +// to the line holding the closing quote. If it never closes, it is treated as a single line. +func dotenvValueEnd(lines []string, i, offset int) int { + line := strings.TrimSuffix(lines[i], "\r") + if offset > len(line) { + return i + } + rest := strings.TrimLeft(line[offset:], " \t") + if rest == "" || (rest[0] != '"' && rest[0] != '\'' && rest[0] != '`') { + return i + } + + quote := rest[0] + if indexClosingQuote(rest[1:], quote) >= 0 { + return i + } + for j := i + 1; j < len(lines); j++ { + if indexClosingQuote(lines[j], quote) >= 0 { + return j + } + } + return i +} + +// indexClosingQuote returns the index of the first unescaped quote in s, or -1. +func indexClosingQuote(s string, quote byte) int { + for k := 0; k < len(s); k++ { + switch { + case quote == '"' && s[k] == '\\': + k++ + case s[k] == quote: + return k + } + } + return -1 } // nextBackupPath returns .auth0-backup, or the first free .auth0-backup.N (N=1,2,…). @@ -2243,11 +2331,9 @@ func (c *cli) writeQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s return outcome, nil case qsConfigMerged: - action, err := c.mergeQuickstartDotenv(s.Path, resolvedEnv, plan.Perm, content) - if err != nil { + if err := c.mergeQuickstartDotenv(s.Path, resolvedEnv, plan.Perm, content, outcome); err != nil { return nil, err } - outcome.Action = action return outcome, nil case qsConfigOverwritten: @@ -2262,6 +2348,7 @@ func (c *cli) writeQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s c.renderer.Successf("%s file overwritten with your Auth0 configuration", ansi.Cyan(s.Path)) c.renderer.Detailf("Backup of the previous file written to %s", ansi.Cyan(backup)) outcome.Backup = &backup + c.tightenSecretFile(s.Path, resolvedEnv, plan.Perm, outcome) return outcome, nil case qsConfigCreated: @@ -2272,11 +2359,9 @@ func (c *cli) writeQuickstartConfig(cmd *cobra.Command, resolvedEnv map[string]s } // The file may have appeared since the pre-flight; never clobber a dotenv file. if info, err := os.Stat(s.Path); err == nil && s.Format == "dotenv" { - action, err := c.mergeQuickstartDotenv(s.Path, resolvedEnv, info.Mode().Perm(), content) - if err != nil { + if err := c.mergeQuickstartDotenv(s.Path, resolvedEnv, info.Mode().Perm(), content, outcome); err != nil { return nil, err } - outcome.Action = action return outcome, nil } if err := c.createQuickstartConfigFile(s.Path, content); err != nil { @@ -2298,28 +2383,62 @@ func (c *cli) createQuickstartConfigFile(path string, content []byte) error { return nil } +// secretFilePerm drops the group and other bits when the values include a secret and the file is +// readable beyond its owner. It returns the new mode and whether it changed. +func secretFilePerm(resolvedEnv map[string]string, perm os.FileMode) (os.FileMode, bool) { + if perm&0o077 == 0 { + return perm, false + } + for key := range resolvedEnv { + if strings.Contains(strings.ToLower(key), "secret") { + return perm &^ 0o077, true + } + } + return perm, false +} + +// tightenSecretFile applies secretFilePerm to an existing file and records a note when it changed. +func (c *cli) tightenSecretFile(path string, resolvedEnv map[string]string, perm os.FileMode, outcome *display.QuickstartSetupConfigFile) { + tightened, changed := secretFilePerm(resolvedEnv, perm) + if !changed || os.Chmod(path, tightened) != nil { + return + } + note := fmt.Sprintf("Permissions of %s were tightened from %04o to %04o because it now holds a client secret", path, perm, tightened) + c.renderer.Detailf("%s", note) + outcome.Notes = append(outcome.Notes, note) +} + // mergeQuickstartDotenv creates the file from content if it vanished since the pre-flight. -func (c *cli) mergeQuickstartDotenv(path string, resolvedEnv map[string]string, perm os.FileMode, content []byte) (string, error) { +func (c *cli) mergeQuickstartDotenv(path string, resolvedEnv map[string]string, perm os.FileMode, content []byte, outcome *display.QuickstartSetupConfigFile) error { existing, err := os.ReadFile(path) if os.IsNotExist(err) { if dir := filepath.Dir(path); dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { - return "", fmt.Errorf("failed to create directory structure %s: %w", dir, err) + return fmt.Errorf("failed to create directory structure %s: %w", dir, err) } } if err := c.createQuickstartConfigFile(path, content); err != nil { - return "", err + return err } - return qsConfigCreated, nil + outcome.Action = qsConfigCreated + return nil } if err != nil { - return "", fmt.Errorf("failed to read %s: %w", path, err) + return fmt.Errorf("failed to read %s: %w", path, err) } - if err := os.WriteFile(path, mergeDotenv(existing, resolvedEnv), perm); err != nil { - return "", fmt.Errorf("failed to write %s: %w", path, err) + merged, replaced := mergeDotenv(existing, resolvedEnv) + if err := os.WriteFile(path, merged, perm); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) } c.renderer.Newline() c.renderer.Successf("%s updated: merged your Auth0 configuration into the existing file", ansi.Cyan(path)) - return qsConfigMerged, nil + if len(replaced) > 0 { + c.renderer.Detailf("Replaced existing values for: %s", strings.Join(replaced, ", ")) + } + c.tightenSecretFile(path, resolvedEnv, perm, outcome) + + outcome.Action = qsConfigMerged + outcome.KeysReplaced = replaced + return nil } diff --git a/internal/cli/quickstarts_test.go b/internal/cli/quickstarts_test.go index b47027db2..cb487ba24 100644 --- a/internal/cli/quickstarts_test.go +++ b/internal/cli/quickstarts_test.go @@ -1115,6 +1115,19 @@ func fileMode(t *testing.T, path string) os.FileMode { // -- #9 config write: mergeDotenv --. +func TestMergeDotenv_ReportsReplacedKeys(t *testing.T) { + t.Parallel() + + _, replaced := mergeDotenv( + []byte("FOO=bar\nAUTH0_DOMAIN=old\nexport AUTH0_CLIENT_ID=old\nAUTH0_DOMAIN=dup\n"), + map[string]string{"AUTH0_DOMAIN": "d", "AUTH0_CLIENT_ID": "c", "AUTH0_SECRET": "s"}, + ) + assert.Equal(t, []string{"AUTH0_CLIENT_ID", "AUTH0_DOMAIN"}, replaced, "only keys that were already present, sorted, once each") + + _, none := mergeDotenv([]byte("FOO=bar\n"), map[string]string{"AUTH0_DOMAIN": "d"}) + assert.Empty(t, none) +} + func TestMergeDotenv(t *testing.T) { t.Parallel() @@ -1177,7 +1190,51 @@ func TestMergeDotenv(t *testing.T) { name: "unrelated CRLF lines are preserved byte-for-byte", existing: "FOO=bar\r\nBAZ=qux\r\n", values: map[string]string{"A": "1"}, - want: "FOO=bar\r\nBAZ=qux\r\nA=\"1\"\n", + want: "FOO=bar\r\nBAZ=qux\r\nA=\"1\"\r\n", + }, + { + name: "CRLF file: replaced and appended lines use CRLF too", + existing: "FOO=bar\r\nAUTH0_DOMAIN=old\r\n", + values: map[string]string{"AUTH0_DOMAIN": "new", "AUTH0_CLIENT_ID": "cid"}, + want: "FOO=bar\r\nAUTH0_DOMAIN=\"new\"\r\nAUTH0_CLIENT_ID=\"cid\"\r\n", + }, + { + // An owned-looking line inside another key's multi-line value is part of that value. + name: "owned key text inside another key's multi-line value is untouched and the real key is appended", + existing: "PRIVATE_KEY=\"-----BEGIN-----\nAUTH0_SECRET=inside-the-key\n-----END-----\"\nFOO=bar\n", + values: map[string]string{"AUTH0_SECRET": "real"}, + want: "PRIVATE_KEY=\"-----BEGIN-----\nAUTH0_SECRET=inside-the-key\n-----END-----\"\nFOO=bar\nAUTH0_SECRET=\"real\"\n", + }, + { + name: "replacing an owned multi-line value removes its continuation lines", + existing: "AUTH0_SECRET=\"line one\nline two\"\nFOO=bar\n", + values: map[string]string{"AUTH0_SECRET": "new"}, + want: "AUTH0_SECRET=\"new\"\nFOO=bar\n", + }, + { + name: "owned multi-line single-quoted value is replaced as a whole", + existing: "A=1\nAUTH0_CLIENT_SECRET='first\nsecond'\nB=2\n", + values: map[string]string{"AUTH0_CLIENT_SECRET": "new"}, + want: "A=1\nAUTH0_CLIENT_SECRET=\"new\"\nB=2\n", + }, + { + name: "escaped quote does not end a double-quoted value", + existing: "NOTE=\"say \\\"hi\\\"\nAUTH0_DOMAIN=still-inside\"\nZ=1\n", + values: map[string]string{"AUTH0_DOMAIN": "new"}, + want: "NOTE=\"say \\\"hi\\\"\nAUTH0_DOMAIN=still-inside\"\nZ=1\nAUTH0_DOMAIN=\"new\"\n", + }, + { + name: "quote that never closes is treated as a single-line value", + existing: "BROKEN='oops\nAUTH0_DOMAIN=old\nFOO=bar\n", + values: map[string]string{"AUTH0_DOMAIN": "new"}, + want: "BROKEN='oops\nAUTH0_DOMAIN=\"new\"\nFOO=bar\n", + }, + { + // Documented limitation: a replaced line is regenerated, so its inline comment is dropped. + name: "inline comment on a replaced line is dropped but full-line comments stay", + existing: "# keep me\nAUTH0_DOMAIN=old # my tenant\n", + values: map[string]string{"AUTH0_DOMAIN": "new"}, + want: "# keep me\nAUTH0_DOMAIN=\"new\"\n", }, { name: "owned key with spaces around = is matched", @@ -1191,7 +1248,7 @@ func TestMergeDotenv(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := mergeDotenv([]byte(tc.existing), tc.values) + got, _ := mergeDotenv([]byte(tc.existing), tc.values) assert.Equal(t, tc.want, string(got)) }) } @@ -1622,10 +1679,12 @@ func TestExecuteSetupQuickstart_DotenvMergeNoForceInNoInput(t *testing.T) { assert.Equal(t, ".env", cfg.Path) assert.Equal(t, "dotenv", cfg.Format) assert.Nil(t, cfg.Backup) + assert.Equal(t, []string{"VITE_AUTH0_DOMAIN"}, cfg.KeysReplaced, "only the key that was already in the file") var steps []string require.NoError(t, json.Unmarshal(doc["next_steps"], &steps)) assert.NotContains(t, strings.ToLower(strings.Join(steps, "\n")), "backup", "a merge writes no backup") + assert.NotContains(t, strings.Join(steps, "\n"), "tightened", "no secret key was written, so the mode stays") } // -- #10 JSON result --. @@ -2060,6 +2119,237 @@ func TestCreateQuickstartApp_ConfigWriteFailureNamesCreatedApp(t *testing.T) { assert.Contains(t, err.Error(), "failed to generate config file") } +// -- failures after something was created --. + +func createdFromEnvelope(t *testing.T, err error) (code string, created map[string]json.RawMessage) { + t.Helper() + env := buildErrorEnvelope(err) + require.NotEmpty(t, env.Error.Details, "the error envelope must carry the created resources") + var details map[string]map[string]json.RawMessage + require.NoError(t, json.Unmarshal(env.Error.Details, &details)) + require.Contains(t, details, "created") + return env.Error.Code, details["created"] +} + +func TestExecuteSetupQuickstart_APIFailureReportsCreatedApp(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + boom := errors.New("boom") + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Read(gomock.Any(), gomock.Any()).Return(nil, errors.New("404 Not Found")) + rsAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(boom) + + c, stdout, _ := newQSTestCLI(display.OutputFormatJSON, true, + &auth0.API{Client: clientAPI, ResourceServer: rsAPI}, &auth0.APIV3{ClientGrant: mock.NewMockClientGrantAPIV3(ctrl)}) + cmd := newQSSetupCmd(t, c, "--app", "--api", "--name", "My SPA", "--type", "spa", "--framework", "react", + "--build-tool", "vite", "--port", "5173", "--identifier", "https://my-api") + inputs := &SetupInputs{ + App: true, API: true, Name: "My SPA", Type: "spa", Framework: "react", BuildTool: "vite", Port: 5173, + Identifier: "https://my-api", SigningAlg: "RS256", TokenLifetime: "86400", + } + + err := executeSetupQuickstart(cmd, c, inputs, false) + require.Error(t, err) + assert.ErrorIs(t, err, boom) + assert.Empty(t, stdout.String(), "stdout stays empty on failure") + + _, created := createdFromEnvelope(t, err) + var app display.QuickstartSetupApp + require.NoError(t, json.Unmarshal(created["app"], &app)) + assert.Equal(t, "app-cid", app.ClientID) + assert.Equal(t, "null", string(created["api"]), "the API was not created") + + var cfg display.QuickstartSetupConfigFile + require.NoError(t, json.Unmarshal(created["config_file"], &cfg)) + assert.Equal(t, ".env", cfg.Path, "the app's config file was already written") +} + +func TestExecuteSetupQuickstart_GrantFailureReportsAppAndAPI(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Read(gomock.Any(), gomock.Any()).Return(nil, errors.New("404 Not Found")) + rsAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + grantAPI := mock.NewMockClientGrantAPIV3(ctrl) + grantAPI.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil, errors.New("grant refused")) + + c, _, _ := newQSTestCLI(display.OutputFormatJSON, true, + &auth0.API{Client: clientAPI, ResourceServer: rsAPI}, &auth0.APIV3{ClientGrant: grantAPI}) + cmd := newQSSetupCmd(t, c, "--app", "--api", "--name", "My SPA", "--type", "spa", "--framework", "react", + "--build-tool", "vite", "--port", "5173", "--identifier", "https://my-api") + inputs := &SetupInputs{ + App: true, API: true, Name: "My SPA", Type: "spa", Framework: "react", BuildTool: "vite", Port: 5173, + Identifier: "https://my-api", SigningAlg: "RS256", TokenLifetime: "86400", + } + + err := executeSetupQuickstart(cmd, c, inputs, false) + require.Error(t, err) + + _, created := createdFromEnvelope(t, err) + var app display.QuickstartSetupApp + require.NoError(t, json.Unmarshal(created["app"], &app)) + assert.Equal(t, "app-cid", app.ClientID) + var api display.QuickstartSetupAPI + require.NoError(t, json.Unmarshal(created["api"], &api)) + assert.Equal(t, "https://my-api", api.Identifier) + assert.Equal(t, "null", string(created["client_grant"]), "no grant was created") +} + +func TestCreateQuickstartApp_ConfigWriteFailureRecordsAppInResult(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + require.NoError(t, os.Mkdir(".env", 0755)) // The write fails after the pre-flight passed. + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "") + c, _, _ := newQSTestCLI("", false, &auth0.API{Client: clientAPI}, nil) + + result := &display.QuickstartSetupResult{} + plan := &qsConfigWritePlan{Path: ".env", Format: "dotenv", Action: qsConfigCreated, Perm: 0600} + inputs := SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "vite", Name: "My App", Port: 5173} + _, err := createQuickstartApp(&cobra.Command{}, c, inputs, "spa:react:vite", plan, result) + require.Error(t, err) + + _, created := createdFromEnvelope(t, withCreatedResources(err, result)) + var app display.QuickstartSetupApp + require.NoError(t, json.Unmarshal(created["app"], &app)) + assert.Equal(t, "app-cid", app.ClientID) +} + +func TestWithCreatedResources(t *testing.T) { + t.Parallel() + + boom := errors.New("boom") + + t.Run("nothing created leaves the error unchanged", func(t *testing.T) { + t.Parallel() + assert.Equal(t, boom, withCreatedResources(boom, nil)) + assert.Equal(t, boom, withCreatedResources(boom, &display.QuickstartSetupResult{})) + }) + + t.Run("keeps the original error and its classification", func(t *testing.T) { + t.Parallel() + inner := usageError{err: boom, reason: "invalid_flag_value"} + err := withCreatedResources(inner, &display.QuickstartSetupResult{App: &display.QuickstartSetupApp{ClientID: "cid"}}) + assert.ErrorIs(t, err, boom) + assert.Equal(t, "usage", errorClass(err)) + assert.Equal(t, "invalid_flag_value", errorReason(err)) + assert.Equal(t, boom.Error(), err.Error()) + }) +} + +func TestExecuteSetupQuickstart_APIAlreadyExistsIsClassified(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ctrl := gomock.NewController(t) + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT().Read(gomock.Any(), gomock.Any()).Return(&management.ResourceServer{}, nil) + + c, _, _ := newQSTestCLI(display.OutputFormatJSON, true, &auth0.API{ResourceServer: rsAPI}, nil) + cmd := newQSSetupCmd(t, c, "--api", "--linked-app-id", "linked-cid", "--name", "My API", "--identifier", "https://my-api") + inputs := &SetupInputs{ + API: true, LinkedAppID: "linked-cid", Name: "My API", Identifier: "https://my-api", + SigningAlg: "RS256", TokenLifetime: "86400", + } + + err := executeSetupQuickstart(cmd, c, inputs, false) + require.Error(t, err) + assert.Equal(t, "usage", errorClass(err)) + assert.Equal(t, "api_exists", errorReason(err)) + assert.Contains(t, err.Error(), "https://my-api") +} + +// -- permissions of files that now hold a secret --. + +func TestSecretFilePerm(t *testing.T) { + t.Parallel() + + secret := map[string]string{"AUTH0_CLIENT_ID": "x", "AUTH0_CLIENT_SECRET": "y"} + noSecret := map[string]string{"VITE_AUTH0_DOMAIN": "x", "VITE_AUTH0_CLIENT_ID": "y"} + + tests := []struct { + name string + env map[string]string + perm os.FileMode + wantPerm os.FileMode + wantChanged bool + }{ + {"world-readable file gets 0600", secret, 0644, 0600, true}, + {"group-writable file gets 0600", secret, 0664, 0600, true}, + {"already private file is left alone", secret, 0600, 0600, false}, + {"owner-only executable bit is kept", secret, 0700, 0700, false}, + {"no secret key: mode is left alone", noSecret, 0644, 0644, false}, + {"secret detected case-insensitively in dotted keys", map[string]string{"okta.oauth2.client-secret": "y"}, 0644, 0600, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, changed := secretFilePerm(tc.env, tc.perm) + assert.Equal(t, tc.wantPerm, got) + assert.Equal(t, tc.wantChanged, changed) + }) + } +} + +func TestExecuteSetupQuickstart_MergeTightensPermissionsWhenWritingSecret(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeQSFile(t, ".env", "KEEP=1\nAUTH0_DOMAIN=old\n", 0644) + + ctrl := gomock.NewController(t) + clientAPI := mock.NewMockClientAPI(ctrl) + expectClientCreate(clientAPI, "app-cid", "the-secret") + + c, stdout, _ := newQSTestCLI(display.OutputFormatJSON, true, &auth0.API{Client: clientAPI}, nil) + cmd := newQSSetupCmd(t, c, "--app", "--type", "regular", "--framework", "nextjs", "--port", "3000") + inputs := &SetupInputs{App: true, Type: "regular", Framework: "nextjs", Port: 3000} + + require.NoError(t, executeSetupQuickstart(cmd, c, inputs, false)) + + assert.Equal(t, os.FileMode(0600), fileMode(t, ".env"), "a file that now holds a client secret must not stay world-readable") + content, err := os.ReadFile(".env") + require.NoError(t, err) + assert.Contains(t, string(content), "KEEP=1") + assert.Contains(t, string(content), "the-secret") + + var res display.QuickstartSetupResult + require.NoError(t, json.Unmarshal(stdout.Bytes(), &res)) + assert.Equal(t, []string{"AUTH0_DOMAIN"}, res.ConfigFile.KeysReplaced) + assert.Contains(t, strings.Join(res.NextSteps, "\n"), "tightened from 0644 to 0600") + assert.NotContains(t, stdout.String(), "the-secret", "secrets must never reach stdout") +} + +func TestWriteQuickstartConfig_OverwriteTightensPermissionsWhenWritingSecret(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + path := filepath.Join("src", "main", "resources", "application.yml") + writeQSFile(t, path, "old: true\n", 0644) + + c, _, _ := newQSTestCLI("", false, nil, nil) + strategy := &auth0.FileOutputStrategy{Path: path, Format: "yaml"} + env := map[string]string{"okta.oauth2.client-id": "cid", "okta.oauth2.client-secret": "shh"} + plan := &qsConfigWritePlan{Path: path, Format: "yaml", Action: qsConfigOverwritten, Perm: 0644} + + out, err := c.writeQuickstartConfig(&cobra.Command{}, env, strategy, plan) + require.NoError(t, err) + + assert.Equal(t, os.FileMode(0600), fileMode(t, path)) + require.NotNil(t, out.Backup) + assert.Equal(t, os.FileMode(0644), fileMode(t, *out.Backup), "the backup keeps the original mode") + require.Len(t, out.Notes, 1) + assert.Contains(t, out.Notes[0], "0644 to 0600") +} + func TestCreateQuickstartAPI_FillsResult(t *testing.T) { t.Parallel() diff --git a/internal/display/quickstarts.go b/internal/display/quickstarts.go index 281a341be..e7b138674 100644 --- a/internal/display/quickstarts.go +++ b/internal/display/quickstarts.go @@ -105,6 +105,12 @@ type QuickstartSetupConfigFile struct { Format string `json:"format"` Action string `json:"action"` // Created | merged | overwritten | skipped. Backup *string `json:"backup"` // Null unless overwritten. + + // KeysReplaced lists the keys (names only) whose existing values a merge replaced. + KeysReplaced []string `json:"keys_replaced"` + + // Notes become next_steps; they are not part of the config_file JSON. + Notes []string `json:"-"` } // QuickstartSetup is a no-op in the human format, whose output is already printed. @@ -116,6 +122,17 @@ func (r *Renderer) QuickstartSetup(res *QuickstartSetupResult) { return } + res.Normalize() + + if r.Format == OutputFormatJSONCompact { + r.JSONCompactResult(res) + return + } + r.JSONResult(res) +} + +// Normalize turns nil list fields into empty slices so they render as [] instead of null. +func (res *QuickstartSetupResult) Normalize() { res.KeysWritten = nonNilStrings(res.KeysWritten) res.NextSteps = nonNilStrings(res.NextSteps) if res.App != nil { @@ -129,12 +146,9 @@ func (r *Renderer) QuickstartSetup(res *QuickstartSetupResult) { if res.ClientGrant != nil { res.ClientGrant.Scopes = nonNilStrings(res.ClientGrant.Scopes) } - - if r.Format == OutputFormatJSONCompact { - r.JSONCompactResult(res) - return + if res.ConfigFile != nil { + res.ConfigFile.KeysReplaced = nonNilStrings(res.ConfigFile.KeysReplaced) } - r.JSONResult(res) } // nonNilStrings keeps JSON list fields as [] instead of null. diff --git a/internal/display/quickstarts_test.go b/internal/display/quickstarts_test.go index 66b2c6785..3506f477c 100644 --- a/internal/display/quickstarts_test.go +++ b/internal/display/quickstarts_test.go @@ -84,10 +84,32 @@ func TestRenderer_QuickstartSetup_JSON_FullShape(t *testing.T) { assert.JSONEq(t, `[]`, string(grant["scopes"])) assert.JSONEq(t, `"cgr_1"`, string(grant["id"])) assert.JSONEq(t, `"`+backup+`"`, string(cfg["backup"])) + assert.JSONEq(t, `[]`, string(cfg["keys_replaced"]), "nil keys_replaced normalizes to []") + assert.NotContains(t, cfg, "notes", "notes feed next_steps and are not part of config_file") + assert.NotContains(t, cfg, "Notes") assert.JSONEq(t, `["clientId","domain"]`, string(doc["keys_written"])) assert.JSONEq(t, `["do x"]`, string(doc["next_steps"])) } +func TestRenderer_QuickstartSetup_KeysReplacedAndNotes(t *testing.T) { + stdout := &bytes.Buffer{} + r := &Renderer{ResultWriter: stdout, MessageWriter: &bytes.Buffer{}, Format: OutputFormatJSON} + + r.QuickstartSetup(&QuickstartSetupResult{ + ConfigFile: &QuickstartSetupConfigFile{ + Path: ".env", Format: "dotenv", Action: "merged", + KeysReplaced: []string{"AUTH0_DOMAIN"}, Notes: []string{"hidden note"}, + }, + }) + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal(stdout.Bytes(), &doc)) + var cfg map[string]json.RawMessage + require.NoError(t, json.Unmarshal(doc["config_file"], &cfg)) + assert.JSONEq(t, `["AUTH0_DOMAIN"]`, string(cfg["keys_replaced"])) + assert.NotContains(t, stdout.String(), "hidden note") +} + func TestRenderer_QuickstartSetup_JSONCompact(t *testing.T) { stdout := &bytes.Buffer{} r := &Renderer{ResultWriter: stdout, MessageWriter: &bytes.Buffer{}, Format: OutputFormatJSONCompact}