diff --git a/docs/auth0_quickstarts.md b/docs/auth0_quickstarts.md index 9ad2f2ed4..d2359e3d4 100644 --- a/docs/auth0_quickstarts.md +++ b/docs/auth0_quickstarts.md @@ -12,4 +12,5 @@ Step-by-step guides to quickly integrate Auth0 into your application. - [auth0 quickstarts download](auth0_quickstarts_download.md) - Download a Quickstart sample app for a specific tech stack - [auth0 quickstarts list](auth0_quickstarts_list.md) - List the available Quickstarts - [auth0 quickstarts setup](auth0_quickstarts_setup.md) - Set up Auth0 for your quickstart application +- [auth0 quickstarts setup-experimental](auth0_quickstarts_setup-experimental.md) - Set up Auth0 for your quickstart application diff --git a/docs/auth0_quickstarts_download.md b/docs/auth0_quickstarts_download.md index ea55fbbd4..1f3638b22 100644 --- a/docs/auth0_quickstarts_download.md +++ b/docs/auth0_quickstarts_download.md @@ -47,5 +47,6 @@ auth0 quickstarts download [flags] - [auth0 quickstarts download](auth0_quickstarts_download.md) - Download a Quickstart sample app for a specific tech stack - [auth0 quickstarts list](auth0_quickstarts_list.md) - List the available Quickstarts - [auth0 quickstarts setup](auth0_quickstarts_setup.md) - Set up Auth0 for your quickstart application +- [auth0 quickstarts setup-experimental](auth0_quickstarts_setup-experimental.md) - Set up Auth0 for your quickstart application diff --git a/docs/auth0_quickstarts_list.md b/docs/auth0_quickstarts_list.md index 8213dbbf3..4fc23e842 100644 --- a/docs/auth0_quickstarts_list.md +++ b/docs/auth0_quickstarts_list.md @@ -49,5 +49,6 @@ auth0 quickstarts list [flags] - [auth0 quickstarts download](auth0_quickstarts_download.md) - Download a Quickstart sample app for a specific tech stack - [auth0 quickstarts list](auth0_quickstarts_list.md) - List the available Quickstarts - [auth0 quickstarts setup](auth0_quickstarts_setup.md) - Set up Auth0 for your quickstart application +- [auth0 quickstarts setup-experimental](auth0_quickstarts_setup-experimental.md) - Set up Auth0 for your quickstart application diff --git a/docs/auth0_quickstarts_setup-experimental.md b/docs/auth0_quickstarts_setup-experimental.md new file mode 100644 index 000000000..6f496eb9d --- /dev/null +++ b/docs/auth0_quickstarts_setup-experimental.md @@ -0,0 +1,72 @@ +--- +layout: default +parent: auth0 quickstarts +has_toc: false +--- +# auth0 quickstarts setup-experimental + +Creates an Auth0 application and/or API and generates a config file with the necessary Auth0 settings. + +The command will: + 1. Check if you are authenticated (and prompt for login if needed) + 2. Auto-detect your project framework from the current directory + 3. Create an Auth0 application and/or API resource server + 4. Generate a config file with the appropriate environment variables + +Supported frameworks are dynamically loaded from the QuickstartConfigs map. + +## Usage +``` +auth0 quickstarts setup-experimental [flags] +``` + +## Examples + +``` + auth0 quickstarts setup-experimental + auth0 quickstarts setup-experimental --app --framework react --type spa + auth0 quickstarts setup-experimental --api --identifier https://my-api + auth0 quickstarts setup-experimental --app --api --name "My App" +``` + + +## 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 + --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 +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 quickstarts download](auth0_quickstarts_download.md) - Download a Quickstart sample app for a specific tech stack +- [auth0 quickstarts list](auth0_quickstarts_list.md) - List the available Quickstarts +- [auth0 quickstarts setup](auth0_quickstarts_setup.md) - Set up Auth0 for your quickstart application +- [auth0 quickstarts setup-experimental](auth0_quickstarts_setup-experimental.md) - Set up Auth0 for your quickstart application + + diff --git a/docs/auth0_quickstarts_setup.md b/docs/auth0_quickstarts_setup.md index c7a158fb1..ceb09a84e 100644 --- a/docs/auth0_quickstarts_setup.md +++ b/docs/auth0_quickstarts_setup.md @@ -61,5 +61,6 @@ auth0 quickstarts setup [flags] - [auth0 quickstarts download](auth0_quickstarts_download.md) - Download a Quickstart sample app for a specific tech stack - [auth0 quickstarts list](auth0_quickstarts_list.md) - List the available Quickstarts - [auth0 quickstarts setup](auth0_quickstarts_setup.md) - Set up Auth0 for your quickstart application +- [auth0 quickstarts setup-experimental](auth0_quickstarts_setup-experimental.md) - Set up Auth0 for your quickstart application diff --git a/docs/auth0_test_login.md b/docs/auth0_test_login.md index 8e045464d..2446a7589 100644 --- a/docs/auth0_test_login.md +++ b/docs/auth0_test_login.md @@ -18,26 +18,27 @@ auth0 test login [flags] auth0 test login auth0 test login auth0 test login --connection-name - auth0 test login --connection-name --audience - auth0 test login --connection-name --audience --organization - auth0 test login --connection-name --audience --domain --params "foo=bar" - auth0 test login --connection-name --audience --domain --scopes - auth0 test login -c -a -d -s --force - auth0 test login -c -a -d -s --json - auth0 test login -c -a -d -s --json-compact - auth0 test login -c -a -d -o -s -p "foo=bar" -p "bazz=buzz" --json - auth0 test login -c -a -d -o -s -p "foo=bar","bazz=buzz" --json - auth0 test login -c -a -d -s --force --json + auth0 test login --connection-name --identifier + auth0 test login --connection-name --identifier --organization + auth0 test login --connection-name --identifier --domain --params "foo=bar" + auth0 test login --connection-name --identifier --domain --scopes + auth0 test login -c -a -d -s --force + auth0 test login -c -a -d -s --json + auth0 test login -c -a -d -s --json-compact + auth0 test login -c -a -d -o -s -p "foo=bar" -p "bazz=buzz" --json + auth0 test login -c -a -d -o -s -p "foo=bar","bazz=buzz" --json + auth0 test login -c -a -d -s --force --json ``` ## Flags ``` - -a, --audience string The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt. + --audience string The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt. -c, --connection-name string The connection name to test during login. -d, --domain string One of your custom domains. --force Skip confirmation. + -a, --identifier string The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt. --json Output in json format. --json-compact Output in compact json format. -o, --organization string organization-id to use for the login. Can use organization-name if allow_organization_name_in_authentication_api is enabled for tenant diff --git a/docs/auth0_test_token.md b/docs/auth0_test_token.md index 003bdfc59..1a24d2a72 100644 --- a/docs/auth0_test_token.md +++ b/docs/auth0_test_token.md @@ -5,7 +5,7 @@ has_toc: false --- # auth0 test token -Request an access token for a given application. Specify the API you want this token for with `--audience` (API Identifier). Additionally, you can also specify the `--scopes` to grant. +Request an access token for a given application. Specify the API you want this token for with `--identifier` (API Identifier). Additionally, you can also specify the `--scopes` to grant. ## Usage ``` @@ -16,23 +16,24 @@ auth0 test token [flags] ``` auth0 test token - auth0 test token --audience --organization --scopes --params "foo=bar" - auth0 test token -a -o -s - auth0 test token -a -s --force - auth0 test token -a -o -s -p "foo=bar" -p "bazz=buzz" --force - auth0 test token -a -s --json - auth0 test token -a -s --json-compact - auth0 test token -a -o -s -p "foo=bar","bazz=buzz" --json - auth0 test token -a -s --force --json + auth0 test token --identifier --organization --scopes --params "foo=bar" + auth0 test token -a -o -s + auth0 test token -a -s --force + auth0 test token -a -o -s -p "foo=bar" -p "bazz=buzz" --force + auth0 test token -a -s --json + auth0 test token -a -s --json-compact + auth0 test token -a -o -s -p "foo=bar","bazz=buzz" --json + auth0 test token -a -s --force --json ``` ## Flags ``` - -a, --audience string The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt. + --audience string The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt. -d, --domain string One of your custom domains. --force Skip confirmation. + -a, --identifier string The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt. --json Output in json format. --json-compact Output in compact json format. -o, --organization string organization-id to use for the login. Can use organization-name if allow_organization_name_in_authentication_api is enabled for tenant diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 27c248f46..4140e0de7 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -127,7 +127,7 @@ var RequiredScopes = []string{ "openid", "offline_access", // For retrieving refresh token. "create:clients", "delete:clients", "read:clients", "update:clients", - "read:client_grants", + "create:client_grants", "read:client_grants", "create:resource_servers", "delete:resource_servers", "read:resource_servers", "update:resource_servers", "create:roles", "delete:roles", "read:roles", "update:roles", "create:rules", "delete:rules", "read:rules", "update:rules", diff --git a/internal/auth0/client_grant.go b/internal/auth0/client_grant.go index 0aa5d7eef..74296cfb9 100644 --- a/internal/auth0/client_grant.go +++ b/internal/auth0/client_grant.go @@ -7,6 +7,10 @@ import ( ) type ClientGrantAPI interface { - // List all client grants. + // Create a new client grant, authorizing the given client for the specified API (audience). + // Returns an error if the grant already exists or the request fails. + Create(ctx context.Context, g *management.ClientGrant, opts ...management.RequestOption) error + + // List returns all client grants for the tenant, with optional filtering via opts. List(ctx context.Context, opts ...management.RequestOption) (*management.ClientGrantList, error) } diff --git a/internal/auth0/mock/client_grant_mock.go b/internal/auth0/mock/client_grant_mock.go index 629b9d6ba..74f085751 100644 --- a/internal/auth0/mock/client_grant_mock.go +++ b/internal/auth0/mock/client_grant_mock.go @@ -35,6 +35,25 @@ func (m *MockClientGrantAPI) EXPECT() *MockClientGrantAPIMockRecorder { return m.recorder } +// Create mocks base method. +func (m *MockClientGrantAPI) Create(ctx context.Context, g *management.ClientGrant, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, g} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockClientGrantAPIMockRecorder) Create(ctx, g interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, g}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockClientGrantAPI)(nil).Create), varargs...) +} + // List mocks base method. func (m *MockClientGrantAPI) List(ctx context.Context, opts ...management.RequestOption) (*management.ClientGrantList, error) { m.ctrl.T.Helper() diff --git a/internal/auth0/quickstart.go b/internal/auth0/quickstart.go index 60a10fee2..8628d9776 100644 --- a/internal/auth0/quickstart.go +++ b/internal/auth0/quickstart.go @@ -9,12 +9,13 @@ import ( "net/url" "os" "path" + "sort" "strings" + "time" "github.com/auth0/go-auth0/management" "github.com/auth0/auth0-cli/internal/buildinfo" - "github.com/auth0/auth0-cli/internal/utils" ) @@ -24,6 +25,13 @@ const ( quickstartsDefaultCallbackURL = "https://YOUR_APP/callback" ) +const ( + quickstartHTTPTimeout = 30 * time.Second + maxDownloadSize = 100 * 1024 * 1024 // 100 MB. +) + +var quickstartHTTPClient = &http.Client{Timeout: quickstartHTTPTimeout} + type Quickstarts []Quickstart type Quickstart struct { @@ -68,11 +76,15 @@ func (q Quickstart) Download(ctx context.Context, downloadPath string, client *m userAgent := "Auth0 CLI" // Set User-Agent header using the standard CLI format. request.Header.Set("User-Agent", fmt.Sprintf("%v/%v", userAgent, strings.TrimPrefix(buildinfo.Version, "v"))) - response, err := http.DefaultClient.Do(request) + response, err := quickstartHTTPClient.Do(request) if err != nil { return err } + defer func() { + _ = response.Body.Close() + }() + if response.StatusCode != http.StatusOK { return fmt.Errorf("expected status %d, got %d", http.StatusOK, response.StatusCode) } @@ -88,7 +100,7 @@ func (q Quickstart) Download(ctx context.Context, downloadPath string, client *m return err } - _, err = io.Copy(tmpFile, response.Body) + _, err = io.Copy(tmpFile, io.LimitReader(response.Body, maxDownloadSize)) if err != nil { return err } @@ -117,7 +129,7 @@ func GetQuickstarts(ctx context.Context) (Quickstarts, error) { return nil, err } - response, err := http.DefaultClient.Do(request) + response, err := quickstartHTTPClient.Do(request) if err != nil { return nil, err } @@ -174,3 +186,674 @@ func (q Quickstarts) Stacks() []string { return stacks } + +const ( + // DetectionSub is replaced at runtime with baseURL+CallbackPath ("/callback" by default). + DetectionSub = "DETECTION_SUB" + // DetectionSubAsBase is replaced at runtime with just the baseURL (no path suffix). + // Use this for SPA callback/logout URLs where the path is the app root. + DetectionSubAsBase = "DETECTION_SUB_AS_BASE" +) + +type FileOutputStrategy struct { + Path string + Format string +} + +type RequestParams struct { + AppType string + Callbacks []string + AllowedLogoutURLs []string + WebOrigins []string + Name string + // CallbackPath is the path suffix appended to baseURL when resolving DetectionSub + // in Callbacks. Leave empty to use the default "/callback". Examples: + // "/api/auth/callback" (Next.js) + // "/auth/callback" (Fastify) + CallbackPath string +} + +type AppConfig struct { + EnvValues map[string]string + RequestParams RequestParams + Strategy FileOutputStrategy + // AudienceVar is the env variable name to add when --api is also specified. + // It receives the API identifier (audience URL) as its value. + // Leave empty for frameworks where the audience is not configured via an env file. + AudienceVar string +} + +// FrameworkBuildTools maps "type:framework" to the alphabetically ordered list of supported +// build tools derived from QuickstartConfigs. +var FrameworkBuildTools = func() map[string][]string { + raw := map[string][]string{} + for k := range QuickstartConfigs { + parts := strings.SplitN(k, ":", 3) + if len(parts) != 3 { + continue + } + tf := parts[0] + ":" + parts[1] + raw[tf] = append(raw[tf], parts[2]) + } + for tf, tools := range raw { + if len(tools) > 1 { + sort.Strings(tools) + } + raw[tf] = tools + } + return raw +}() + +var QuickstartConfigs = map[string]AppConfig{ + + // ==========================================. + "spa:react:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_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: "VITE_AUTH0_AUDIENCE", + }, + "spa:angular:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{DetectionSubAsBase}, + AllowedLogoutURLs: []string{DetectionSub}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + // Angular-ts wraps domain/clientId under an auth0:{} object matching the + // official Angular quickstart structure: environment.auth0.domain / environment.auth0.clientId. + Strategy: FileOutputStrategy{Path: "src/environments/environment.ts", Format: "angular-ts"}, + }, + "spa:vue:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_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: "VITE_AUTH0_AUDIENCE", + }, + "spa:svelte:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_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: "VITE_AUTH0_AUDIENCE", + }, + "spa:vanilla-javascript:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_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: "VITE_AUTH0_AUDIENCE", + }, + "spa:flutter-web:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{DetectionSubAsBase}, + AllowedLogoutURLs: []string{DetectionSub}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "lib/auth_config.dart", Format: "dart"}, + }, + + // ==========================================. + "regular:nextjs: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: "/api/auth/callback", + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + AudienceVar: "AUTH0_AUDIENCE", + }, + "regular:nuxt:none": { + EnvValues: map[string]string{ + "NUXT_AUTH0_DOMAIN": DetectionSub, + "NUXT_AUTH0_CLIENT_ID": DetectionSub, + "NUXT_AUTH0_CLIENT_SECRET": DetectionSub, + "NUXT_AUTH0_SESSION_SECRET": DetectionSub, + "NUXT_AUTH0_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"}, + }, + "regular:fastify:none": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + "SESSION_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"}, + }, + "regular:sveltekit: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"}, + }, + // SvelteKit with Vite uses the same server-side config as regular:sveltekit:none. + // SvelteKit SSR requires a client secret regardless of the underlying build tool. + "regular:sveltekit:vite": { + 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"}, + }, + "regular:express:none": { + EnvValues: map[string]string{ + "ISSUER_BASE_URL": DetectionSub, + "CLIENT_ID": DetectionSub, + "SECRET": DetectionSub, + "BASE_URL": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:hono:none": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + "AUTH0_SESSION_ENCRYPTION_KEY": DetectionSub, + "BASE_URL": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:vanilla-python:none": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + "AUTH0_SECRET": DetectionSub, + "AUTH0_REDIRECT_URI": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:django:none": { + 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, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:vanilla-go:none": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + "AUTH0_CALLBACK_URL": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:vanilla-java:maven": { + EnvValues: map[string]string{ + "com.auth0.domain": DetectionSub, + "com.auth0.clientId": DetectionSub, + "com.auth0.clientSecret": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "src/main/webapp/WEB-INF/web.xml", Format: "webxml"}, + }, + "regular:java-ee:maven": { + EnvValues: map[string]string{ + "auth0/domain": DetectionSub, + "auth0/clientId": DetectionSub, + "auth0/clientSecret": DetectionSub, + // Auth0/scope is a fixed value read by Auth0AuthenticationConfig via JNDI lookup. + "auth0/scope": "openid profile email", + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + // Javaee-webxml writes JNDI env-entry elements to web.xml, matching the + // official Auth0 Java EE quickstart (auth0.com/docs/quickstart/webapp/java-ee). + Strategy: FileOutputStrategy{Path: "src/main/webapp/WEB-INF/web.xml", Format: "javaee-webxml"}, + }, + "regular:spring-boot:maven": { + EnvValues: map[string]string{ + "okta.oauth2.issuer": DetectionSub, + "okta.oauth2.client-id": DetectionSub, + "okta.oauth2.client-secret": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + // Spring Boot OAuth2 login registers the redirect URI under the OIDC + // registration ID. The okta-spring-boot-starter uses "oidc" by default. + CallbackPath: "/login/oauth2/code/oidc", + }, + Strategy: FileOutputStrategy{Path: "src/main/resources/application.yml", Format: "yaml"}, + }, + // Spring Boot Gradle produces the same application.yml config as Maven. + "regular:spring-boot:gradle": { + EnvValues: map[string]string{ + "okta.oauth2.issuer": DetectionSub, + "okta.oauth2.client-id": DetectionSub, + "okta.oauth2.client-secret": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + CallbackPath: "/login/oauth2/code/oidc", + }, + Strategy: FileOutputStrategy{Path: "src/main/resources/application.yml", Format: "yaml"}, + }, + "regular:aspnet-mvc:none": { + EnvValues: map[string]string{ + "Auth0:Domain": DetectionSub, + "Auth0:ClientId": DetectionSub, + "Auth0:ClientSecret": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "appsettings.json", Format: "json"}, + }, + "regular:aspnet-blazor:none": { + EnvValues: map[string]string{ + "Auth0:Domain": DetectionSub, + "Auth0:ClientId": DetectionSub, + "Auth0:ClientSecret": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "appsettings.json", Format: "json"}, + }, + "regular:aspnet-owin:none": { + EnvValues: map[string]string{ + "auth0:Domain": DetectionSub, + "auth0:ClientId": DetectionSub, + "auth0:ClientSecret": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "Web.config", Format: "xml"}, + }, + "regular:vanilla-php:composer": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + "AUTH0_COOKIE_SECRET": DetectionSub, + "AUTH0_BASE_URL": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:laravel:composer": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + "AUTH0_COOKIE_SECRET": DetectionSub, + "AUTH0_BASE_URL": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:rails:none": { + 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: "/auth/auth0/callback", + }, + Strategy: FileOutputStrategy{Path: "config/auth0.yml", Format: "rails-yaml"}, + }, + "regular:jhipster:none": { + EnvValues: map[string]string{ + "SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI": DetectionSub, + "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID": DetectionSub, + "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET": DetectionSub, + "JHIPSTER_SECURITY_OAUTH2_AUDIENCE": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + CallbackPath: "/login/oauth2/code/oidc", + }, + Strategy: FileOutputStrategy{Path: ".auth0.env", Format: "dotenv"}, + }, + + // ========================================== + // Native/mobile apps: most use custom URI scheme callbacks derived from the bundle + // identifier, which is not known at setup time. For those, Callbacks and + // AllowedLogoutURLs are left empty so Auth0 does not register an incorrect URL; + // the user must add the correct callback URL in the Auth0 Dashboard after setup. + // Exceptions: Expo uses exp://localhost:19000 (Expo Go), and Ionic/Capacitor uses + // http://localhost (Capacitor WebView intercept). + "native:flutter:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{}, + AllowedLogoutURLs: []string{}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "lib/auth_config.dart", Format: "dart"}, + }, + "native:react-native:none": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{}, + AllowedLogoutURLs: []string{}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "native:expo:none": { + EnvValues: map[string]string{ + "EXPO_PUBLIC_AUTH0_DOMAIN": DetectionSub, + "EXPO_PUBLIC_AUTH0_CLIENT_ID": DetectionSub, + }, + // Expo Go uses exp://localhost:19000 as the standard redirect URI. + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{"exp://localhost:19000"}, + AllowedLogoutURLs: []string{"exp://localhost:19000"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "native:ionic-angular:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + // Capacitor intercepts http://localhost redirects in the WebView. + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{"http://localhost"}, + AllowedLogoutURLs: []string{"http://localhost"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "src/environments/environment.ts", Format: "ts"}, + }, + "native:ionic-react:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_AUTH0_CLIENT_ID": DetectionSub, + }, + // Capacitor intercepts http://localhost redirects in the WebView. + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{"http://localhost"}, + AllowedLogoutURLs: []string{"http://localhost"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "native:ionic-vue:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_AUTH0_CLIENT_ID": DetectionSub, + }, + // Capacitor intercepts http://localhost redirects in the WebView. + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{"http://localhost"}, + AllowedLogoutURLs: []string{"http://localhost"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "native:dotnet-mobile:none": { + EnvValues: map[string]string{ + "Auth0:Domain": DetectionSub, + "Auth0:ClientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{}, + AllowedLogoutURLs: []string{}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "appsettings.json", Format: "json"}, + }, + "native:maui:none": { + EnvValues: map[string]string{ + "Auth0:Domain": DetectionSub, + "Auth0:ClientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{}, + AllowedLogoutURLs: []string{}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "appsettings.json", Format: "json"}, + }, + "native:wpf-winforms:none": { + EnvValues: map[string]string{ + "Auth0:Domain": DetectionSub, + "Auth0:ClientId": DetectionSub, + // No Auth0:ClientSecret — WPF/WinForms apps are public native clients + // that use Authorization Code + PKCE; the client secret is unused and + // Auth0 returns an empty/placeholder value for native app types. + }, + // WPF/WinForms uses the bare loopback http://localhost (no port, no path) per Auth0 docs. + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{"http://localhost"}, + AllowedLogoutURLs: []string{"http://localhost"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "appsettings.json", Format: "json"}, + }, + + "native:android:gradle": { + EnvValues: map[string]string{ + "com_auth0_domain": DetectionSub, + "com_auth0_client_id": DetectionSub, + // Com_auth0_scheme is always "https" for App Links (HTTPS callback scheme). + "com_auth0_scheme": "https", + }, + // Android uses App Links (https:///android//callback). + // Package name is not known at setup time; user must add the URL in the Auth0 Dashboard. + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{}, + AllowedLogoutURLs: []string{}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "app/src/main/res/values/strings.xml", Format: "android-strings"}, + }, + "native:ios-swift:none": { + EnvValues: map[string]string{ + "ClientId": DetectionSub, + "Domain": DetectionSub, + }, + // IOS Swift uses universal links or custom URI scheme callbacks based on the bundle + // identifier. Bundle ID is not known at setup time; user must add URL in Auth0 Dashboard. + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{}, + AllowedLogoutURLs: []string{}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "Auth0.plist", Format: "plist"}, + }, + + // ========================================== + // M2M apps use the client_credentials flow — no frontend, no port, no callback URLs. + "m2m:none:none": { + EnvValues: map[string]string{ + "AUTH0_DOMAIN": DetectionSub, + "AUTH0_CLIENT_ID": DetectionSub, + "AUTH0_CLIENT_SECRET": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "non_interactive", + Callbacks: []string{}, + AllowedLogoutURLs: []string{}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7d4355123..456f6b23a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -102,6 +102,13 @@ func (c *cli) setupWithAuthentication(ctx context.Context) error { c.renderer.Warnf("Failed to renew access token: %s", err) c.renderer.Warnf("Please log in to re-authorize the CLI.\n") + // In --no-input mode, fail immediately instead of hanging on an interactive prompt. + if c.noInput { + return fmt.Errorf( + "auth token expired and --no-input is set; run 'auth0 login' to re-authenticate", + ) + } + // Determine tenant domain for login. tenantDomain := "" if c.Config.DefaultTenant != "" { diff --git a/internal/cli/quickstart_detect.go b/internal/cli/quickstart_detect.go new file mode 100644 index 000000000..60921b861 --- /dev/null +++ b/internal/cli/quickstart_detect.go @@ -0,0 +1,990 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" +) + +// DetectionResult holds the values resolved by scanning the working directory. +// Fields are empty/zero when not detected. AmbiguousFrameworks is populated when +// multiple package.json deps match and the framework cannot be determined uniquely. +type DetectionResult struct { + Framework string + Type string // "spa" | "regular" | "native". + BuildTool string // "vite" | "maven" | "gradle" | "composer" | "" (NA). + Port int // 0 means no applicable default. + AppName string // Basename of the working directory. + 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. +} + +// detectionCandidate is used internally during package.json dep scanning. +type detectionCandidate struct { + framework string + qsType string + buildTool string +} + +// DetectProject scans dir for framework signal files and returns a DetectionResult. +// Rules follow the priority order from the spec: config files beat package.json scanning. +func DetectProject(dir string) DetectionResult { + result := DetectionResult{ + AppName: filepath.Base(dir), + } + if name := readProjectName(dir); name != "" { + result.AppName = name + } + + // Read package.json deps early - needed for checks that must precede file-based signals. + earlyDeps := readPackageJSONDeps(dir) + + // -- 1. Manage.py - must check BEFORE Ionic to prevent monorepo misdetection -- + // Manage.py is universally generated by django-admin startproject and is unique + // to Django - no other Python framework produces it. Checking it before Ionic + // ensures a Django+Ionic monorepo is detected as Django rather than Ionic. + if fileExists(dir, "manage.py") { + result.Framework = "django" + result.Type = "regular" + result.Port = 8000 + result.Detected = true + return result + } + + // -- 2. Ionic (package.json deps - must check BEFORE angular.json and vite.config) --. + if hasDep(earlyDeps, "@ionic/angular") { + result.Framework = "ionic-angular" + result.Type = "native" + result.BundleID = readCapacitorAppID(dir) + result.Detected = true + return result + } + if hasDep(earlyDeps, "@ionic/react") { + result.Framework = "ionic-react" + result.Type = "native" + result.BuildTool = detectBuildToolFromFiles(dir, "ionic-react") + result.BundleID = readCapacitorAppID(dir) + result.Detected = true + return result + } + if hasDep(earlyDeps, "@ionic/vue") { + result.Framework = "ionic-vue" + result.Type = "native" + result.BuildTool = detectBuildToolFromFiles(dir, "ionic-vue") + result.BundleID = readCapacitorAppID(dir) + result.Detected = true + return result + } + + // -- 3. Angular.json --. + if fileExists(dir, "angular.json") { + result.Framework = "angular" + result.Type = "spa" + result.Port = defaultPortForFramework("angular") + result.Detected = true + return result + } + + // -- 4. Pubspec.yaml (Flutter) --. + if data, ok := readFileContent(dir, "pubspec.yaml"); ok { + if strings.Contains(data, "sdk: flutter") { + result.Detected = true + // Flutter create (default) has included web/ since Flutter 2.10, so web/ alone + // is not a reliable signal for web-only intent. + // Native intent is signalled by android/ or ios/ platform directories. + // Web intent is signalled by a "web:" key nested under the "flutter:" section + // in pubspec.yaml. Absence of both signals defaults to native. + switch { + case dirExists(dir, "android") || dirExists(dir, "ios"): + result.Framework = "flutter" + result.Type = "native" + result.BundleID = readMobileBundleID(dir) + case pubspecHasFlutterWebTarget(data): + result.Framework = "flutter-web" + result.Type = "spa" + default: + // No native platform dirs and no web target in pubspec - default to native. + result.Framework = "flutter" + result.Type = "native" + result.BundleID = readMobileBundleID(dir) + } + return result + } + } + + // -- 5. Composer.json (PHP) - BEFORE vite.config to prevent Laravel misdetection -- + // Laravel 10+ ships with vite.config.js; checking composer.json first avoids a + // false-positive Vanilla-JavaScript match for Laravel projects. + if data, ok := readFileContent(dir, "composer.json"); ok { + result.BuildTool = "composer" + result.Type = "regular" + result.Detected = true + if strings.Contains(data, "laravel/framework") { + result.Framework = "laravel" + result.Port = 8000 + } else { + result.Framework = "vanilla-php" + } + return result + } + + // -- 6. SvelteKit (@sveltejs/kit dep - BEFORE vite.config) -- + // Plain Svelte+Vite also creates svelte.config.js and vite.config.ts, so + // @sveltejs/kit in package.json is the only reliable distinguishing signal. + if hasDep(earlyDeps, "@sveltejs/kit") { + result.Framework = "sveltekit" + result.Type = "regular" + result.BuildTool = detectBuildToolFromFiles(dir, "sveltekit") + result.Port = defaultPortForFramework("sveltekit") + result.Detected = true + return result + } + + // -- 7. Nuxt.config.[ts|js] - BEFORE vite.config -- + // Nuxt uses Vite internally, so Nuxt projects commonly contain vite.config.ts + // alongside nuxt.config.ts. Checking nuxt.config first prevents a Nuxt project + // from being misdetected as a Vite SPA. + if fileExistsAny(dir, "nuxt.config.ts", "nuxt.config.js") { + result.Framework = "nuxt" + result.Type = "regular" + result.Port = 3000 + 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" + result.BuildTool = "vite" + result.Detected = true + switch { + case hasDep(earlyDeps, "react"): + result.Framework = "react" + case hasDep(earlyDeps, "vue"): + result.Framework = "vue" + case hasDep(earlyDeps, "svelte"): + result.Framework = "svelte" + default: + result.Framework = "vanilla-javascript" + } + result.Port = defaultPortForFramework(result.Framework) + return result + } + + // -- 9. Next.config.[js|ts|mjs] --. + if fileExistsAny(dir, "next.config.js", "next.config.ts", "next.config.mjs") { + result.Framework = "nextjs" + result.Type = "regular" + result.Port = defaultPortForFramework("nextjs") + result.Detected = true + return result + } + + // -- 10. Svelte.config.[js|ts] -- + // Plain Svelte projects scaffolded with older templates can also have + // svelte.config.js without @sveltejs/kit. Only label as sveltekit when the + // @sveltejs/kit dep is confirmed in package.json, or when there is no + // package.json at all (in which case the config file is the best signal). + if fileExistsAny(dir, "svelte.config.js", "svelte.config.ts") { + framework := "sveltekit" + appType := "regular" + if len(earlyDeps) > 0 && !hasDep(earlyDeps, "@sveltejs/kit") { + framework = "svelte" + appType = "spa" + } + result.Framework = framework + result.Type = appType + result.BuildTool = detectBuildToolFromFiles(dir, framework) + result.Port = defaultPortForFramework(framework) + result.Detected = true + return result + } + + // Create-expo-app has generated app.json (not expo.json) since SDK 46 (2022). + // Check app.json first; fall back to expo.json for legacy projects. + if isExpoProject(dir) || fileExists(dir, "expo.json") { + result.Framework = "expo" + result.Type = "native" + result.Detected = true + return result + } + + // -- 12. .csproj --. + if content, ok := findCsprojContent(dir); ok { + if fw, qsType, found := detectFromCsproj(content); found { + result.Framework = fw + result.Type = qsType + if fw == "maui" || fw == "dotnet-mobile" { + result.BundleID = readDotnetMobileBundleID(content) + } + result.Detected = true + return result + } + } + + // -- 13. Android (native Java/Kotlin) - BEFORE Java build files -- + // AndroidManifest.xml is required by every Android project and is absent from + // Java server-side apps. Checking it here prevents an Android project from + // being misdetected as vanilla-java when build.gradle is present. + // React Native projects also have app/src/main/AndroidManifest.xml in their + // Android sub-project; guard against that by checking for the react-native dep. + if fileExists(dir, filepath.Join("app", "src", "main", "AndroidManifest.xml")) && !hasDep(earlyDeps, "react-native") { + result.Framework = "android" + result.Type = "native" + result.BuildTool = "gradle" + result.BundleID = readAndroidApplicationID(dir) + result.Detected = true + return result + } + + // -- 14. IOS Swift - xcodeproj directory or Package.swift -- + // Vapor (server-side Swift) also uses Package.swift. Guard against it by + // checking for a Vapor dependency before classifying as ios-swift. + if hasXcodeprojDir(dir) || (fileExists(dir, "Package.swift") && !isVaporSwiftPackage(dir)) { + result.Framework = "ios-swift" + result.Type = "native" + result.BundleID = readIOSBundleID(dir) + result.Detected = true + return result + } + + // -- 15. Pom.xml / build.gradle (Java) --. + if content, buildTool, ok := findJavaBuildContent(dir); ok { + fw, port := detectJavaFramework(content) + result.Framework = fw + result.Type = "regular" + result.BuildTool = buildTool + result.Port = port + result.Detected = true + return result + } + + // -- 16. Go.mod --. + if fileExists(dir, "go.mod") { + result.Framework = "vanilla-go" + result.Type = "regular" + result.Detected = true + return result + } + + // -- 17. Gemfile (Ruby on Rails) --. + if data, ok := readFileContent(dir, "Gemfile"); ok { + if strings.Contains(data, "rails") { + result.Framework = "rails" + result.Type = "regular" + result.Port = 3000 + result.Detected = true + return result + } + } + + // -- 18. Requirements.txt / pyproject.toml / Pipfile (Python) -- + // Pipfile and Pipfile.lock are also checked so that Pipenv-based projects + // (which have no requirements.txt) are detected correctly. + for _, pyFile := range []string{"requirements.txt", "pyproject.toml", "Pipfile", "Pipfile.lock"} { + if data, ok := readFileContent(dir, pyFile); ok { + lower := strings.ToLower(data) + if strings.Contains(lower, "flask") { + result.Framework = "vanilla-python" + result.Type = "regular" + result.Port = 5000 + result.Detected = true + return result + } + if strings.Contains(lower, "django") { + result.Framework = "django" + result.Type = "regular" + result.Port = 8000 + result.Detected = true + return result + } + } + } + + // -- 19. Package.json dep scanning (lowest priority) -- + // Note: Ionic deps are already handled above (step 2). + if len(earlyDeps) > 0 { + candidates := collectPackageJSONCandidates(earlyDeps) + switch len(candidates) { + case 1: + c := candidates[0] + result.Framework = c.framework + result.Type = c.qsType + result.BuildTool = c.buildTool + result.Port = defaultPortForFramework(c.framework) + result.Detected = true + // React Native uses the same android/app/build.gradle structure as Flutter. + if c.framework == "react-native" { + result.BundleID = readMobileBundleID(dir) + } + default: + if len(candidates) > 1 { + result.Type = "regular" // All package.json web deps are regular/native. + result.Detected = true + // Use the common port if all candidates agree (e.g. express + hono both use 3000). + commonPort := defaultPortForFramework(candidates[0].framework) + for _, c := range candidates { + if defaultPortForFramework(c.framework) != commonPort { + commonPort = 0 + break + } + } + result.Port = commonPort + for _, c := range candidates { + result.AmbiguousFrameworks = append(result.AmbiguousFrameworks, c.framework) + } + } + } + } + + return result +} + +// collectPackageJSONCandidates returns all framework candidates found in deps. +// Note: Ionic deps (@ionic/angular, @ionic/react, @ionic/vue) are intentionally +// absent here - DetectProject handles them in step 2 with an early return, so they +// can never reach this function. +func collectPackageJSONCandidates(deps map[string]bool) []detectionCandidate { + var candidates []detectionCandidate + // React-native without expo (expo check would have matched earlier in DetectProject). + if hasDep(deps, "react-native") { + candidates = append(candidates, detectionCandidate{framework: "react-native", qsType: "native"}) + } + if hasDep(deps, "express") { + candidates = append(candidates, detectionCandidate{framework: "express", qsType: "regular"}) + } + if hasDep(deps, "hono") { + candidates = append(candidates, detectionCandidate{framework: "hono", qsType: "regular"}) + } + if hasDep(deps, "fastify") { + candidates = append(candidates, detectionCandidate{framework: "fastify", qsType: "regular"}) + } + return candidates +} + +// detectFromCsproj returns framework and type from .csproj file content. +func detectFromCsproj(content string) (framework, qsType string, found bool) { + switch { + case strings.Contains(content, "Microsoft.AspNetCore.Components"): + return "aspnet-blazor", "regular", true + case strings.Contains(content, "Microsoft.Owin"): + return "aspnet-owin", "regular", true + case strings.Contains(content, "Microsoft.AspNetCore.Mvc"): + return "aspnet-mvc", "regular", true + // .NET 6+: MVC is built-in via Microsoft.NET.Sdk.Web - no PackageReference generated. + // Check this after Blazor (AspNetCore.Components) and OWIN to avoid false positives. + case strings.Contains(content, `Sdk="Microsoft.NET.Sdk.Web"`): + return "aspnet-mvc", "regular", true + case strings.Contains(content, "Microsoft.Maui"): + return "maui", "native", true + // A .csproj targeting a mobile TFM (net*-android or net*-ios) without a + // Microsoft.Maui reference is a .NET Mobile (dotnet-mobile) project. + // The regex requires the net. prefix to avoid false positives + // on package names or condition strings that contain bare "-ios"/"-android". + case mobileTFMRegex.MatchString(content): + return "dotnet-mobile", "native", true + case strings.Contains(content, "-windows"): + return "wpf-winforms", "native", true + } + return "", "", false +} + +// detectJavaFramework returns the framework key and default port from Java build file content. +func detectJavaFramework(content string) (framework string, port int) { + lower := strings.ToLower(content) + switch { + case strings.Contains(lower, "spring-boot") || + strings.Contains(lower, "springframework.boot"): + return "spring-boot", 8080 + 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"): + return "java-ee", 0 + default: + return "vanilla-java", 0 + } +} + +func isExpoProject(dir string) bool { + data, err := os.ReadFile(filepath.Join(dir, "app.json")) + if err != nil { + return false + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + return false + } + _, hasExpoKey := obj["expo"] + return hasExpoKey +} + +// readRawExpoScheme reads the "expo.scheme" field from app.json without validating it. +// Returns the raw string value (may be empty or invalid). +func readRawExpoScheme(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, "app.json")) + if err != nil { + return "" + } + var obj struct { + Expo struct { + Scheme string `json:"scheme"` + } `json:"expo"` + } + if err := json.Unmarshal(data, &obj); err != nil { + return "" + } + return obj.Expo.Scheme +} + +// readExpoScheme reads the "expo.scheme" from app.json and validates it per RFC 3986. +// Returns empty string if absent, invalid, or on any error. +func readExpoScheme(dir string) string { + scheme := readRawExpoScheme(dir) + if !isValidURIScheme(scheme) { + return "" + } + return scheme +} + +// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). +func isValidURIScheme(s string) bool { + if len(s) == 0 { + return false + } + for i, r := range s { + if i == 0 { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) { + return false + } + } else { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '+' || r == '-' || r == '.') { + return false + } + } + } + return true +} + +// pubspecHasFlutterWebTarget returns true if pubspec.yaml content contains a "web:" +// key nested under the top-level "flutter:" section. This indicates the project +// explicitly targets Flutter Web (e.g. via "flutter create --platforms=web"). +func pubspecHasFlutterWebTarget(data string) bool { + inFlutterSection := false + for _, line := range strings.Split(data, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + // A line with no leading whitespace is a top-level YAML key. + isTopLevel := len(line) > 0 && line[0] != ' ' && line[0] != '\t' + if isTopLevel { + inFlutterSection = strings.HasPrefix(line, "flutter:") + continue + } + if inFlutterSection && strings.HasPrefix(trimmed, "web:") { + return true + } + } + return false +} + +func dirExists(dir, name string) bool { + info, err := os.Stat(filepath.Join(dir, name)) + return err == nil && info.IsDir() +} + +func fileExists(dir, name string) bool { + _, err := os.Stat(filepath.Join(dir, name)) + return err == nil +} + +func fileExistsAny(dir string, names ...string) bool { + for _, name := range names { + if fileExists(dir, name) { + return true + } + } + return false +} + +const maxDetectionFileSize = 10 * 1024 * 1024 // 10 MB. + +func readFileContent(dir, name string) (string, bool) { + filePath := filepath.Join(dir, name) + info, err := os.Stat(filePath) + if err != nil || info.Size() > maxDetectionFileSize { + return "", false + } + data, err := os.ReadFile(filePath) + if err != nil { + return "", false + } + return string(data), true +} + +// readPackageJSONDeps reads package.json and returns a set of all dependency names +// (from both "dependencies" and "devDependencies"). +func readPackageJSONDeps(dir string) map[string]bool { + data, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return make(map[string]bool) + } + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return make(map[string]bool) + } + deps := make(map[string]bool) + for k := range pkg.Dependencies { + deps[k] = true + } + for k := range pkg.DevDependencies { + deps[k] = true + } + return deps +} + +// readPackageJSONName reads the "name" field from package.json in dir. +// Returns empty string if not found or on any error. +func readPackageJSONName(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return "" + } + var pkg struct { + Name string `json:"name"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return "" + } + return pkg.Name +} + +// readProjectName tries to extract a meaningful project name from language-specific +// manifest files. It falls back to empty string if none are found; the caller then +// uses filepath.Base(dir). +func readProjectName(dir string) string { + if name := readPackageJSONName(dir); name != "" { + return name + } + if name := readGoModuleName(dir); name != "" { + return name + } + if name := readPyprojectName(dir); name != "" { + return name + } + if name := readPubspecName(dir); name != "" { + return name + } + if name := readComposerName(dir); name != "" { + return name + } + if name := readPomArtifactID(dir); name != "" { + return name + } + return "" +} + +// readGoModuleName reads the module path from go.mod and returns its last path segment. +func readGoModuleName(dir string) string { + data, ok := readFileContent(dir, "go.mod") + if !ok { + return "" + } + for _, line := range strings.SplitN(data, "\n", 20) { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + modulePath := strings.TrimSpace(strings.TrimPrefix(line, "module ")) + return filepath.Base(modulePath) + } + } + return "" +} + +// readPyprojectName reads the project name from pyproject.toml ([project] or [tool.poetry] section). +func readPyprojectName(dir string) string { + data, ok := readFileContent(dir, "pyproject.toml") + if !ok { + return "" + } + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "name ") && !strings.HasPrefix(line, "name=") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"'`) + if val != "" { + return val + } + } + return "" +} + +// readPubspecName reads the name field from pubspec.yaml. +func readPubspecName(dir string) string { + data, ok := readFileContent(dir, "pubspec.yaml") + if !ok { + return "" + } + for _, line := range strings.Split(data, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "name:") { + val := strings.TrimSpace(strings.TrimPrefix(trimmed, "name:")) + if val != "" { + return val + } + } + } + return "" +} + +// readComposerName reads the package name from composer.json and returns the part after "/". +func readComposerName(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, "composer.json")) + if err != nil { + return "" + } + var pkg struct { + Name string `json:"name"` + } + if err := json.Unmarshal(data, &pkg); err != nil || pkg.Name == "" { + return "" + } + if idx := strings.LastIndex(pkg.Name, "/"); idx >= 0 { + return pkg.Name[idx+1:] + } + return pkg.Name +} + +// readPomArtifactID reads the project value from pom.xml. +// It strips the ... block first so the parent's artifactId +// (e.g. spring-boot-starter-parent) is not returned instead of the project's own. +func readPomArtifactID(dir string) string { + data, ok := readFileContent(dir, "pom.xml") + if !ok { + return "" + } + // Strip ... so we don't accidentally pick up the parent artifactId. + if ps := strings.Index(data, ""); ps != -1 { + if pe := strings.Index(data[ps:], ""); pe != -1 { + data = data[:ps] + data[ps+pe+len(""):] + } + } + const open = "" + const closeTag = "" + start := strings.Index(data, open) + if start == -1 { + return "" + } + start += len(open) + end := strings.Index(data[start:], closeTag) + if end == -1 { + return "" + } + return strings.TrimSpace(data[start : start+end]) +} + +func hasDep(deps map[string]bool, name string) bool { + return deps[name] +} + +func findCsprojContent(dir string) (string, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") { + if data, fileErr := os.ReadFile(filepath.Join(dir, e.Name())); fileErr == nil { + return string(data), true + } + } + } + return "", false +} + +// mobileTFMRegex matches .NET mobile Target Framework Monikers (net*-android or net*-ios). +// A bare substring match on "-android" / "-ios" can produce false positives on package +// names such as "Newtonsoft.Json-ios" or condition attributes. Requiring the leading +// net. prefix eliminates those false positives. +var mobileTFMRegex = regexp.MustCompile(`net\d+\.\d+-(?:android|ios)`) + +// detectBuildToolFromFiles detects the build tool by checking for config files in dir. +// Falls back to the conventional default for the framework if no relevant file is found. +func detectBuildToolFromFiles(dir, framework string) string { + if fileExistsAny(dir, "vite.config.ts", "vite.config.js") { + return "vite" + } + if fileExists(dir, "pom.xml") { + return "maven" + } + if fileExistsAny(dir, "build.gradle", "build.gradle.kts") { + return "gradle" + } + if fileExists(dir, "composer.json") { + return "composer" + } + // Framework-specific defaults as fallback. + switch framework { + case "ionic-react", "ionic-vue", "sveltekit": + return "vite" + case "spring-boot", "vanilla-java", "java-ee": + return "maven" + case "laravel", "vanilla-php": + return "composer" + } + return "" +} + +// findJavaBuildContent finds pom.xml or build.gradle and returns content + build tool name. +func findJavaBuildContent(dir string) (content, buildTool string, ok bool) { + if data, err := os.ReadFile(filepath.Join(dir, "pom.xml")); err == nil { + return string(data), "maven", true + } + if data, err := os.ReadFile(filepath.Join(dir, "build.gradle")); err == nil { + return string(data), "gradle", true + } + if data, err := os.ReadFile(filepath.Join(dir, "build.gradle.kts")); err == nil { + return string(data), "gradle", true + } + return "", "", false +} + +// readMobileBundleID reads the application ID for Flutter and React Native projects. +// It first checks android/app/build.gradle (applicationId). For iOS-only projects +// (no android/ directory or no applicationId found), it falls back to reading +// ios/Runner.xcodeproj/project.pbxproj (PRODUCT_BUNDLE_IDENTIFIER) and then +// ios/Runner/Info.plist (CFBundleIdentifier, if not a build variable reference). +// +// Note: when both Android and iOS are present, the Android applicationId is returned +// for both platform callback URL patterns. In practice these identifiers usually match, +// but they can differ for projects that set them independently - in that case the user +// should update the Auth0 Dashboard callback URLs manually. +func readMobileBundleID(dir string) string { + if data, err := os.ReadFile(filepath.Join(dir, "android", "app", "build.gradle")); err == nil { + if id := extractGradleApplicationID(string(data)); id != "" { + return id + } + } + // IOS fallback: try project.pbxproj first (the canonical source for the bundle ID), + // then Info.plist (only when CFBundleIdentifier is not a build variable reference). + return readIOSBundleID(dir) +} + +// readIOSBundleID reads the bundle identifier from iOS project files. +// It checks, in order: +// 1. ios/Runner.xcodeproj/project.pbxproj (Flutter path) +// 2. Any root-level *.xcodeproj/project.pbxproj (native Xcode projects) +// 3. ios/Runner/Info.plist (Flutter fallback) +// +// Returns empty string if no concrete (non-variable) bundle ID is found. +func readIOSBundleID(dir string) string { + // Flutter path. + if data, err := os.ReadFile(filepath.Join(dir, "ios", "Runner.xcodeproj", "project.pbxproj")); err == nil { + if id := extractPbxprojBundleID(string(data)); id != "" { + return id + } + } + // Native Xcode projects place the .xcodeproj at the root of the repo. + if entries, err := os.ReadDir(dir); err == nil { + for _, e := range entries { + if e.IsDir() && strings.HasSuffix(e.Name(), ".xcodeproj") { + pbx := filepath.Join(dir, e.Name(), "project.pbxproj") + if data, err := os.ReadFile(pbx); err == nil { + if id := extractPbxprojBundleID(string(data)); id != "" { + return id + } + } + } + } + } + if data, err := os.ReadFile(filepath.Join(dir, "ios", "Runner", "Info.plist")); err == nil { + if id := extractInfoPlistBundleID(string(data)); id != "" { + return id + } + } + return "" +} + +// pbxprojBundleIDRegex matches PRODUCT_BUNDLE_IDENTIFIER = com.example.app; in project.pbxproj. +var pbxprojBundleIDRegex = regexp.MustCompile(`PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*;`) + +// extractPbxprojBundleID extracts the app target's PRODUCT_BUNDLE_IDENTIFIER from +// project.pbxproj content. It iterates all occurrences and skips test-target bundle +// IDs (those ending in .Tests, .RunnerTests, or .UITests), which typically appear +// before the app target in the file. Returns empty string if no app-target ID is found. +func extractPbxprojBundleID(content string) string { + all := pbxprojBundleIDRegex.FindAllStringSubmatch(content, -1) + for _, m := range all { + if len(m) < 2 { + continue + } + id := strings.TrimSpace(m[1]) + // Skip test-target bundle IDs. In Xcode-generated project.pbxproj files, + // test targets appear before the app target. Both dotted suffixes + // (com.example.app.Tests, com.example.app.UITests) and concatenated + // suffixes (com.example.appTests) all end in "Tests". + if strings.HasSuffix(id, "Tests") { + continue + } + return id + } + return "" +} + +// infoPlistBundleIDRegex matches CFBundleIdentifier followed by value. +var infoPlistBundleIDRegex = regexp.MustCompile(`CFBundleIdentifier\s*([^<]+)`) + +// extractInfoPlistBundleID extracts CFBundleIdentifier from Info.plist content. +// Returns empty string if absent or if the value is an Xcode build variable reference +// (e.g. "$(PRODUCT_BUNDLE_IDENTIFIER)"). +func extractInfoPlistBundleID(content string) string { + matches := infoPlistBundleIDRegex.FindStringSubmatch(content) + if len(matches) < 2 { + return "" + } + id := strings.TrimSpace(matches[1]) + // Skip Xcode variable references such as "$(PRODUCT_BUNDLE_IDENTIFIER)". + if strings.Contains(id, "$") { + return "" + } + return id +} + +// gradleAppIDRegex matches applicationId in build.gradle files. +// Supports both double-quoted ("com.example.app") and single-quoted ('com.example.app') +// forms (Groovy DSL), as well as the Kotlin DSL assignment form +// applicationId = "com.example.app". +var gradleAppIDRegex = regexp.MustCompile(`applicationId\s*=?\s*["']([a-zA-Z][a-zA-Z0-9._-]*)["']`) + +// extractGradleApplicationID extracts the applicationId value from build.gradle content. +func extractGradleApplicationID(content string) string { + matches := gradleAppIDRegex.FindStringSubmatch(content) + if len(matches) < 2 { + return "" + } + return matches[1] +} + +// capacitorTSAppIDRegex extracts the appId value from capacitor.config.ts. +// Uses alternation to enforce matching quotes (prevents mismatched-quote false positives). +var capacitorTSAppIDRegex = regexp.MustCompile(`appId\s*:\s*(?:'([^']*)'|"([^"]*)")`) + +// readCapacitorAppID reads the "appId" field from capacitor.config.json or +// capacitor.config.ts in dir. Capacitor v3+ defaults to the TypeScript config. +// Returns empty string if neither file is present or the field is missing. +func readCapacitorAppID(dir string) string { + // Try JSON config first (Capacitor v2 and v3+ both support it). + if data, err := os.ReadFile(filepath.Join(dir, "capacitor.config.json")); err == nil { + var cfg struct { + AppID string `json:"appId"` + } + if jsonErr := json.Unmarshal(data, &cfg); jsonErr == nil && cfg.AppID != "" { + return cfg.AppID + } + } + // Fall back to TypeScript config (Capacitor v3+ default). + // Process line-by-line to skip comment lines that may contain an appId value + // (e.g. "// appId: 'old.value'") which would otherwise be matched first. + if data, err := os.ReadFile(filepath.Join(dir, "capacitor.config.ts")); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + if m := capacitorTSAppIDRegex.FindStringSubmatch(line); len(m) >= 3 { + // M[1] = single-quoted match, m[2] = double-quoted match. + if m[1] != "" { + return m[1] + } + if m[2] != "" { + return m[2] + } + } + } + } + return "" +} + +// readDotnetMobileBundleID extracts the element from .csproj content. +// Used for MAUI and .NET Mobile apps to generate callback URL guidance. +// Returns empty string if the element is absent. +func readDotnetMobileBundleID(content string) string { + matches := csprojAppIDRegex.FindStringSubmatch(content) + if len(matches) < 2 { + return "" + } + return strings.TrimSpace(matches[1]) +} + +// csprojAppIDRegex matches the element in a .csproj file. +var csprojAppIDRegex = regexp.MustCompile(`\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*`) + +// isVaporSwiftPackage returns true if Package.swift in dir contains a Vapor dependency. +// Vapor's Package.swift references "vapor/vapor.git" or the package URL contains "vapor/vapor". +func isVaporSwiftPackage(dir string) bool { + data, ok := readFileContent(dir, "Package.swift") + if !ok { + return false + } + return strings.Contains(data, "vapor/vapor") +} + +// hasXcodeprojDir returns true if any directory entry in dir ends with ".xcodeproj". +// An .xcodeproj bundle is the primary signal file for Xcode-based iOS/macOS Swift projects. +func hasXcodeprojDir(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() && strings.HasSuffix(e.Name(), ".xcodeproj") { + return true + } + } + return false +} + +// readAndroidApplicationID reads the applicationId from app/build.gradle or app/build.gradle.kts. +func readAndroidApplicationID(dir string) string { + for _, name := range []string{ + filepath.Join("app", "build.gradle"), + filepath.Join("app", "build.gradle.kts"), + } { + if data, err := os.ReadFile(filepath.Join(dir, name)); err == nil { + if id := extractGradleApplicationID(string(data)); id != "" { + return id + } + } + } + return "" +} + +// detectionFriendlyAppType returns a concise label for the detection summary display. +func detectionFriendlyAppType(qsType string) string { + switch qsType { + case "spa": + return "Single Page App" + case "regular": + return "Regular Web App" + case "native": + return "Native / Mobile" + case "m2m": + return "Machine to Machine" + default: + return qsType + } +} diff --git a/internal/cli/quickstart_detect_test.go b/internal/cli/quickstart_detect_test.go new file mode 100644 index 000000000..4493ead42 --- /dev/null +++ b/internal/cli/quickstart_detect_test.go @@ -0,0 +1,3289 @@ +package cli + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/auth0/go-auth0/management" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" +) + +// -- test helpers --. + +func writeTestFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0600)) +} + +func mkTestDir(t *testing.T, dir, sub string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0755)) +} + +// -- QuickstartConfig invariants --. + +// TestSvelteKitViteAndNoneShareConfig verifies that regular:sveltekit:vite and +// regular:sveltekit:none use the same server-side env var keys. Both configs +// target SvelteKit SSR which requires a client secret regardless of build tool. +func TestSvelteKitViteAndNoneShareConfig(t *testing.T) { + viteConfig, ok := auth0.QuickstartConfigs["regular:sveltekit:vite"] + require.True(t, ok, "regular:sveltekit:vite must exist in QuickstartConfigs") + noneConfig, ok := auth0.QuickstartConfigs["regular:sveltekit:none"] + require.True(t, ok, "regular:sveltekit:none must exist in QuickstartConfigs") + + assert.Equal(t, noneConfig.EnvValues, viteConfig.EnvValues, + "regular:sveltekit:vite and regular:sveltekit:none must share the same env var keys") + assert.Equal(t, noneConfig.RequestParams, viteConfig.RequestParams, + "regular:sveltekit:vite and regular:sveltekit:none must share the same RequestParams") + assert.Equal(t, noneConfig.Strategy, viteConfig.Strategy, + "regular:sveltekit:vite and regular:sveltekit:none must share the same Strategy") +} + +// TestWPFWinformsConfigHasNoClientSecret verifies that the WPF/WinForms config does +// not include Auth0:ClientSecret. WPF/WinForms apps are public native clients using +// PKCE; Auth0 returns an empty/placeholder secret for native app types. +func TestWPFWinformsConfigHasNoClientSecret(t *testing.T) { + wpfConfig, ok := auth0.QuickstartConfigs["native:wpf-winforms:none"] + require.True(t, ok, "native:wpf-winforms:none must exist in QuickstartConfigs") + + _, hasSecret := wpfConfig.EnvValues["Auth0:ClientSecret"] + assert.False(t, hasSecret, "native:wpf-winforms:none must not have Auth0:ClientSecret") +} + +// -- DetectProject - no signal --. + +func TestDetectProject_NoDetection(t *testing.T) { + dir := t.TempDir() + got := DetectProject(dir) + assert.False(t, got.Detected) + assert.Empty(t, got.Framework) + assert.Empty(t, got.Type) +} + +// -- DetectProject - SPA --. + +// Auth0 qs setup --app --type spa --framework react --build-tool vite. +func TestDetectProject_React(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"name":"my-react-app","dependencies":{"react":"^18"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "react", got.Framework) + assert.Equal(t, "spa", got.Type) + assert.Equal(t, "vite", got.BuildTool) + assert.Equal(t, 5173, got.Port) + assert.Equal(t, "my-react-app", got.AppName) +} + +// Auth0 qs setup --app --type spa --framework angular. +func TestDetectProject_Angular(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "angular.json", `{}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "angular", got.Framework) + assert.Equal(t, "spa", got.Type) + assert.Empty(t, got.BuildTool) + assert.Equal(t, 4200, got.Port) +} + +// Auth0 qs setup --app --type spa --framework vue --build-tool vite. +func TestDetectProject_Vue(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.js", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"vue":"^3"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vue", got.Framework) + assert.Equal(t, "spa", got.Type) + assert.Equal(t, "vite", got.BuildTool) + assert.Equal(t, 5173, got.Port) +} + +// Auth0 qs setup --app --type spa --framework svelte --build-tool vite. +func TestDetectProject_Svelte(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"svelte":"^4"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "svelte", got.Framework) + assert.Equal(t, "spa", got.Type) + assert.Equal(t, "vite", got.BuildTool) +} + +// Auth0 qs setup --app --type spa --framework vanilla-javascript --build-tool vite. +func TestDetectProject_VanillaJavaScript(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"some-utility":"^1"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-javascript", got.Framework) + assert.Equal(t, "spa", got.Type) + assert.Equal(t, "vite", got.BuildTool) +} + +func TestDetectProject_VanillaJavaScript_NoPackageJSON(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.js", "") + // No package.json -> deps are empty -> falls through to vanilla-javascript. + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-javascript", got.Framework) + assert.Equal(t, "spa", got.Type) +} + +// Auth0 qs setup --app --type spa --framework flutter-web. +func TestDetectProject_FlutterWeb(t *testing.T) { + dir := t.TempDir() + // "web:" nested under "flutter:" is the signal for Flutter Web intent. + writeTestFile(t, dir, "pubspec.yaml", "name: my_flutter_web\nflutter:\n sdk: flutter\n web:\n renderer: auto\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "flutter-web", got.Framework) + assert.Equal(t, "spa", got.Type) +} + +// pubspec.yaml with sdk: flutter but no web: target and no platform dirs -> native flutter. +func TestDetectProject_FlutterNativeNoPlatformDirs(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pubspec.yaml", "name: my_flutter_native\nflutter:\n sdk: flutter\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "flutter", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// pubspec.yaml with android/ dir -> native flutter (android/ is the reliable native signal). +func TestDetectProject_Flutter_WithoutWeb(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pubspec.yaml", "name: my_flutter_app\nflutter:\n sdk: flutter\n") + // Simulate default `flutter create` output: has android/ and ios/ (and web/ too, but native wins). + mkTestDir(t, dir, "android") + mkTestDir(t, dir, "ios") + mkTestDir(t, dir, "web") + require.NoError(t, os.WriteFile(filepath.Join(dir, "web", "index.html"), []byte(""), 0600)) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "flutter", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// pubspec.yaml without sdk: flutter is not detected. +func TestDetectProject_PubspecWithoutFlutter(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pubspec.yaml", "name: dart_only\nversion: 1.0.0\n") + + got := DetectProject(dir) + assert.False(t, got.Detected) +} + +// -- DetectProject - Regular Web Apps --. + +// Auth0 qs setup --app --type regular --framework nextjs. +func TestDetectProject_NextJS_ConfigJS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "next.config.js", "") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "nextjs", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 3000, got.Port) +} + +func TestDetectProject_NextJS_ConfigTS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "next.config.ts", "") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "nextjs", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +func TestDetectProject_NextJS_ConfigMJS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "next.config.mjs", "") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "nextjs", got.Framework) +} + +// Auth0 qs setup --app --type regular --framework nuxt. +func TestDetectProject_Nuxt_ConfigTS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "nuxt.config.ts", "") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "nuxt", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 3000, got.Port) +} + +func TestDetectProject_Nuxt_ConfigJS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "nuxt.config.js", "") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "nuxt", got.Framework) +} + +// Nuxt uses Vite internally so real Nuxt projects often contain vite.config.ts. +// Nuxt must be detected as nuxt (regular), not as a Vite SPA. +func TestDetectProject_Nuxt_WithViteConfig(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "nuxt.config.ts", "") + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"nuxt":"^3","vue":"^3"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "nuxt", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 3000, got.Port) +} + +// Auth0 qs setup --app --type regular --framework sveltekit. +func TestDetectProject_SvelteKit_ConfigJS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "svelte.config.js", "") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "sveltekit", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +func TestDetectProject_SvelteKit_ConfigTS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "svelte.config.ts", "") + + got := DetectProject(dir) + assert.Equal(t, "sveltekit", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Auth0 qs setup --app --type regular --framework fastify. +func TestDetectProject_Fastify(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"fastify":"^4"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "fastify", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 3000, got.Port) +} + +// Auth0 qs setup --name express-app --api ... --app --type regular --framework express. +func TestDetectProject_Express(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"express":"^4"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "express", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 3000, got.Port) +} + +// Auth0 qs setup --app --type regular --framework hono. +func TestDetectProject_Hono(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"hono":"^3"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "hono", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 3000, got.Port) +} + +// Auth0 qs setup --app --type regular --framework vanilla-python. +func TestDetectProject_VanillaPython_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "requirements.txt", "flask==2.0\nwerkzeug\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-python", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 5000, got.Port) +} + +func TestDetectProject_VanillaPython_Pyproject(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pyproject.toml", "[project]\nname = \"myapp\"\ndependencies = [\"Flask>=2.0\"]\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-python", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +func TestDetectProject_VanillaPython_CaseInsensitive(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "requirements.txt", "Flask==2.0\n") + + got := DetectProject(dir) + assert.Equal(t, "vanilla-python", got.Framework) +} + +// Auth0 qs setup --app --type regular --framework vanilla-go. +func TestDetectProject_VanillaGo(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go.mod", "module github.com/my-org/my-service\n\ngo 1.21\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-go", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Auth0 qs setup --app --type regular --framework rails. +func TestDetectProject_Rails(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "Gemfile", "source 'https://rubygems.org'\ngem 'rails', '~> 7.0'\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "rails", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 3000, got.Port) +} + +func TestDetectProject_GemfileWithoutRails(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "Gemfile", "source 'https://rubygems.org'\ngem 'sinatra'\n") + + got := DetectProject(dir) + assert.False(t, got.Detected) +} + +// Auth0 qs setup --app --type regular --framework vanilla-java (pom.xml). +func TestDetectProject_VanillaJava_Maven(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", `my-app`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-java", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "maven", got.BuildTool) +} + +// Auth0 qs setup --app --type regular --framework java-ee. +func TestDetectProject_JavaEE_JaxServlet(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, "regular", got.Type) + assert.Equal(t, "maven", got.BuildTool) +} + +func TestDetectProject_JavaEE_JakartaEE(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", `jakarta.ee`) + + got := DetectProject(dir) + assert.Equal(t, "java-ee", got.Framework) +} + +func TestDetectProject_JavaEE_JakartaServlet(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", `jakarta.servlet`) + + got := DetectProject(dir) + assert.Equal(t, "java-ee", got.Framework) +} + +func TestDetectProject_JavaEE_JaxEE(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", `javax.ee`) + + got := DetectProject(dir) + assert.Equal(t, "java-ee", got.Framework) +} + +// Auth0 qs setup --app --type regular --framework spring-boot. +func TestDetectProject_SpringBoot_Maven(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", `spring-boot-starter-parent`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "spring-boot", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "maven", got.BuildTool) + assert.Equal(t, 8080, got.Port) +} + +func TestDetectProject_SpringBoot_Gradle(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "build.gradle", `dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' }`) + + got := DetectProject(dir) + assert.Equal(t, "spring-boot", 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 }`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-java", got.Framework) + assert.Equal(t, "gradle", got.BuildTool) +} + +// Auth0 qs setup --app --type regular --framework aspnet-mvc. +func TestDetectProject_AspnetMVC(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + ``) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "aspnet-mvc", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Auth0 qs setup --app --type regular --framework aspnet-blazor. +func TestDetectProject_AspnetBlazor(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + ``) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "aspnet-blazor", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Auth0 qs setup --app --type regular --framework aspnet-owin. +func TestDetectProject_AspnetOwin(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + ``) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "aspnet-owin", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Auth0 qs setup --app --type regular --framework vanilla-php. +func TestDetectProject_VanillaPHP(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "composer.json", `{"name":"my/app","require":{"php":"^8.0"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-php", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "composer", got.BuildTool) +} + +// Auth0 qs setup --app --type regular --framework laravel. +func TestDetectProject_Laravel(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "composer.json", `{"name":"my/laravel-app","require":{"laravel/framework":"^10.0"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "laravel", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "composer", got.BuildTool) + assert.Equal(t, 8000, got.Port) +} + +// -- DetectProject - Native / Mobile --. + +// Auth0 qs setup --app --type native --framework flutter. +func TestDetectProject_Flutter(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pubspec.yaml", "name: my_flutter_app\nflutter:\n sdk: flutter\n") + // Android/ present -> native (reliable signal for native intent). + mkTestDir(t, dir, "android") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "flutter", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Auth0 qs setup --app --type native --framework react-native. +func TestDetectProject_ReactNative(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"react-native":"^0.72"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "react-native", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Auth0 qs setup --app --type native --framework expo. +func TestDetectProject_Expo(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "expo.json", `{"expo":{"name":"my-expo-app"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "expo", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// expo.json takes priority over react-native in package.json. +func TestDetectProject_ExpoBeatsReactNative(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "expo.json", `{"expo":{}}`) + writeTestFile(t, dir, "package.json", `{"dependencies":{"react-native":"^0.72"}}`) + + got := DetectProject(dir) + assert.Equal(t, "expo", got.Framework) +} + +// Auth0 qs setup --app --type native --framework ionic-angular. +func TestDetectProject_IonicAngular(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/angular":"^7"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "ionic-angular", got.Framework) + assert.Equal(t, "native", got.Type) + assert.Empty(t, got.BuildTool) +} + +// Auth0 qs setup --app --type native --framework ionic-react --build-tool vite. +func TestDetectProject_IonicReact(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/react":"^7"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "ionic-react", got.Framework) + assert.Equal(t, "native", got.Type) + assert.Equal(t, "vite", got.BuildTool) +} + +// Auth0 qs setup --app --type native --framework ionic-vue --build-tool vite. +func TestDetectProject_IonicVue(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/vue":"^7"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "ionic-vue", got.Framework) + assert.Equal(t, "native", got.Type) + assert.Equal(t, "vite", got.BuildTool) + assert.Empty(t, got.BundleID) +} + +// Auth0 qs setup --app --type native --framework maui (.NET Android/iOS). +// A .csproj with only mobile TFMs (no Microsoft.Maui reference) is dotnet-mobile, +// not MAUI. This matches the report Bug 4 fix. +func TestDetectProject_DotnetMobile_AndroidIOS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + `net8.0-android;net8.0-ios`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "dotnet-mobile", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// A .csproj with Microsoft.Maui reference alongside mobile TFMs is MAUI. +func TestDetectProject_MAUI_WithMauiReference(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + `net8.0-android;net8.0-ios`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "maui", got.Framework) + assert.Equal(t, "native", got.Type) +} + +func TestDetectProject_MAUI_ExplicitSDK(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + `true`) + + got := DetectProject(dir) + assert.Equal(t, "maui", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Auth0 qs setup --app --type native --framework wpf-winforms. +func TestDetectProject_WPFWinforms(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + `net8.0-windows`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "wpf-winforms", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Auth0 qs setup --app --type native --framework android. +func TestDetectProject_Android(t *testing.T) { + dir := t.TempDir() + // Create the AndroidManifest.xml in its standard location. + mkTestDir(t, dir, filepath.Join("app", "src", "main")) + writeTestFile(t, filepath.Join(dir, "app", "src", "main"), "AndroidManifest.xml", + ``) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "android", got.Framework) + assert.Equal(t, "native", got.Type) + assert.Equal(t, "gradle", got.BuildTool) +} + +// Android must win over the Java build.gradle check when AndroidManifest.xml is present. +func TestDetectProject_Android_BeatsVanillaJava(t *testing.T) { + dir := t.TempDir() + mkTestDir(t, dir, filepath.Join("app", "src", "main")) + writeTestFile(t, filepath.Join(dir, "app", "src", "main"), "AndroidManifest.xml", "") + writeTestFile(t, dir, "build.gradle", `plugins { id 'com.android.application' }`) + + got := DetectProject(dir) + assert.Equal(t, "android", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Auth0 qs setup --app --type native --framework ios-swift (xcodeproj). +func TestDetectProject_IOSSwift_Xcodeproj(t *testing.T) { + dir := t.TempDir() + // .xcodeproj is a directory (bundle) in Xcode projects. + mkTestDir(t, dir, "MyApp.xcodeproj") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "ios-swift", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Auth0 qs setup --app --type native --framework ios-swift (Swift Package Manager). +func TestDetectProject_IOSSwift_PackageSwift(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "Package.swift", "// swift-tools-version:5.9\nimport PackageDescription\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "ios-swift", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Manage.py is generated by django-admin startproject and is the primary signal. +func TestDetectProject_Django_ManagePy(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "manage.py", `#!/usr/bin/env python`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "django", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 8000, got.Port) +} + +// manage.py must beat @ionic/angular in a Django+Ionic monorepo. +// Before the fix, the Ionic dep check fired first and returned early, causing +// the project to be detected as Ionic instead of Django. +func TestDetectProject_Django_ManagePy_BeatsIonic(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "manage.py", `#!/usr/bin/env python`) + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/angular":"^7"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "django", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 8000, got.Port) +} + +// manage.py must be detected as Django even when a vite.config.ts is present +// (fullstack project with a Django backend and a Vite-powered frontend). +func TestDetectProject_Django_ManagePy_BeatsViteConfig(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "manage.py", `#!/usr/bin/env python`) + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"react":"^18"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "django", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 8000, got.Port) +} + +// Django in requirements.txt (no manage.py) is detected as django. +func TestDetectProject_Django_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "requirements.txt", "django==4.2\npsycopg2-binary\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "django", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 8000, got.Port) +} + +// Django in pyproject.toml (no manage.py) is detected as django. +func TestDetectProject_Django_Pyproject(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pyproject.toml", "[project]\nname = \"mysite\"\ndependencies = [\"Django>=4.0\"]\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "django", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 8000, got.Port) +} + +// Flask in Pipfile is detected as vanilla-python. +func TestDetectProject_Flask_Pipfile(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "Pipfile", "[packages]\nflask = \"*\"\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-python", got.Framework) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, 5000, got.Port) +} + +// Django in Pipfile is detected as django. +func TestDetectProject_Django_Pipfile(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "Pipfile", "[packages]\ndjango = \"*\"\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "django", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Django in Pipfile.lock (JSON format) is detected as django. +func TestDetectProject_Django_PipfileLock(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "Pipfile.lock", `{"default":{"django":{"version":"==4.2"}}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "django", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Flask takes priority over Django when both appear in the same file (first match wins). +func TestDetectProject_FlaskTakesPriorityOverDjango(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "requirements.txt", "flask==2.0\ndjango==4.0\n") + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "vanilla-python", got.Framework) +} + +// -- DetectProject - priority rules --. + +// angular.json beats package.json deps (checked first). +func TestDetectProject_AngularPriorityOverPackageJSON(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "angular.json", `{}`) + writeTestFile(t, dir, "package.json", `{"dependencies":{"react":"^18"}}`) + + got := DetectProject(dir) + assert.Equal(t, "angular", got.Framework) +} + +// vite config beats package.json dep-only scan (step 3 < step 14). +func TestDetectProject_ViteConfigBeatsPackageJSONScan(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"express":"^4","react":"^18"}}`) + + // Vite.config.ts found first; react dep wins over express. + got := DetectProject(dir) + assert.Equal(t, "react", got.Framework) + assert.Equal(t, "spa", got.Type) +} + +// Ambiguous: multiple package.json web deps with no config file. +// Bug 17: port must still be shown when framework is ambiguous but all candidates share a port. +func TestDetectProject_AmbiguousPackageJSON(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"express":"^4","hono":"^3"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Empty(t, got.Framework) + assert.Len(t, got.AmbiguousFrameworks, 2) + assert.Contains(t, got.AmbiguousFrameworks, "express") + assert.Contains(t, got.AmbiguousFrameworks, "hono") + // Both express and hono default to port 3000, so the common port must be set. + assert.Equal(t, 3000, got.Port) +} + +// Bug 1: @sveltejs/kit dep must override vite.config.ts + svelte dep → sveltekit (regular), +// not svelte (spa). Plain Svelte+Vite also has vite.config.ts and a svelte dep, so the kit +// dep is the only reliable distinguisher. +func TestDetectProject_SvelteKitDepBeatsViteAndSvelteDep(t *testing.T) { + dir := t.TempDir() + // Simulate a real SvelteKit project: has vite.config.ts, svelte dep, AND @sveltejs/kit. + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "svelte.config.js", "") + writeTestFile(t, dir, "package.json", `{"devDependencies":{"@sveltejs/kit":"^2","svelte":"^5","vite":"^7"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "sveltekit", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Create-expo-app has generated app.json (not expo.json) since SDK 46 in 2022. +func TestDetectProject_ExpoViaAppJSON(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"name":"my-app","slug":"my-app"}}`) + writeTestFile(t, dir, "package.json", `{"dependencies":{"expo":"~54.0.0","react-native":"0.81.5"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "expo", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Ionic start --type=angular generates angular.json in the project root. +func TestDetectProject_IonicAngularBeatsAngularJSON(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "angular.json", `{}`) + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/angular":"^7","@angular/core":"^17"}}`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "ionic-angular", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Bug 3: @ionic/react must win over vite.config.ts + react dep. +func TestDetectProject_IonicReactBeatsViteAndReact(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/react":"^8","react":"^18"}}`) + + got := DetectProject(dir) + assert.Equal(t, "ionic-react", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Bug 3: @ionic/vue must win over vite.config.ts + vue dep. +func TestDetectProject_IonicVueBeatsViteAndVue(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/vue":"^8","vue":"^3"}}`) + + got := DetectProject(dir) + assert.Equal(t, "ionic-vue", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Bug 8: jakarta.platform:jakarta.jakartaee-api is the standard Jakarta EE 9+ BOM; +// it must be detected as java-ee, not vanilla-java. +func TestDetectProject_JavaEE_JakartaPlatform(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", + `jakarta.platform`+ + `jakarta.jakartaee-api`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "java-ee", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Bug 10: dotnet new mvc on .NET 6+ does NOT generate a Microsoft.AspNetCore.Mvc +// PackageReference - it only sets Sdk="Microsoft.NET.Sdk.Web". Must still detect as aspnet-mvc. +func TestDetectProject_AspnetMVC_DotNet6NoPkgRef(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + `net10.0`) + + got := DetectProject(dir) + assert.True(t, got.Detected) + assert.Equal(t, "aspnet-mvc", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// Bug 5: Laravel 10+ ships with vite.config.js; composer.json must take priority. +func TestDetectProject_LaravelBeatsViteConfig(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.js", "") + writeTestFile(t, dir, "composer.json", `{"name":"my/laravel-app","require":{"laravel/framework":"^10.0"}}`) + + got := DetectProject(dir) + assert.Equal(t, "laravel", got.Framework) + assert.Equal(t, "regular", got.Type) +} + +// -- DetectProject - app name detection --. + +func TestDetectProject_AppNameFromPackageJSON(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "vite.config.ts", "") + writeTestFile(t, dir, "package.json", `{"name":"my-awesome-app","dependencies":{"react":"^18"}}`) + + got := DetectProject(dir) + assert.Equal(t, "my-awesome-app", got.AppName) +} + +func TestDetectProject_AppNameFromGoMod(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go.mod", "module github.com/org/myapp\n\ngo 1.21\n") + + got := DetectProject(dir) + assert.Equal(t, "myapp", got.AppName) +} + +func TestDetectProject_AppNameFromPubspec(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pubspec.yaml", "name: flutter_app\nflutter:\n sdk: flutter\n") + + got := DetectProject(dir) + assert.Equal(t, "flutter_app", got.AppName) +} + +func TestDetectProject_AppNameFromComposer(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "composer.json", `{"name":"vendor/my-php-app","require":{"php":"^8"}}`) + + got := DetectProject(dir) + assert.Equal(t, "my-php-app", got.AppName) +} + +func TestDetectProject_AppNameFromPomArtifactID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", `com.examplemy-java-app`) + + got := DetectProject(dir) + assert.Equal(t, "my-java-app", got.AppName) +} + +// -- readExpoScheme --. + +func TestReadExpoScheme(t *testing.T) { + t.Run("reads scheme field", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"name":"my-app","slug":"my-app","scheme":"myapp"}}`) + assert.Equal(t, "myapp", readExpoScheme(dir)) + }) + + t.Run("no scheme field returns empty", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"name":"my-app","slug":"my-app"}}`) + assert.Empty(t, readExpoScheme(dir)) + }) + + t.Run("no app.json returns empty", func(t *testing.T) { + assert.Empty(t, readExpoScheme(t.TempDir())) + }) + + t.Run("invalid json returns empty", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `not valid json`) + assert.Empty(t, readExpoScheme(dir)) + }) + + t.Run("scheme starting with digit is rejected", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"1app"}}`) + assert.Empty(t, readExpoScheme(dir)) + }) + + t.Run("scheme with space is rejected", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"my app"}}`) + assert.Empty(t, readExpoScheme(dir)) + }) + + t.Run("scheme with underscore is rejected", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"my_app"}}`) + assert.Empty(t, readExpoScheme(dir)) + }) + + t.Run("valid scheme with plus dot dash is accepted", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"my+app-v1.0"}}`) + assert.Equal(t, "my+app-v1.0", readExpoScheme(dir)) + }) +} + +// -- detectFromCsproj --. + +func TestDetectFromCsproj(t *testing.T) { + tests := []struct { + name string + content string + wantFw string + wantType string + wantFound bool + }{ + { + name: "blazor", + content: ``, + wantFw: "aspnet-blazor", + wantType: "regular", + wantFound: true, + }, + { + name: "mvc", + content: ``, + wantFw: "aspnet-mvc", + wantType: "regular", + wantFound: true, + }, + { + name: "owin", + content: ``, + wantFw: "aspnet-owin", + wantType: "regular", + wantFound: true, + }, + { + name: "maui_sdk", + content: ``, + wantFw: "maui", + wantType: "native", + wantFound: true, + }, + { + // Mobile TFM without Microsoft.Maui reference → dotnet-mobile (Bug 4 fix). + // Uses regex net\d+\.\d+-(?:android|ios) to avoid false positives. + name: "dotnet_mobile_android_target", + content: `net8.0-android`, + wantFw: "dotnet-mobile", + wantType: "native", + wantFound: true, + }, + { + // Mobile TFM without Microsoft.Maui reference → dotnet-mobile (Bug 4 fix). + name: "dotnet_mobile_ios_target", + content: `net8.0-ios`, + wantFw: "dotnet-mobile", + wantType: "native", + wantFound: true, + }, + { + // Bare "-ios" in a package name must NOT trigger dotnet-mobile (false-positive guard). + name: "bare_ios_in_package_name_no_match", + content: ``, + wantFw: "", + wantType: "", + wantFound: false, + }, + { + // Bare "-android" in a non-TFM context must NOT trigger dotnet-mobile. + name: "bare_android_in_condition_no_match", + content: `'$(Platform)'=='-android'`, + wantFw: "", + wantType: "", + wantFound: false, + }, + { + // Mobile TFM WITH Microsoft.Maui reference → maui. + name: "maui_android_with_maui_ref", + content: `net8.0-android`, + wantFw: "maui", + wantType: "native", + wantFound: true, + }, + { + name: "wpf_winforms_windows_target", + content: `net8.0-windows`, + wantFw: "wpf-winforms", + wantType: "native", + wantFound: true, + }, + { + name: "blazor_takes_priority_over_mvc", + content: ``, + wantFw: "aspnet-blazor", + wantType: "regular", + wantFound: true, + }, + { + name: "unknown_csproj", + content: `Exe`, + wantFw: "", + wantType: "", + wantFound: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fw, qsType, found := detectFromCsproj(tc.content) + assert.Equal(t, tc.wantFw, fw) + assert.Equal(t, tc.wantType, qsType) + assert.Equal(t, tc.wantFound, found) + }) + } +} + +// -- detectJavaFramework --. + +func TestDetectJavaFramework(t *testing.T) { + tests := []struct { + name string + content string + wantFw string + wantPort int + }{ + { + name: "spring_boot", + content: `spring-boot-starter-parent`, + wantFw: "spring-boot", + wantPort: 8080, + }, + { + name: "javax_ee", + content: `javax.ee`, + wantFw: "java-ee", + wantPort: 0, + }, + { + name: "jakarta_ee", + content: `jakarta.ee`, + wantFw: "java-ee", + wantPort: 0, + }, + { + name: "javax_servlet", + content: `javax.servlet`, + wantFw: "java-ee", + wantPort: 0, + }, + { + name: "jakarta_servlet", + content: `jakarta.servlet`, + wantFw: "java-ee", + wantPort: 0, + }, + { + name: "vanilla_java_plain_pom", + content: `plain-java`, + wantFw: "vanilla-java", + wantPort: 0, + }, + { + name: "spring_boot_gradle_dependency", + content: `implementation("org.springframework.boot:spring-boot-starter-web")`, + wantFw: "spring-boot", + wantPort: 8080, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fw, port := detectJavaFramework(tc.content) + assert.Equal(t, tc.wantFw, fw) + assert.Equal(t, tc.wantPort, port) + }) + } +} + +// -- collectPackageJSONCandidates --. + +func TestCollectPackageJSONCandidates(t *testing.T) { + t.Run("react_native", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"react-native": true}) + require.Len(t, got, 1) + assert.Equal(t, "react-native", got[0].framework) + assert.Equal(t, "native", got[0].qsType) + }) + + t.Run("express", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"express": true}) + require.Len(t, got, 1) + assert.Equal(t, "express", got[0].framework) + assert.Equal(t, "regular", got[0].qsType) + }) + + t.Run("hono", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"hono": true}) + require.Len(t, got, 1) + assert.Equal(t, "hono", got[0].framework) + assert.Equal(t, "regular", got[0].qsType) + }) + + t.Run("fastify", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"fastify": true}) + require.Len(t, got, 1) + assert.Equal(t, "fastify", got[0].framework) + assert.Equal(t, "regular", got[0].qsType) + }) + + t.Run("empty_deps_returns_no_candidates", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{}) + assert.Empty(t, got) + }) + + t.Run("multiple_deps_returns_multiple_candidates", func(t *testing.T) { + deps := map[string]bool{"express": true, "hono": true, "fastify": true} + got := collectPackageJSONCandidates(deps) + assert.Len(t, got, 3) + }) + + t.Run("unrecognised_dep_returns_no_candidates", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"some-random-lib": true}) + assert.Empty(t, got) + }) +} + +// -- detectionFriendlyAppType --. + +func TestDetectionFriendlyAppType(t *testing.T) { + assert.Equal(t, "Single Page App", detectionFriendlyAppType("spa")) + assert.Equal(t, "Regular Web App", detectionFriendlyAppType("regular")) + assert.Equal(t, "Native / Mobile", detectionFriendlyAppType("native")) + assert.Equal(t, "Machine to Machine", detectionFriendlyAppType("m2m")) + assert.Equal(t, "unknown-type", detectionFriendlyAppType("unknown-type")) + assert.Equal(t, "", detectionFriendlyAppType("")) +} + +// -- readGoModuleName --. + +func TestReadGoModuleName(t *testing.T) { + t.Run("returns last path segment", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go.mod", "module github.com/org/my-service\n\ngo 1.21\n") + assert.Equal(t, "my-service", readGoModuleName(dir)) + }) + + t.Run("bare module name", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go.mod", "module myapp\n\ngo 1.21\n") + assert.Equal(t, "myapp", readGoModuleName(dir)) + }) + + t.Run("no go.mod returns empty", func(t *testing.T) { + assert.Empty(t, readGoModuleName(t.TempDir())) + }) +} + +// -- readPyprojectName --. + +func TestReadPyprojectName(t *testing.T) { + t.Run("reads project name", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pyproject.toml", "[project]\nname = \"my-python-app\"\nversion = \"0.1\"\n") + assert.Equal(t, "my-python-app", readPyprojectName(dir)) + }) + + t.Run("no pyproject.toml returns empty", func(t *testing.T) { + assert.Empty(t, readPyprojectName(t.TempDir())) + }) +} + +// -- readPubspecName --. + +func TestReadPubspecName(t *testing.T) { + t.Run("reads name field", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pubspec.yaml", "name: flutter_app\nversion: 1.0.0\n") + assert.Equal(t, "flutter_app", readPubspecName(dir)) + }) + + t.Run("no pubspec.yaml returns empty", func(t *testing.T) { + assert.Empty(t, readPubspecName(t.TempDir())) + }) +} + +// -- readComposerName --. + +func TestReadComposerName(t *testing.T) { + t.Run("returns part after slash", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "composer.json", `{"name":"vendor/my-php-app"}`) + assert.Equal(t, "my-php-app", readComposerName(dir)) + }) + + t.Run("name without slash", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "composer.json", `{"name":"myapp"}`) + assert.Equal(t, "myapp", readComposerName(dir)) + }) + + t.Run("no composer.json returns empty", func(t *testing.T) { + assert.Empty(t, readComposerName(t.TempDir())) + }) +} + +// -- readPomArtifactID --. + +func TestReadPomArtifactID(t *testing.T) { + t.Run("reads first artifactId", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", + `com.examplemy-java-app`) + assert.Equal(t, "my-java-app", readPomArtifactID(dir)) + }) + + t.Run("no pom.xml returns empty", func(t *testing.T) { + assert.Empty(t, readPomArtifactID(t.TempDir())) + }) + + t.Run("pom without artifactId returns empty", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pom.xml", `com.example`) + assert.Empty(t, readPomArtifactID(dir)) + }) +} + +// -- readPackageJSONName --. + +func TestReadPackageJSONName(t *testing.T) { + t.Run("reads name field", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"name":"my-js-app","version":"1.0.0"}`) + assert.Equal(t, "my-js-app", readPackageJSONName(dir)) + }) + + t.Run("no package.json returns empty", func(t *testing.T) { + assert.Empty(t, readPackageJSONName(t.TempDir())) + }) + + t.Run("invalid json returns empty", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `not valid json`) + assert.Empty(t, readPackageJSONName(dir)) + }) +} + +// -- readFileContent --. + +func TestReadFileContent_SizeLimit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + // Write a file slightly over maxDetectionFileSize (10 MB). + oversizedFile := filepath.Join(dir, "big.xml") + data := make([]byte, maxDetectionFileSize+1) + require.NoError(t, os.WriteFile(oversizedFile, data, 0600)) + + content, ok := readFileContent(dir, "big.xml") + assert.False(t, ok, "readFileContent should reject files over maxDetectionFileSize") + assert.Empty(t, content) +} + +// -- defaultPortForFramework --. + +func TestDefaultPortForFramework(t *testing.T) { + tests := []struct { + framework string + wantPort int + }{ + // SPA vite frameworks. + {"react", 5173}, + {"vue", 5173}, + {"svelte", 5173}, + {"vanilla-javascript", 5173}, + // SPA non-vite. + {"angular", 4200}, + // Regular - Python. + {"vanilla-python", 5000}, + {"flask", 5000}, + // Regular - PHP. + {"laravel", 8000}, + // Regular - Java. + {"spring-boot", 8080}, + {"java-ee", 8080}, + {"vanilla-java", 8080}, + // Regular - default 3000. + {"nextjs", 3000}, + {"nuxt", 3000}, + {"express", 3000}, + {"fastify", 3000}, + {"hono", 3000}, + {"sveltekit", 5173}, + {"rails", 3000}, + {"vanilla-go", 3000}, + {"django", 8000}, + // Native - default 3000. + {"flutter", 3000}, + {"react-native", 3000}, + {"expo", 3000}, + // Catch-all. + {"unknown-framework", 3000}, + } + + for _, tc := range tests { + t.Run(tc.framework, func(t *testing.T) { + assert.Equal(t, tc.wantPort, defaultPortForFramework(tc.framework)) + }) + } +} + +// -- frameworksForType --. + +func TestFrameworksForType(t *testing.T) { + t.Run("spa", func(t *testing.T) { + fws := frameworksForType("spa") + assert.Contains(t, fws, "react") + assert.Contains(t, fws, "angular") + assert.Contains(t, fws, "vue") + assert.Contains(t, fws, "svelte") + assert.Contains(t, fws, "vanilla-javascript") + assert.Contains(t, fws, "flutter-web") + // SPA frameworks must be sorted. + assert.Equal(t, sort.StringsAreSorted(fws), true) + }) + + t.Run("regular", func(t *testing.T) { + fws := frameworksForType("regular") + assert.Contains(t, fws, "nextjs") + assert.Contains(t, fws, "nuxt") + assert.Contains(t, fws, "fastify") + assert.Contains(t, fws, "sveltekit") + assert.Contains(t, fws, "express") + assert.Contains(t, fws, "hono") + assert.Contains(t, fws, "vanilla-python") + assert.Contains(t, fws, "django") + assert.Contains(t, fws, "vanilla-go") + assert.Contains(t, fws, "vanilla-java") + assert.Contains(t, fws, "java-ee") + assert.Contains(t, fws, "spring-boot") + assert.Contains(t, fws, "aspnet-mvc") + assert.Contains(t, fws, "aspnet-blazor") + assert.Contains(t, fws, "aspnet-owin") + assert.Contains(t, fws, "vanilla-php") + assert.Contains(t, fws, "laravel") + assert.Contains(t, fws, "rails") + }) + + t.Run("native", func(t *testing.T) { + fws := frameworksForType("native") + assert.Contains(t, fws, "flutter") + assert.Contains(t, fws, "react-native") + assert.Contains(t, fws, "expo") + assert.Contains(t, fws, "ionic-angular") + assert.Contains(t, fws, "ionic-react") + assert.Contains(t, fws, "ionic-vue") + assert.Contains(t, fws, "dotnet-mobile") + assert.Contains(t, fws, "maui") + assert.Contains(t, fws, "wpf-winforms") + }) + + t.Run("unknown type returns empty", func(t *testing.T) { + assert.Empty(t, frameworksForType("nonexistent")) + }) +} + +// GetQuickstartConfigKey +// +// Tests cover all framework/type/buildTool combinations from the requirements +// table. All inputs are fully populated to avoid interactive prompts. + +func TestGetQuickstartConfigKey(t *testing.T) { + tests := []struct { + name string + inputs SetupInputs + wantKey string + wantBuildTool string + wantAutoSelect bool + }{ + // SPA . + // Auth0 qs setup --app --type spa --framework react --build-tool vite. + { + name: "spa react vite", + inputs: SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "vite", Port: 5173}, + wantKey: "spa:react:vite", + wantBuildTool: "vite", + }, + { + name: "spa react build-tool none auto-selects vite", + inputs: SetupInputs{App: true, Type: "spa", Framework: "react", BuildTool: "none", Port: 5173}, + wantKey: "spa:react:vite", + wantBuildTool: "vite", + wantAutoSelect: true, + }, + // Auth0 qs setup --app --type spa --framework angular. + { + name: "spa angular none", + inputs: SetupInputs{App: true, Type: "spa", Framework: "angular", BuildTool: "none", Port: 4200}, + wantKey: "spa:angular:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type spa --framework vue --build-tool vite. + { + name: "spa vue vite", + inputs: SetupInputs{App: true, Type: "spa", Framework: "vue", BuildTool: "vite", Port: 5173}, + wantKey: "spa:vue:vite", + wantBuildTool: "vite", + }, + // Auth0 qs setup --app --type spa --framework svelte --build-tool vite. + { + name: "spa svelte vite", + inputs: SetupInputs{App: true, Type: "spa", Framework: "svelte", BuildTool: "vite", Port: 5173}, + wantKey: "spa:svelte:vite", + wantBuildTool: "vite", + }, + // Auth0 qs setup --app --type spa --framework vanilla-javascript --build-tool vite. + { + name: "spa vanilla-javascript vite", + inputs: SetupInputs{App: true, Type: "spa", Framework: "vanilla-javascript", BuildTool: "vite", Port: 5173}, + wantKey: "spa:vanilla-javascript:vite", + wantBuildTool: "vite", + }, + // Auth0 qs setup --app --type spa --framework flutter-web. + { + name: "spa flutter-web none", + inputs: SetupInputs{App: true, Type: "spa", Framework: "flutter-web", BuildTool: "none", Port: 3000}, + wantKey: "spa:flutter-web:none", + wantBuildTool: "none", + }, + + // Regular + // Auth0 qs setup --app --type regular --framework nextjs. + { + name: "regular nextjs none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "nextjs", BuildTool: "none", Port: 3000}, + wantKey: "regular:nextjs:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework nuxt. + { + name: "regular nuxt none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "nuxt", BuildTool: "none", Port: 3000}, + wantKey: "regular:nuxt:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework fastify. + { + name: "regular fastify none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "fastify", BuildTool: "none", Port: 3000}, + wantKey: "regular:fastify:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework sveltekit. + { + name: "regular sveltekit none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "sveltekit", BuildTool: "none", Port: 3000}, + wantKey: "regular:sveltekit:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --name express-app --api ... --app --type regular --framework express. + { + name: "regular express none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "express", BuildTool: "none", Port: 3000}, + wantKey: "regular:express:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework hono. + { + name: "regular hono none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "hono", BuildTool: "none", Port: 3000}, + wantKey: "regular:hono:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework vanilla-python. + { + name: "regular vanilla-python none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "vanilla-python", BuildTool: "none", Port: 5000}, + wantKey: "regular:vanilla-python:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework django. + { + name: "regular django none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "django", BuildTool: "none", Port: 3000}, + wantKey: "regular:django:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework vanilla-go. + { + name: "regular vanilla-go none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "vanilla-go", BuildTool: "none", Port: 3000}, + wantKey: "regular:vanilla-go:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework vanilla-java. + { + name: "regular vanilla-java maven", + inputs: SetupInputs{App: true, Type: "regular", Framework: "vanilla-java", BuildTool: "maven", Port: 8080}, + wantKey: "regular:vanilla-java:maven", + wantBuildTool: "maven", + }, + // Auth0 qs setup --app --type regular --framework java-ee. + { + name: "regular java-ee maven", + inputs: SetupInputs{App: true, Type: "regular", Framework: "java-ee", BuildTool: "maven", Port: 8080}, + wantKey: "regular:java-ee:maven", + wantBuildTool: "maven", + }, + // Auth0 qs setup --app --type regular --framework spring-boot. + { + name: "regular spring-boot maven", + inputs: SetupInputs{App: true, Type: "regular", Framework: "spring-boot", BuildTool: "maven", Port: 8080}, + wantKey: "regular:spring-boot:maven", + wantBuildTool: "maven", + }, + // Auth0 qs setup --app --type regular --framework aspnet-mvc. + { + name: "regular aspnet-mvc none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "aspnet-mvc", BuildTool: "none", Port: 3000}, + wantKey: "regular:aspnet-mvc:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework aspnet-blazor. + { + name: "regular aspnet-blazor none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "aspnet-blazor", BuildTool: "none", Port: 3000}, + wantKey: "regular:aspnet-blazor:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework aspnet-owin. + { + name: "regular aspnet-owin none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "aspnet-owin", BuildTool: "none", Port: 3000}, + wantKey: "regular:aspnet-owin:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type regular --framework vanilla-php. + { + name: "regular vanilla-php composer", + inputs: SetupInputs{App: true, Type: "regular", Framework: "vanilla-php", BuildTool: "composer", Port: 3000}, + wantKey: "regular:vanilla-php:composer", + wantBuildTool: "composer", + }, + // Auth0 qs setup --app --type regular --framework laravel. + { + name: "regular laravel composer", + inputs: SetupInputs{App: true, Type: "regular", Framework: "laravel", BuildTool: "composer", Port: 8000}, + wantKey: "regular:laravel:composer", + wantBuildTool: "composer", + }, + // Auth0 qs setup --app --type regular --framework rails. + { + name: "regular rails none", + inputs: SetupInputs{App: true, Type: "regular", Framework: "rails", BuildTool: "none", Port: 3000}, + wantKey: "regular:rails:none", + wantBuildTool: "none", + }, + + // Native + // Auth0 qs setup --app --type native --framework flutter. + { + name: "native flutter none", + inputs: SetupInputs{App: true, Type: "native", Framework: "flutter", BuildTool: "none", Port: 3000}, + wantKey: "native:flutter:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type native --framework react-native. + { + name: "native react-native none", + inputs: SetupInputs{App: true, Type: "native", Framework: "react-native", BuildTool: "none", Port: 3000}, + wantKey: "native:react-native:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type native --framework expo. + { + name: "native expo none", + inputs: SetupInputs{App: true, Type: "native", Framework: "expo", BuildTool: "none", Port: 3000}, + wantKey: "native:expo:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type native --framework ionic-angular. + { + name: "native ionic-angular none", + inputs: SetupInputs{App: true, Type: "native", Framework: "ionic-angular", BuildTool: "none", Port: 3000}, + wantKey: "native:ionic-angular:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type native --framework ionic-react --build-tool vite. + { + name: "native ionic-react vite", + inputs: SetupInputs{App: true, Type: "native", Framework: "ionic-react", BuildTool: "vite", Port: 3000}, + wantKey: "native:ionic-react:vite", + wantBuildTool: "vite", + }, + // Auth0 qs setup --app --type native --framework ionic-vue --build-tool vite. + { + name: "native ionic-vue vite", + inputs: SetupInputs{App: true, Type: "native", Framework: "ionic-vue", BuildTool: "vite", Port: 3000}, + wantKey: "native:ionic-vue:vite", + wantBuildTool: "vite", + }, + // Auth0 qs setup --app --type native --framework dotnet-mobile. + { + name: "native dotnet-mobile none", + inputs: SetupInputs{App: true, Type: "native", Framework: "dotnet-mobile", BuildTool: "none", Port: 3000}, + wantKey: "native:dotnet-mobile:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type native --framework maui. + { + name: "native maui none", + inputs: SetupInputs{App: true, Type: "native", Framework: "maui", BuildTool: "none", Port: 3000}, + wantKey: "native:maui:none", + wantBuildTool: "none", + }, + // Auth0 qs setup --app --type native --framework wpf-winforms. + { + name: "native wpf-winforms none", + inputs: SetupInputs{App: true, Type: "native", Framework: "wpf-winforms", BuildTool: "none", Port: 3000}, + wantKey: "native:wpf-winforms:none", + wantBuildTool: "none", + }, + + //API-only: no app . + { + name: "api-only returns empty key", + inputs: SetupInputs{App: false, API: true}, + wantKey: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + key, updated, wasAuto, err := getQuickstartConfigKey(&cobra.Command{}, tc.inputs) + require.NoError(t, err) + assert.Equal(t, tc.wantKey, key) + assert.Equal(t, tc.wantAutoSelect, wasAuto) + if tc.inputs.App { + assert.Equal(t, tc.wantBuildTool, updated.BuildTool) + } + }) + } +} + +func TestGetQuickstartConfigKey_EmptyBuildToolTreatedAsNone(t *testing.T) { + // BuildTool == "" should be normalised to "none" internally. + inputs := SetupInputs{App: true, Type: "regular", Framework: "nextjs", BuildTool: "", Port: 3000} + key, _, _, err := getQuickstartConfigKey(&cobra.Command{}, inputs) + require.NoError(t, err) + assert.Equal(t, "regular:nextjs:none", key) +} + +// -- resolveRequestParams --. + +func TestResolveRequestParams(t *testing.T) { + const sub = auth0.DetectionSub + + t.Run("DetectionSub replaced in callbacks", func(t *testing.T) { + req := auth0.RequestParams{ + AppType: "spa", + Callbacks: []string{sub}, + AllowedLogoutURLs: []string{sub}, + WebOrigins: []string{sub}, + Name: sub, + } + got := resolveRequestParams(req, "MyApp", 3000) + assert.Equal(t, []string{"http://localhost:3000/callback"}, got.Callbacks) + assert.Equal(t, []string{"http://localhost:3000"}, got.AllowedLogoutURLs) + assert.Equal(t, []string{"http://localhost:3000"}, got.WebOrigins) + assert.Equal(t, "MyApp", got.Name) + assert.Equal(t, "spa", got.AppType) + }) + + t.Run("port 0 defaults to 3000", func(t *testing.T) { + req := auth0.RequestParams{Callbacks: []string{sub}} + got := resolveRequestParams(req, "App", 0) + assert.Equal(t, []string{"http://localhost:3000/callback"}, got.Callbacks) + }) + + t.Run("custom port is used", func(t *testing.T) { + req := auth0.RequestParams{Callbacks: []string{sub}, AllowedLogoutURLs: []string{sub}} + got := resolveRequestParams(req, "App", 5173) + assert.Equal(t, []string{"http://localhost:5173/callback"}, got.Callbacks) + assert.Equal(t, []string{"http://localhost:5173"}, got.AllowedLogoutURLs) + }) + + t.Run("literal URLs are not replaced", func(t *testing.T) { + req := auth0.RequestParams{ + Callbacks: []string{"http://localhost:5173/callback"}, + AllowedLogoutURLs: []string{"http://localhost:5173"}, + } + got := resolveRequestParams(req, "App", 5173) + assert.Equal(t, []string{"http://localhost:5173/callback"}, got.Callbacks) + assert.Equal(t, []string{"http://localhost:5173"}, got.AllowedLogoutURLs) + }) + + t.Run("non-DetectionSub name is preserved", func(t *testing.T) { + req := auth0.RequestParams{Name: "literal-name"} + got := resolveRequestParams(req, "OtherName", 3000) + assert.Equal(t, "literal-name", got.Name) + }) +} + +// -- replaceDetectionSub --. + +func TestReplaceDetectionSub(t *testing.T) { + const sub = auth0.DetectionSub + const domain = "tenant.auth0.com" + + clientID := "test-client-id" + clientSecret := "test-client-secret" + client := &management.Client{ + ClientID: &clientID, + ClientSecret: &clientSecret, + } + + t.Run("domain keys", func(t *testing.T) { + domainKeys := []string{ + "VITE_AUTH0_DOMAIN", + "AUTH0_DOMAIN", + "NUXT_AUTH0_DOMAIN", + "EXPO_PUBLIC_AUTH0_DOMAIN", + "domain", + "auth0.domain", + "auth0/domain", + "Auth0:Domain", + "auth0:Domain", + "auth0_domain", + } + for _, key := range domainKeys { + t.Run(key, func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{key: sub}, domain, client, 3000) + require.NoError(t, err) + assert.Equal(t, domain, got[key]) + }) + } + }) + + t.Run("ISSUER_BASE_URL gets https prefix", func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{"ISSUER_BASE_URL": sub}, domain, client, 3000) + require.NoError(t, err) + assert.Equal(t, "https://"+domain, got["ISSUER_BASE_URL"]) + }) + + t.Run("okta issuer gets https prefix and trailing slash", func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{"okta.oauth2.issuer": sub}, domain, client, 3000) + require.NoError(t, err) + assert.Equal(t, "https://"+domain+"/", got["okta.oauth2.issuer"]) + }) + + t.Run("client ID keys", func(t *testing.T) { + clientIDKeys := []string{ + "VITE_AUTH0_CLIENT_ID", + "AUTH0_CLIENT_ID", + "CLIENT_ID", + "EXPO_PUBLIC_AUTH0_CLIENT_ID", + "NUXT_AUTH0_CLIENT_ID", + "clientId", + "auth0.clientId", + "auth0/clientId", + "okta.oauth2.client-id", + "Auth0:ClientId", + "auth0:ClientId", + "auth0_client_id", + } + for _, key := range clientIDKeys { + t.Run(key, func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{key: sub}, domain, client, 3000) + require.NoError(t, err) + assert.Equal(t, clientID, got[key]) + }) + } + }) + + t.Run("client secret keys", func(t *testing.T) { + secretKeys := []string{ + "AUTH0_CLIENT_SECRET", + "NUXT_AUTH0_CLIENT_SECRET", + "auth0.clientSecret", + "auth0/clientSecret", + "okta.oauth2.client-secret", + "Auth0:ClientSecret", + "auth0:ClientSecret", + "auth0_client_secret", + } + for _, key := range secretKeys { + t.Run(key, func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{key: sub}, domain, client, 3000) + require.NoError(t, err) + assert.Equal(t, clientSecret, got[key]) + }) + } + }) + + t.Run("secret generation keys produce non-empty random value", func(t *testing.T) { + secretGenKeys := []string{ + "AUTH0_SECRET", + "NUXT_AUTH0_SESSION_SECRET", + "SESSION_SECRET", + "SECRET", + "AUTH0_SESSION_ENCRYPTION_KEY", + "AUTH0_COOKIE_SECRET", + } + for _, key := range secretGenKeys { + t.Run(key, func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{key: sub}, domain, client, 3000) + require.NoError(t, err) + assert.NotEmpty(t, got[key]) + assert.NotEqual(t, sub, got[key]) + }) + } + }) + + t.Run("base URL keys", func(t *testing.T) { + for _, key := range []string{"APP_BASE_URL", "NUXT_AUTH0_APP_BASE_URL", "BASE_URL"} { + t.Run(key, func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{key: sub}, domain, client, 3000) + require.NoError(t, err) + assert.Equal(t, "http://localhost:3000", got[key]) + }) + } + }) + + t.Run("redirect and callback URL keys", func(t *testing.T) { + for _, key := range []string{"AUTH0_REDIRECT_URI", "AUTH0_CALLBACK_URL"} { + t.Run(key, func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{key: sub}, domain, client, 5000) + require.NoError(t, err) + assert.Equal(t, "http://localhost:5000/callback", got[key]) + }) + } + }) + + t.Run("literal values are preserved unchanged", func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{"SOME_KEY": "literal-value"}, domain, client, 3000) + require.NoError(t, err) + assert.Equal(t, "literal-value", got["SOME_KEY"]) + }) + + t.Run("port 0 defaults to 3000 for URL keys", func(t *testing.T) { + got, err := replaceDetectionSub(map[string]string{"BASE_URL": sub}, domain, client, 0) + require.NoError(t, err) + assert.Equal(t, "http://localhost:3000", got["BASE_URL"]) + }) +} + +// -- buildNestedMap --. + +func TestBuildNestedMap(t *testing.T) { + t.Run("dot-delimited keys produce nested structure", func(t *testing.T) { + flat := map[string]string{ + "okta.oauth2.issuer": "https://example.auth0.com/", + "okta.oauth2.client-id": "abc", + "okta.oauth2.client-secret": "secret", + } + got := buildNestedMap(flat) + + okta, ok := got["okta"].(map[string]interface{}) + require.True(t, ok, "expected 'okta' to be a map") + oauth2, ok := okta["oauth2"].(map[string]interface{}) + require.True(t, ok, "expected 'oauth2' to be a map") + assert.Equal(t, "https://example.auth0.com/", oauth2["issuer"]) + assert.Equal(t, "abc", oauth2["client-id"]) + assert.Equal(t, "secret", oauth2["client-secret"]) + }) + + t.Run("non-dot keys remain top-level", func(t *testing.T) { + flat := map[string]string{"Domain": "example.com", "ClientId": "abc"} + got := buildNestedMap(flat) + assert.Equal(t, "example.com", got["Domain"]) + assert.Equal(t, "abc", got["ClientId"]) + }) + + t.Run("empty map returns empty result", func(t *testing.T) { + got := buildNestedMap(map[string]string{}) + assert.Empty(t, got) + }) + + t.Run("leaf key and nested key under same prefix do not panic", func(t *testing.T) { + // "a" is a leaf (string) but "a.b" tries to descend into "a". The guarded + // type assertion must recover and create a new nested map rather than panic. + flat := map[string]string{ + "a": "leaf-value", + "a.b": "nested-value", + } + // Must not panic; result should at minimum contain the nested key. + got := buildNestedMap(flat) + require.NotNil(t, got) + aVal, ok := got["a"].(map[string]interface{}) + require.True(t, ok, "expected 'a' to be promoted to a nested map") + assert.Equal(t, "nested-value", aVal["b"]) + }) +} + +// -- sortedKeys --. + +func TestSortedKeys(t *testing.T) { + m := map[string]string{"beta": "b", "alpha": "a", "gamma": "g", "delta": "d"} + got := sortedKeys(m) + assert.Equal(t, []string{"alpha", "beta", "delta", "gamma"}, got) +} + +func TestSortedKeys_EmptyMap(t *testing.T) { + assert.Empty(t, sortedKeys(map[string]string{})) +} + +// -- GenerateAndWriteQuickstartConfig --. + +func TestGenerateAndWriteQuickstartConfig(t *testing.T) { + clientID := "cid-123" + clientSecret := "csecret-456" + client := &management.Client{ + ClientID: &clientID, + ClientSecret: &clientSecret, + } + const domain = "tenant.auth0.com" + + tests := []struct { + name string + strategy auth0.FileOutputStrategy + envValues map[string]string + port int + checkContent func(t *testing.T, content string) + }{ + // Dotenv - covers React, Vue, Svelte, Vanilla JS, Next.js, Nuxt, etc. + { + name: "dotenv format", + strategy: auth0.FileOutputStrategy{Format: "dotenv"}, + envValues: map[string]string{ + "AUTH0_DOMAIN": auth0.DetectionSub, + "AUTH0_CLIENT_ID": auth0.DetectionSub, + }, + port: 3000, + checkContent: func(t *testing.T, content string) { + assert.Contains(t, content, `AUTH0_DOMAIN="tenant.auth0.com"`) + assert.Contains(t, content, `AUTH0_CLIENT_ID="cid-123"`) + }, + }, + // TypeScript environment file - covers Angular, Ionic Angular. + { + name: "ts format", + strategy: auth0.FileOutputStrategy{Format: "ts"}, + envValues: map[string]string{ + "domain": auth0.DetectionSub, + "clientId": auth0.DetectionSub, + }, + port: 4200, + checkContent: func(t *testing.T, content string) { + assert.Contains(t, content, "export const environment") + assert.Contains(t, content, "domain: 'tenant.auth0.com'") + assert.Contains(t, content, "clientId: 'cid-123'") + }, + }, + // Dart - covers Flutter and Flutter Web. + { + name: "dart format", + strategy: auth0.FileOutputStrategy{Format: "dart"}, + envValues: map[string]string{ + "domain": auth0.DetectionSub, + "clientId": auth0.DetectionSub, + }, + port: 3000, + checkContent: func(t *testing.T, content string) { + assert.Contains(t, content, "const Map authConfig") + assert.Contains(t, content, "'domain': 'tenant.auth0.com'") + assert.Contains(t, content, "'clientId': 'cid-123'") + }, + }, + // YAML - covers Spring Boot (application.yml). + { + name: "yaml format", + strategy: auth0.FileOutputStrategy{Format: "yaml"}, + envValues: map[string]string{ + "okta.oauth2.issuer": auth0.DetectionSub, + "okta.oauth2.client-id": auth0.DetectionSub, + "okta.oauth2.client-secret": auth0.DetectionSub, + }, + port: 8080, + checkContent: func(t *testing.T, content string) { + assert.Contains(t, content, "okta:") + assert.Contains(t, content, "oauth2:") + assert.Contains(t, content, "https://tenant.auth0.com/") + assert.Contains(t, content, "cid-123") + }, + }, + // JSON - covers ASP.NET Core MVC, Blazor, dotnet-mobile, MAUI, WPF. + { + name: "json format", + strategy: auth0.FileOutputStrategy{Format: "json"}, + envValues: map[string]string{ + "Auth0:Domain": auth0.DetectionSub, + "Auth0:ClientId": auth0.DetectionSub, + }, + port: 3000, + checkContent: func(t *testing.T, content string) { + assert.Contains(t, content, `"Auth0"`) + assert.Contains(t, content, `"Domain"`) + assert.Contains(t, content, `"tenant.auth0.com"`) + assert.Contains(t, content, `"ClientId"`) + assert.Contains(t, content, `"cid-123"`) + }, + }, + // XML - covers ASP.NET OWIN (Web.config). + { + name: "xml format", + strategy: auth0.FileOutputStrategy{Format: "xml"}, + envValues: map[string]string{ + "auth0:Domain": auth0.DetectionSub, + "auth0:ClientId": auth0.DetectionSub, + "auth0:ClientSecret": auth0.DetectionSub, + }, + port: 3000, + checkContent: func(t *testing.T, content string) { + assert.Contains(t, content, `\"'" + clientSecret := "secret&<>\"'" + client := &management.Client{ + ClientID: &clientID, + ClientSecret: &clientSecret, + } + const domain = "tenant.auth0.com" + + t.Run("xml format escapes special characters", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + strategy := auth0.FileOutputStrategy{ + Path: filepath.Join(dir, "Web.config"), + Format: "xml", + } + envValues := map[string]string{ + "auth0:Domain": auth0.DetectionSub, + "auth0:ClientId": auth0.DetectionSub, + "auth0:ClientSecret": auth0.DetectionSub, + } + _, err := GenerateAndWriteQuickstartConfig(&strategy, envValues, domain, client, 3000) + require.NoError(t, err) + + data, err := os.ReadFile(strategy.Path) + require.NoError(t, err) + content := string(data) + // The raw special characters must not appear unescaped in the XML value. + assert.NotContains(t, content, `value="cid&<>"`) + // Proper XML entities should be present. + assert.Contains(t, content, "cid&<>") + }) + + t.Run("ts format escapes single quotes in values", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + strategy := auth0.FileOutputStrategy{ + Path: filepath.Join(dir, "environment.ts"), + Format: "ts", + } + // Inject a value that contains a single quote. + clientIDWithQuote := "cid-with'-quote" + clientWithQuote := &management.Client{ClientID: &clientIDWithQuote} + envValues := map[string]string{ + "domain": auth0.DetectionSub, + "clientId": auth0.DetectionSub, + } + _, err := GenerateAndWriteQuickstartConfig(&strategy, envValues, domain, clientWithQuote, 4200) + require.NoError(t, err) + + data, err := os.ReadFile(strategy.Path) + require.NoError(t, err) + content := string(data) + // Single quote in the value must be escaped so the TS file remains valid. + assert.Contains(t, content, `cid-with\'-quote`) + }) + + t.Run("dart format escapes single quotes in values", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + strategy := auth0.FileOutputStrategy{ + Path: filepath.Join(dir, "auth_config.dart"), + Format: "dart", + } + clientIDWithQuote := "cid-with'-quote" + clientWithQuote := &management.Client{ClientID: &clientIDWithQuote} + envValues := map[string]string{ + "domain": auth0.DetectionSub, + "clientId": auth0.DetectionSub, + } + _, err := GenerateAndWriteQuickstartConfig(&strategy, envValues, domain, clientWithQuote, 3000) + require.NoError(t, err) + + data, err := os.ReadFile(strategy.Path) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, `cid-with\'-quote`) + }) +} + +// -- generateClient --. + +func TestGenerateClient(t *testing.T) { + const sub = auth0.DetectionSub + + tests := []struct { + name string + input SetupInputs + reqParams auth0.RequestParams + wantName string + wantAppType string + wantCallbacks []string + wantLogouts []string + wantWebOrigins *[]string // Nil means no WebOrigins field set. + wantOIDC bool + wantAlgorithm string + wantMetadataKey string + }{ + // Auth0 qs setup --app --type spa --framework react --build-tool vite. + { + name: "spa react vite", + input: SetupInputs{Name: "React App", Port: 5173}, + reqParams: auth0.RequestParams{ + AppType: "spa", + Callbacks: []string{sub}, + AllowedLogoutURLs: []string{sub}, + WebOrigins: []string{sub}, + Name: sub, + }, + wantName: "React App", + wantAppType: "spa", + wantCallbacks: []string{"http://localhost:5173/callback"}, + wantLogouts: []string{"http://localhost:5173"}, + wantWebOrigins: &[]string{"http://localhost:5173"}, + wantOIDC: true, + wantAlgorithm: "RS256", + wantMetadataKey: "created_by", + }, + // Auth0 qs setup --app --type spa --framework angular. + { + name: "spa angular no web-origins", + input: SetupInputs{Name: "Angular App", Port: 4200}, + reqParams: auth0.RequestParams{ + AppType: "spa", + Callbacks: []string{sub}, + AllowedLogoutURLs: []string{sub}, + Name: sub, + // No WebOrigins - angular doesn't need them. + }, + wantName: "Angular App", + wantAppType: "spa", + wantCallbacks: []string{"http://localhost:4200/callback"}, + wantLogouts: []string{"http://localhost:4200"}, + wantWebOrigins: nil, + wantOIDC: true, + wantAlgorithm: "RS256", + }, + // Auth0 qs setup --app --type regular --framework nextjs. + { + name: "regular nextjs", + input: SetupInputs{Name: "Next App", Port: 3000}, + reqParams: auth0.RequestParams{ + AppType: "regular_web", + Callbacks: []string{sub}, + AllowedLogoutURLs: []string{sub}, + Name: sub, + }, + wantName: "Next App", + wantAppType: "regular_web", + wantCallbacks: []string{"http://localhost:3000/callback"}, + wantLogouts: []string{"http://localhost:3000"}, + wantWebOrigins: nil, + wantOIDC: true, + wantAlgorithm: "RS256", + }, + // Auth0 qs setup --app --type native --framework flutter. + { + name: "native flutter", + input: SetupInputs{Name: "Flutter App", Port: 3000}, + reqParams: auth0.RequestParams{ + AppType: "native", + Callbacks: []string{sub}, + AllowedLogoutURLs: []string{sub}, + Name: sub, + }, + wantName: "Flutter App", + wantAppType: "native", + wantCallbacks: []string{"http://localhost:3000/callback"}, + wantLogouts: []string{"http://localhost:3000"}, + wantWebOrigins: nil, + wantOIDC: true, + wantAlgorithm: "RS256", + }, + // Auth0 qs setup --app --type regular --framework spring-boot (port 8080). + { + name: "regular spring-boot port 8080", + input: SetupInputs{Name: "Spring App", Port: 8080}, + reqParams: auth0.RequestParams{ + AppType: "regular_web", + Callbacks: []string{sub}, + AllowedLogoutURLs: []string{sub}, + Name: sub, + }, + wantName: "Spring App", + wantCallbacks: []string{"http://localhost:8080/callback"}, + wantLogouts: []string{"http://localhost:8080"}, + wantOIDC: true, + wantAlgorithm: "RS256", + }, + // Name defaults to "My App" when empty. + { + name: "empty name defaults to My App", + input: SetupInputs{Port: 3000}, + reqParams: auth0.RequestParams{ + AppType: "regular_web", + Name: sub, + }, + wantName: "My App", + wantOIDC: true, + wantAlgorithm: "RS256", + }, + // Port 0 defaults to 3000. + { + name: "port 0 defaults to 3000", + input: SetupInputs{Name: "App", Port: 0}, + reqParams: auth0.RequestParams{ + AppType: "regular_web", + Callbacks: []string{sub}, + Name: sub, + }, + wantName: "App", + wantCallbacks: []string{"http://localhost:3000/callback"}, + wantOIDC: true, + wantAlgorithm: "RS256", + }, + // Custom metadata is preserved (not overwritten by default). + { + name: "custom metadata preserved", + input: SetupInputs{ + Name: "App", + Port: 3000, + MetaData: map[string]interface{}{"env": "staging"}, + }, + reqParams: auth0.RequestParams{AppType: "spa"}, + wantName: "App", + wantOIDC: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, err := generateClient(tc.input, tc.reqParams) + require.NoError(t, err) + require.NotNil(t, client) + + assert.Equal(t, tc.wantName, client.GetName()) + + if tc.wantAppType != "" { + assert.Equal(t, tc.wantAppType, client.GetAppType()) + } + if tc.wantCallbacks != nil { + assert.Equal(t, tc.wantCallbacks, client.GetCallbacks()) + } + if tc.wantLogouts != nil { + assert.Equal(t, tc.wantLogouts, client.GetAllowedLogoutURLs()) + } + if tc.wantWebOrigins != nil { + require.NotNil(t, client.WebOrigins) + assert.Equal(t, *tc.wantWebOrigins, client.GetWebOrigins()) + } else { + assert.Nil(t, client.WebOrigins) + } + if tc.wantOIDC { + assert.True(t, client.GetOIDCConformant()) + } + if tc.wantAlgorithm != "" { + assert.Equal(t, tc.wantAlgorithm, client.GetJWTConfiguration().GetAlgorithm()) + } + if tc.wantMetadataKey != "" { + require.NotNil(t, client.ClientMetadata) + assert.Contains(t, *client.ClientMetadata, tc.wantMetadataKey) + } + }) + } +} + +func TestGenerateClient_DefaultMetadata(t *testing.T) { + // When MetaData is nil in SetupInputs, generateClient must inject the default metadata. + client, err := generateClient( + SetupInputs{Name: "App", Port: 3000}, + auth0.RequestParams{AppType: "spa"}, + ) + require.NoError(t, err) + require.NotNil(t, client.ClientMetadata) + assert.Equal(t, "quickstart-docs-manual-cli", (*client.ClientMetadata)["created_by"]) +} + +func TestGenerateClient_CustomMetadataNotOverwritten(t *testing.T) { + // When MetaData is provided in SetupInputs, it must NOT be replaced with the default. + custom := map[string]interface{}{"source": "ci-pipeline"} + client, err := generateClient( + SetupInputs{Name: "App", Port: 3000, MetaData: custom}, + auth0.RequestParams{AppType: "regular_web"}, + ) + require.NoError(t, err) + require.NotNil(t, client.ClientMetadata) + assert.Equal(t, "ci-pipeline", (*client.ClientMetadata)["source"]) + assert.NotContains(t, *client.ClientMetadata, "created_by") +} + +// -- getSupportedQuickstartTypes --. + +func TestGetSupportedQuickstartTypes(t *testing.T) { + types := getSupportedQuickstartTypes() + + assert.NotEmpty(t, types) + assert.True(t, sort.StringsAreSorted(types), "types should be sorted") + + // Spot-check representative keys from each app-type bucket. + requiredKeys := []string{ + // SPA. + "spa:react:vite", + "spa:angular:none", + "spa:vue:vite", + "spa:svelte:vite", + "spa:vanilla-javascript:vite", + "spa:flutter-web:none", + // Regular. + "regular:nextjs:none", + "regular:nuxt:none", + "regular:fastify:none", + "regular:sveltekit:none", + "regular:sveltekit:vite", + "regular:express:none", + "regular:hono:none", + "regular:vanilla-python:none", + "regular:django:none", + "regular:vanilla-go:none", + "regular:vanilla-java:maven", + "regular:java-ee:maven", + "regular:spring-boot:maven", + "regular:spring-boot:gradle", + "regular:aspnet-mvc:none", + "regular:aspnet-blazor:none", + "regular:aspnet-owin:none", + "regular:vanilla-php:composer", + "regular:laravel:composer", + "regular:rails:none", + // Native. + "native:flutter:none", + "native:react-native:none", + "native:expo:none", + "native:ionic-angular:none", + "native:ionic-react:vite", + "native:ionic-vue:vite", + "native:dotnet-mobile:none", + "native:maui:none", + "native:wpf-winforms:none", + } + + for _, key := range requiredKeys { + assert.Contains(t, types, key, "missing required key: %s", key) + } +} + +// SetupQuickstartCmdExperimental - command-level interaction flows +// +// These tests exercise the RunE handler to verify the 7 top-level interaction +// flow paths. Because setupWithAuthentication reads ~/ config, we redirect +// HOME to a fresh temp dir so every test starts from a clean, unauthenticated +// state and gets a deterministic "config.json file is missing" error. + +// flow 1: --app with all flags set (no interactive prompts needed). +func TestSetupQuickstartCmdExperimental_AppAllFlagsAuthRequired(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + cliObj := &cli{} + cmd := setupQuickstartCmdExperimental(cliObj) + cmd.SetArgs([]string{ + "--app", + "--name", "My App", + "--type", "spa", + "--framework", "react", + "--build-tool", "vite", + "--port", "5173", + }) + err := cmd.Execute() + assert.EqualError(t, err, "authentication required: config.json file is missing") +} + +// flow 2: --api only (no --app). +func TestSetupQuickstartCmdExperimental_APIOnlyAuthRequired(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + cliObj := &cli{} + cmd := setupQuickstartCmdExperimental(cliObj) + cmd.SetArgs([]string{ + "--api", + "--identifier", "https://my-api.example.com", + "--signing-alg", "RS256", + }) + err := cmd.Execute() + assert.EqualError(t, err, "authentication required: config.json file is missing") +} + +// flow 3: --app and --api together (creates both resources). +func TestSetupQuickstartCmdExperimental_AppAndAPIAuthRequired(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + cliObj := &cli{} + cmd := setupQuickstartCmdExperimental(cliObj) + cmd.SetArgs([]string{ + "--app", + "--name", "Express App", + "--type", "regular", + "--framework", "express", + "--port", "3000", + "--api", + "--identifier", "https://example", + "--signing-alg", "RS256", + }) + err := cmd.Execute() + assert.EqualError(t, err, "authentication required: config.json file is missing") +} + +// flow 4: SPA frameworks - each framework/build-tool combo requires auth. +func TestSetupQuickstartCmdExperimental_SPAFrameworks(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + spaTests := []struct { + framework string + buildTool string + port string + }{ + {"react", "vite", "5173"}, + {"angular", "none", "4200"}, + {"vue", "vite", "5173"}, + {"svelte", "vite", "5173"}, + {"vanilla-javascript", "vite", "5173"}, + {"flutter-web", "none", "3000"}, + } + + for _, tc := range spaTests { + t.Run(tc.framework, func(t *testing.T) { + cliObj := &cli{} + cmd := setupQuickstartCmdExperimental(cliObj) + cmd.SetArgs([]string{ + "--app", + "--name", tc.framework + "-app", + "--type", "spa", + "--framework", tc.framework, + "--build-tool", tc.buildTool, + "--port", tc.port, + }) + err := cmd.Execute() + assert.EqualError(t, err, "authentication required: config.json file is missing", + "framework %s", tc.framework) + }) + } +} + +// flow 5: Regular web frameworks. +func TestSetupQuickstartCmdExperimental_RegularFrameworks(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + regularTests := []struct { + framework string + buildTool string + port string + }{ + {"nextjs", "none", "3000"}, + {"nuxt", "none", "3000"}, + {"fastify", "none", "3000"}, + {"sveltekit", "none", "3000"}, + {"express", "none", "3000"}, + {"hono", "none", "3000"}, + {"vanilla-python", "none", "5000"}, + {"django", "none", "3000"}, + {"vanilla-go", "none", "3000"}, + {"vanilla-java", "maven", "8080"}, + {"java-ee", "maven", "8080"}, + {"spring-boot", "maven", "8080"}, + {"aspnet-mvc", "none", "3000"}, + {"aspnet-blazor", "none", "3000"}, + {"aspnet-owin", "none", "3000"}, + {"vanilla-php", "composer", "3000"}, + {"laravel", "composer", "8000"}, + {"rails", "none", "3000"}, + } + + for _, tc := range regularTests { + t.Run(tc.framework, func(t *testing.T) { + cliObj := &cli{} + cmd := setupQuickstartCmdExperimental(cliObj) + cmd.SetArgs([]string{ + "--app", + "--name", tc.framework + "-app", + "--type", "regular", + "--framework", tc.framework, + "--build-tool", tc.buildTool, + "--port", tc.port, + }) + err := cmd.Execute() + assert.EqualError(t, err, "authentication required: config.json file is missing", + "framework %s", tc.framework) + }) + } +} + +// flow 6: Native / Mobile frameworks. +func TestSetupQuickstartCmdExperimental_NativeFrameworks(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + nativeTests := []struct { + framework string + buildTool string + }{ + {"flutter", "none"}, + {"react-native", "none"}, + {"expo", "none"}, + {"ionic-angular", "none"}, + {"ionic-react", "vite"}, + {"ionic-vue", "vite"}, + {"dotnet-mobile", "none"}, + {"maui", "none"}, + {"wpf-winforms", "none"}, + } + + for _, tc := range nativeTests { + t.Run(tc.framework, func(t *testing.T) { + cliObj := &cli{} + cmd := setupQuickstartCmdExperimental(cliObj) + cmd.SetArgs([]string{ + "--app", + "--name", tc.framework + "-app", + "--type", "native", + "--framework", tc.framework, + "--build-tool", tc.buildTool, + "--port", "3000", + }) + err := cmd.Execute() + assert.EqualError(t, err, "authentication required: config.json file is missing", + "framework %s", tc.framework) + }) + } +} + +// flow 7: auto-detection path - the command reads from CWD, which is controlled +// by the caller; with no auth config the command still fails at auth before +// attempting detection. +func TestSetupQuickstartCmdExperimental_DetectionPathAuthRequired(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Create a React project in a temp dir so detection would fire if auth passed. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "vite.config.ts"), nil, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), + []byte(`{"name":"my-react-app","dependencies":{"react":"^18"}}`), 0600)) + + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + cliObj := &cli{} + cmd := setupQuickstartCmdExperimental(cliObj) + cmd.SetArgs([]string{"--app"}) + err = cmd.Execute() + assert.EqualError(t, err, "authentication required: config.json file is missing") +} + +func TestGenerateAndWriteQuickstartConfig_NilStrategyDefaultsToDotenv(t *testing.T) { + origDir, err := os.Getwd() + require.NoError(t, err) + dir := t.TempDir() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + clientID := "cid" + client := &management.Client{ClientID: &clientID} + + filePath, err := GenerateAndWriteQuickstartConfig(nil, map[string]string{"AUTH0_DOMAIN": "example.com"}, "tenant.auth0.com", client, 3000) + require.NoError(t, err) + assert.Equal(t, ".env", filepath.Base(filePath)) +} + +// -- readMobileBundleID --. + +func TestReadMobileBundleID(t *testing.T) { + t.Run("reads applicationId from android/app/build.gradle (double quotes)", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "android", "app"), 0755)) + writeTestFile(t, dir, filepath.Join("android", "app", "build.gradle"), + `android { + defaultConfig { + applicationId "com.example.myapp" + minSdkVersion 21 + } +}`) + assert.Equal(t, "com.example.myapp", readMobileBundleID(dir)) + }) + + t.Run("reads applicationId with single quotes (Groovy DSL style)", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "android", "app"), 0755)) + writeTestFile(t, dir, filepath.Join("android", "app", "build.gradle"), + `android { + defaultConfig { + applicationId 'com.example.singlequote' + minSdkVersion 21 + } +}`) + assert.Equal(t, "com.example.singlequote", readMobileBundleID(dir)) + }) + + t.Run("falls back to project.pbxproj for iOS-only project", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ios", "Runner.xcodeproj"), 0755)) + writeTestFile(t, dir, filepath.Join("ios", "Runner.xcodeproj", "project.pbxproj"), + `PRODUCT_BUNDLE_IDENTIFIER = com.example.iosonly;`) + assert.Equal(t, "com.example.iosonly", readMobileBundleID(dir)) + }) + + t.Run("falls back to Info.plist when project.pbxproj absent", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ios", "Runner"), 0755)) + writeTestFile(t, dir, filepath.Join("ios", "Runner", "Info.plist"), + `CFBundleIdentifiercom.example.infoplist`) + assert.Equal(t, "com.example.infoplist", readMobileBundleID(dir)) + }) + + t.Run("Info.plist with Xcode variable reference returns empty (falls through)", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ios", "Runner"), 0755)) + writeTestFile(t, dir, filepath.Join("ios", "Runner", "Info.plist"), + `CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)`) + assert.Empty(t, readMobileBundleID(dir)) + }) + + t.Run("no build.gradle and no iOS files returns empty", func(t *testing.T) { + assert.Empty(t, readMobileBundleID(t.TempDir())) + }) + + t.Run("build.gradle without applicationId falls back to iOS", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "android", "app"), 0755)) + writeTestFile(t, dir, filepath.Join("android", "app", "build.gradle"), + `android { defaultConfig { minSdkVersion 21 } }`) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ios", "Runner.xcodeproj"), 0755)) + writeTestFile(t, dir, filepath.Join("ios", "Runner.xcodeproj", "project.pbxproj"), + `PRODUCT_BUNDLE_IDENTIFIER = com.example.iosfallback;`) + assert.Equal(t, "com.example.iosfallback", readMobileBundleID(dir)) + }) +} + +// -- extractGradleApplicationID single-quote support --. + +func TestExtractGradleApplicationID_SingleQuotes(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {"double quotes", `applicationId "com.example.double"`, "com.example.double"}, + {"single quotes", `applicationId 'com.example.single'`, "com.example.single"}, + {"with whitespace", `applicationId "com.example.space"`, "com.example.space"}, + {"not present", `minSdkVersion 21`, ""}, + {"mixed - double wins (first match)", `applicationId "com.example.first" +applicationId 'com.example.second'`, "com.example.first"}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, extractGradleApplicationID(tc.content)) + }) + } +} + +// -- readIOSBundleID --. + +func TestReadIOSBundleID(t *testing.T) { + t.Run("reads PRODUCT_BUNDLE_IDENTIFIER from project.pbxproj", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ios", "Runner.xcodeproj"), 0755)) + writeTestFile(t, dir, filepath.Join("ios", "Runner.xcodeproj", "project.pbxproj"), + `PRODUCT_BUNDLE_IDENTIFIER = com.example.pbxproj;`) + assert.Equal(t, "com.example.pbxproj", readIOSBundleID(dir)) + }) + + t.Run("falls back to Info.plist when project.pbxproj missing", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ios", "Runner"), 0755)) + writeTestFile(t, dir, filepath.Join("ios", "Runner", "Info.plist"), + `CFBundleIdentifier +com.example.plist`) + assert.Equal(t, "com.example.plist", readIOSBundleID(dir)) + }) + + t.Run("Info.plist variable reference returns empty", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ios", "Runner"), 0755)) + writeTestFile(t, dir, filepath.Join("ios", "Runner", "Info.plist"), + `CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)`) + assert.Empty(t, readIOSBundleID(dir)) + }) + + t.Run("no iOS files returns empty", func(t *testing.T) { + assert.Empty(t, readIOSBundleID(t.TempDir())) + }) +} + +// -- extractPbxprojBundleID --. + +func TestExtractPbxprojBundleID(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "single app target returns it", + content: `PRODUCT_BUNDLE_IDENTIFIER = com.example.app;`, + want: "com.example.app", + }, + { + name: "test target appears first - skipped, app target returned", + content: `PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; +PRODUCT_BUNDLE_IDENTIFIER = com.example.app;`, + want: "com.example.app", + }, + { + name: ".Tests suffix skipped", + content: `PRODUCT_BUNDLE_IDENTIFIER = com.example.app.Tests; +PRODUCT_BUNDLE_IDENTIFIER = com.example.app;`, + want: "com.example.app", + }, + { + name: ".UITests suffix skipped", + content: `PRODUCT_BUNDLE_IDENTIFIER = com.example.app.UITests; +PRODUCT_BUNDLE_IDENTIFIER = com.example.app;`, + want: "com.example.app", + }, + { + name: "no match returns empty", + content: `OTHER_KEY = some.value;`, + want: "", + }, + { + name: "all test targets - returns empty", + content: `PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; +PRODUCT_BUNDLE_IDENTIFIER = com.example.app.UITests;`, + want: "", + }, + { + // Com.example.appTests (no dot) previously passed the filter. + name: "no-dot Tests suffix skipped", + content: `PRODUCT_BUNDLE_IDENTIFIER = com.example.appTests; +PRODUCT_BUNDLE_IDENTIFIER = com.example.app;`, + want: "com.example.app", + }, + { + name: "no-dot UITests suffix skipped", + content: `PRODUCT_BUNDLE_IDENTIFIER = com.example.appUITests; +PRODUCT_BUNDLE_IDENTIFIER = com.example.app;`, + want: "com.example.app", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, extractPbxprojBundleID(tc.content)) + }) + } +} + +// -- readCapacitorAppID --. + +func TestReadCapacitorAppID(t *testing.T) { + t.Run("reads appId from capacitor.config.json", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.json", `{"appId":"com.example.ionic","appName":"MyApp"}`) + assert.Equal(t, "com.example.ionic", readCapacitorAppID(dir)) + }) + + t.Run("missing file returns empty", func(t *testing.T) { + assert.Empty(t, readCapacitorAppID(t.TempDir())) + }) + + t.Run("malformed JSON returns empty", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.json", `not valid json`) + assert.Empty(t, readCapacitorAppID(dir)) + }) + + t.Run("missing appId field returns empty", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.json", `{"appName":"MyApp"}`) + assert.Empty(t, readCapacitorAppID(dir)) + }) + + t.Run("reads appId from capacitor.config.ts (Capacitor v3+ default)", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.ts", `import { CapacitorConfig } from '@capacitor/cli'; +const config: CapacitorConfig = { + appId: 'com.example.tsapp', + appName: 'MyApp', + webDir: 'www', +}; +export default config;`) + assert.Equal(t, "com.example.tsapp", readCapacitorAppID(dir)) + }) + + t.Run("reads double-quoted appId from capacitor.config.ts", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.ts", `import { CapacitorConfig } from '@capacitor/cli'; +const config: CapacitorConfig = { + appId: "com.example.dqapp", + appName: 'MyApp', +}; +export default config;`) + assert.Equal(t, "com.example.dqapp", readCapacitorAppID(dir)) + }) + + t.Run("skips commented appId line in capacitor.config.ts", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.ts", `import { CapacitorConfig } from '@capacitor/cli'; +const config: CapacitorConfig = { + // appId: 'com.example.old', + appId: 'com.example.actual', +}; +export default config;`) + assert.Equal(t, "com.example.actual", readCapacitorAppID(dir)) + }) + + t.Run("json config takes priority over ts config", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.json", `{"appId":"com.example.json"}`) + writeTestFile(t, dir, "capacitor.config.ts", `const config = { appId: 'com.example.ts' };`) + assert.Equal(t, "com.example.json", readCapacitorAppID(dir)) + }) + + t.Run("ts config fallback when json has no appId", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "capacitor.config.json", `{"appName":"NoID"}`) + writeTestFile(t, dir, "capacitor.config.ts", `const config = { appId: 'com.example.tsfallback' };`) + assert.Equal(t, "com.example.tsfallback", readCapacitorAppID(dir)) + }) +} + +// -- readDotnetMobileBundleID --. + +func TestReadDotnetMobileBundleID(t *testing.T) { + t.Run("reads ApplicationId from csproj content", func(t *testing.T) { + content := ` + + com.example.myapp + net8.0-android;net8.0-ios + +` + assert.Equal(t, "com.example.myapp", readDotnetMobileBundleID(content)) + }) + + t.Run("no ApplicationId returns empty", func(t *testing.T) { + content := `` + assert.Empty(t, readDotnetMobileBundleID(content)) + }) + + t.Run("ApplicationId with whitespace is trimmed", func(t *testing.T) { + content := ` com.example.app ` + assert.Equal(t, "com.example.app", readDotnetMobileBundleID(content)) + }) +} + +// -- DetectProject BundleID population --. + +func TestDetectProject_ReactNativePopulatesBundleID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"react-native":"^0.72"}}`) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "android", "app"), 0755)) + writeTestFile(t, dir, filepath.Join("android", "app", "build.gradle"), + `android { defaultConfig { applicationId "com.example.rn" } }`) + + got := DetectProject(dir) + assert.Equal(t, "react-native", got.Framework) + assert.Equal(t, "com.example.rn", got.BundleID) +} + +func TestDetectProject_FlutterPopulatesBundleID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "pubspec.yaml", "name: my_app\nflutter:\n sdk: flutter") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "android", "app"), 0755)) + writeTestFile(t, dir, filepath.Join("android", "app", "build.gradle"), + `android { defaultConfig { applicationId "com.example.flutter" } }`) + + got := DetectProject(dir) + assert.Equal(t, "flutter", got.Framework) + assert.Equal(t, "com.example.flutter", got.BundleID) +} + +func TestDetectProject_MAUIPopulatesBundleID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + ` + + com.example.maui + true + + +`) + + got := DetectProject(dir) + assert.Equal(t, "maui", got.Framework) + assert.Equal(t, "com.example.maui", got.BundleID) +} + +func TestDetectProject_DotnetMobilePopulatesBundleID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "MyApp.csproj", + ` + + com.example.mobile + net8.0-android;net8.0-ios + +`) + + got := DetectProject(dir) + assert.Equal(t, "dotnet-mobile", got.Framework) + assert.Equal(t, "com.example.mobile", got.BundleID) +} + +func TestDetectProject_IonicAngularPopulatesBundleID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/angular":"^7.0.0"}}`) + writeTestFile(t, dir, "capacitor.config.json", `{"appId":"com.example.ionic","appName":"MyIonicApp"}`) + + got := DetectProject(dir) + assert.Equal(t, "ionic-angular", got.Framework) + assert.Equal(t, "com.example.ionic", got.BundleID) +} + +func TestDetectProject_IonicReactPopulatesBundleID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/react":"^7.0.0"}}`) + writeTestFile(t, dir, "capacitor.config.json", `{"appId":"com.example.ionicreact","appName":"MyIonicReactApp"}`) + + got := DetectProject(dir) + assert.Equal(t, "ionic-react", got.Framework) + assert.Equal(t, "com.example.ionicreact", got.BundleID) +} + +func TestDetectProject_IonicVuePopulatesBundleID(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/vue":"^7.0.0"}}`) + writeTestFile(t, dir, "capacitor.config.json", `{"appId":"com.example.ionicvue","appName":"MyIonicVueApp"}`) + + got := DetectProject(dir) + assert.Equal(t, "ionic-vue", got.Framework) + assert.Equal(t, "com.example.ionicvue", got.BundleID) +} + +func TestDetectProject_IonicVue_CapacitorTS(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"@ionic/vue":"^7.0.0"}}`) + writeTestFile(t, dir, "capacitor.config.ts", `import { CapacitorConfig } from '@capacitor/cli'; +const config: CapacitorConfig = { + appId: 'com.example.ionicvuets', + appName: 'MyIonicVueApp', + webDir: 'dist', +}; +export default config;`) + + got := DetectProject(dir) + assert.Equal(t, "ionic-vue", got.Framework) + assert.Equal(t, "com.example.ionicvuets", got.BundleID) +} + +// -- readRawExpoScheme --. + +func TestReadRawExpoScheme(t *testing.T) { + t.Parallel() + + t.Run("returns valid scheme unchanged", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"myapp"}}`) + assert.Equal(t, "myapp", readRawExpoScheme(dir)) + }) + + t.Run("returns invalid scheme without validation", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"my app"}}`) + assert.Equal(t, "my app", readRawExpoScheme(dir)) + }) + + t.Run("returns scheme starting with digit", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"1invalid"}}`) + assert.Equal(t, "1invalid", readRawExpoScheme(dir)) + }) + + t.Run("returns empty when no scheme field", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"name":"my-app"}}`) + assert.Empty(t, readRawExpoScheme(dir)) + }) + + t.Run("returns empty when no app.json", func(t *testing.T) { + t.Parallel() + assert.Empty(t, readRawExpoScheme(t.TempDir())) + }) + + t.Run("readRawExpoScheme returns value that readExpoScheme rejects", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeTestFile(t, dir, "app.json", `{"expo":{"scheme":"my_app"}}`) + // ReadRawExpoScheme returns the raw invalid value. + assert.Equal(t, "my_app", readRawExpoScheme(dir)) + // ReadExpoScheme rejects it because underscore is not valid in RFC 3986 schemes. + assert.Empty(t, readExpoScheme(dir)) + }) +} + +// -- Negative detection edge-case tests --. + +// React Native projects include app/src/main/AndroidManifest.xml as part of their +// Android sub-project. Ensure they are detected as react-native, not android. +func TestDetectProject_ReactNativeWithAndroidManifest_NotDetectedAsAndroid(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "package.json", `{"dependencies":{"react-native":"^0.72"}}`) + mkTestDir(t, dir, filepath.Join("app", "src", "main")) + writeTestFile(t, filepath.Join(dir, "app", "src", "main"), "AndroidManifest.xml", + ``) + + got := DetectProject(dir) + assert.Equal(t, "react-native", got.Framework) + assert.Equal(t, "native", got.Type) +} + +// Vapor is a server-side Swift framework that uses Package.swift. +// It must NOT be detected as ios-swift. +func TestDetectProject_VaporPackageSwift_NotDetectedAsIOSSwift(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "Package.swift", `// swift-tools-version:5.9 +import PackageDescription +let package = Package( + name: "MyVaporApp", + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), + ] +)`) + + got := DetectProject(dir) + assert.False(t, got.Detected, "Vapor project should not be detected as ios-swift") + assert.NotEqual(t, "ios-swift", got.Framework) +} + +// Kotlin DSL build.gradle.kts uses `applicationId = "..."` (with assignment operator). +// Ensure extractGradleApplicationID handles this form. +func TestExtractGradleApplicationID_KotlinDSL(t *testing.T) { + content := `android { + defaultConfig { + applicationId = "com.example.kotlindsl" + minSdk = 21 + } +}` + assert.Equal(t, "com.example.kotlindsl", extractGradleApplicationID(content)) +} + +// readIOSBundleID should read PRODUCT_BUNDLE_IDENTIFIER from a root-level .xcodeproj +// (native Xcode project layout, not Flutter). +func TestReadIOSBundleID_RootXcodeproj(t *testing.T) { + dir := t.TempDir() + mkTestDir(t, dir, "MyApp.xcodeproj") + writeTestFile(t, dir, filepath.Join("MyApp.xcodeproj", "project.pbxproj"), + `PRODUCT_BUNDLE_IDENTIFIER = com.example.native;`) + assert.Equal(t, "com.example.native", readIOSBundleID(dir)) +} diff --git a/internal/cli/quickstarts.go b/internal/cli/quickstarts.go index d9978fc24..13fddb320 100644 --- a/internal/cli/quickstarts.go +++ b/internal/cli/quickstarts.go @@ -3,16 +3,23 @@ package cli import ( "context" _ "embed" + "encoding/json" "fmt" + "net/url" "os" "path" "path/filepath" "regexp" + "slices" + "sort" "strconv" "strings" "github.com/auth0/go-auth0/management" "github.com/spf13/cobra" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "gopkg.in/yaml.v2" "github.com/auth0/auth0-cli/internal/ansi" "github.com/auth0/auth0-cli/internal/auth0" @@ -68,6 +75,7 @@ func quickstartsCmd(cli *cli) *cobra.Command { cmd.AddCommand(listQuickstartsCmd(cli)) cmd.AddCommand(downloadQuickstartCmd(cli)) cmd.AddCommand(setupQuickstartCmd(cli)) + cmd.AddCommand(setupQuickstartCmdExperimental(cli)) return cmd } @@ -438,6 +446,107 @@ var ( } ) +// Flags for the setup-experimental command. +var ( + setupExpApp = Flag{ + Name: "App", + LongForm: "app", + Help: "Create an Auth0 application (SPA, regular web, or native)", + } + setupExpName = Flag{ + Name: "Name", + LongForm: "name", + Help: "Name of the Auth0 application", + } + setupExpType = Flag{ + Name: "Type", + LongForm: "type", + Help: "Application type: spa, regular, native, or m2m", + } + setupExpFramework = Flag{ + Name: "Framework", + LongForm: "framework", + Help: "Framework to configure (e.g., react, nextjs, vue, express)", + } + setupExpBuildTool = Flag{ + Name: "Build Tool", + LongForm: "build-tool", + Help: "Build tool used by the project (vite, webpack, cra, none)", + } + setupExpPort = Flag{ + Name: "Port", + LongForm: "port", + Help: "Local port the application runs on (default varies by framework, e.g. 3000, 5173)", + } + setupExpCallbackURL = Flag{ + Name: "Callback URL", + LongForm: "callback-url", + Help: "Override the allowed callback URL for the application", + } + setupExpLogoutURL = Flag{ + Name: "Logout URL", + LongForm: "logout-url", + Help: "Override the allowed logout URL for the application", + } + setupExpWebOriginURL = Flag{ + Name: "Web Origin URL", + LongForm: "web-origin-url", + Help: "Override the allowed web origin URL for the application", + } + setupExpAPI = Flag{ + Name: "API", + LongForm: "api", + Help: "Create an Auth0 API resource server", + } + setupExpIdentifier = Flag{ + Name: "Identifier", + LongForm: "identifier", + Help: "Unique URL identifier for the API (audience), e.g. https://my-api", + AlsoKnownAs: []string{"audience"}, + } + setupExpSigningAlg = Flag{ + Name: "Signing Algorithm", + LongForm: "signing-alg", + Help: "[API] Token signing algorithm: RS256, PS256, or HS256 (leave blank to be prompted interactively)", + } + setupExpScopes = Flag{ + Name: "Scopes", + LongForm: "scopes", + Help: "[API] Comma-separated list of permission scopes for the API", + } + setupExpTokenLifetime = Flag{ + Name: "Token Lifetime", + LongForm: "token-lifetime", + Help: "[API] Access token lifetime in seconds (default: 86400 = 24 hours)", + } + setupExpOfflineAccess = Flag{ + Name: "Offline Access", + LongForm: "offline-access", + Help: "Allow offline access (enables refresh tokens)", + } +) + +// SetupInputs holds the user-provided inputs for the setup-experimental command. +type SetupInputs struct { + Name string + App bool + Type string + Framework string + BuildTool string + Port int + BundleID string // Package/bundle ID for native apps, populated from detection. + CallbackURL string + LogoutURL string + WebOriginURL string + API bool + Identifier string + SigningAlg string + Scopes string + TokenLifetime string + OfflineAccess bool + MetaData map[string]interface{} +} + func setupQuickstartCmd(cli *cli) *cobra.Command { var inputs struct { Type string @@ -656,3 +765,1160 @@ func setupQuickstartCmd(cli *cli) *cobra.Command { return cmd } + +func setupQuickstartCmdExperimental(cli *cli) *cobra.Command { + var inputs SetupInputs + + cmd := &cobra.Command{ + Use: "setup-experimental", + Args: cobra.NoArgs, + Short: "Set up Auth0 for your quickstart application", + Long: "Creates an Auth0 application and/or API and generates a config file with the necessary Auth0 settings.\n\n" + + "The command will:\n" + + " 1. Check if you are authenticated (and prompt for login if needed)\n" + + " 2. Auto-detect your project framework from the current directory\n" + + " 3. Create an Auth0 application and/or API resource server\n" + + " 4. Generate a config file with the appropriate environment variables\n\n" + + "Supported frameworks are dynamically loaded from the QuickstartConfigs map.", + Example: ` auth0 quickstarts setup-experimental + auth0 quickstarts setup-experimental --app --framework react --type spa + auth0 quickstarts setup-experimental --api --identifier https://my-api + auth0 quickstarts setup-experimental --app --api --name "My App"`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if err := cli.setupWithAuthentication(ctx); err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + // LinkedAppClientID tracks which app client ID to link to the API + // (either a newly created app or one selected from the tenant). + var linkedAppClientID string + canPromptFlag := canPrompt(cmd) + + // -- Step 1: Decide what to create (App / API / both) --. + if !inputs.App && !inputs.API { + if !canPromptFlag { + return fmt.Errorf("in --no-input mode, specify at least one of --app or --api") + } + var selections []string + if err := prompt.AskMultiSelect( + "What do you want to create? (select whatever applies)", + &selections, + "App", "API", + ); err != nil { + return fmt.Errorf("failed to select target resource(s): %w", err) + } + for _, s := range selections { + switch strings.ToLower(s) { + case "app": + inputs.App = true + case "api": + inputs.API = true + } + } + if !inputs.App && !inputs.API { + return fmt.Errorf("please select at least one option: App and/or API") + } + } + + // -- Step 2: Auto-detect project framework --. + if inputs.App { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // M2M apps have no framework or port; skip DetectProject entirely. + if inputs.Type == "m2m" { + if inputs.Name == "" { + inputs.Name = filepath.Base(cwd) + } + } else { + detection := DetectProject(cwd) + + typeFromFlag := setupExpType.IsSet(cmd) + frameworkFromFlag := setupExpFramework.IsSet(cmd) + + switch { + case typeFromFlag && frameworkFromFlag: + // User explicitly specified type and framework via flags; skip detection UI. + if inputs.Name == "" { + inputs.Name = detection.AppName + } + // If build tool was not explicitly provided, read it from detected config + // files (e.g. vite.config.ts) rather than defaulting to "none" statically. + if !setupExpBuildTool.IsSet(cmd) && detection.BuildTool != "" { + inputs.BuildTool = detection.BuildTool + } + if inputs.BundleID == "" && detection.BundleID != "" { + inputs.BundleID = detection.BundleID + } + case detection.Detected: + noInputMode := !canPromptFlag + if len(detection.AmbiguousFrameworks) > 1 { + // Multiple package.json deps matched - show partial summary and ask user to disambiguate. + cli.renderer.InfofBullet("Detected in current directory") + cli.renderer.InfofBullet("Framework: %s", "Could not be determined") + cli.renderer.InfofBullet("App type: %s", detectionFriendlyAppType(detection.Type)) + if noInputMode || prompt.ConfirmWithDefault("Do you want to proceed with the detected values?", true) { + inputs = applyDetectionToInputs(inputs, detection) + if inputs.Framework == "" { + if noInputMode { + inputs.Framework = detection.AmbiguousFrameworks[0] + } else { + defaultFramework := detection.AmbiguousFrameworks[0] + if err := setupExpFramework.Select(cmd, &inputs.Framework, detection.AmbiguousFrameworks, &defaultFramework); err != nil { + return fmt.Errorf("failed to select framework: %w", err) + } + } + } + } + } else if detection.Framework != "" { + // Single clear detection - show summary and confirm. + titleCaser := cases.Title(language.English) + frameworkDisplay := frameworkDisplayName(detection.Framework) + if detection.BuildTool != "" && detection.BuildTool != "none" { + frameworkDisplay += " - " + titleCaser.String(detection.BuildTool) + } + cli.renderer.InfofBullet("Detected in current directory") + cli.renderer.InfofBullet("Framework: %s", frameworkDisplay) + cli.renderer.InfofBullet("App type: %s", detectionFriendlyAppType(detection.Type)) + cli.renderer.InfofBullet("App name: %s", detection.AppName) + if detection.Port > 0 { + cli.renderer.InfofBullet("Port: %d", detection.Port) + } + + if noInputMode || prompt.ConfirmWithDefault("Do you want to proceed with the detected values?", true) { + inputs = applyDetectionToInputs(inputs, detection) + if inputs.Framework == "" { + inputs.Framework = detection.Framework + } + } + } + default: + // No detection signal found - notify the user and pre-fill name from directory. + if !canPromptFlag && inputs.Type == "" { + return fmt.Errorf( + "auto-detection failed: unable to auto detect application. " + + "In --no-input mode provide --type, --framework, and optionally --build-tool " + + "(e.g. --type spa --framework react --build-tool vite)", + ) + } + cli.renderer.Warnf("auto-detection failed: unable to auto detect application") + if inputs.Name == "" { + inputs.Name = detection.AppName + } + } + } + } + + // -- Step 3: Resolve remaining prompts for App / API -- + // In non-interactive mode, --type alone is not enough; --framework is also required. + if !canPromptFlag && inputs.App && inputs.Type != "" && inputs.Type != "m2m" && inputs.Framework == "" { + return fmt.Errorf( + "--framework is required in non-interactive mode when --type is %s: "+ + "use --framework and optionally --build-tool flags "+ + "(e.g. --framework react --build-tool vite)", + inputs.Type, + ) + } + qsConfigKey, updatedInputs, wasAutoSelected, err := getQuickstartConfigKey(cmd, inputs) + if err != nil { + return fmt.Errorf("failed to get quickstart configuration: %w", err) + } + inputs = updatedInputs + if inputs.App && wasAutoSelected { + cli.renderer.Infof("Auto-selected build tool %q for %s/%s (no exact match for 'none')", inputs.BuildTool, inputs.Type, inputs.Framework) + } + + // -- Step 3b: Collect application name --. + if inputs.App { + if !setupExpName.IsSet(cmd) { + defaultName := inputs.Name + if defaultName == "" { + defaultName = "My App" + } + inputs.Name = defaultName + if err := setupExpName.Ask(cmd, &inputs.Name, &defaultName); err != nil { + return fmt.Errorf("failed to enter application name: %w", err) + } + if inputs.Name == "" { + return fmt.Errorf("application name cannot be empty") + } + } + if inputs.Name == "" { + return fmt.Errorf("application name cannot be empty") + } + } + + // -- Step 3d: Prompt for port if not explicitly set --. + if inputs.App && inputs.Type != "native" && inputs.Type != "m2m" { + portStr := strconv.Itoa(inputs.Port) + if err := setupExpPort.AskInt(cmd, &inputs.Port, &portStr); err != nil { + return fmt.Errorf("failed to enter port: %w", err) + } + if inputs.Port < 1024 || inputs.Port > 65535 { + return fmt.Errorf("invalid port number: %d (must be between 1024 and 65535)", inputs.Port) + } + } + + // -- Step 3c: Collect API name for API-only flow --. + if inputs.API && !inputs.App { + // Collect API name if not already set (pre-fill from CWD folder name). + if inputs.Name == "" && !setupExpName.IsSet(cmd) { + cwd, _ := os.Getwd() + defaultName := filepath.Base(cwd) + if defaultName == "" || defaultName == "." { + defaultName = "my-api" + } + inputs.Name = defaultName + if err := setupExpName.Ask(cmd, &inputs.Name, &defaultName); err != nil { + return fmt.Errorf("failed to enter application name: %w", err) + } + } + } + + if inputs.API { + // Prompt for the identifier if not explicitly provided via flag. + if !setupExpIdentifier.IsSet(cmd) { + // Compute a suggested default without pre-populating inputs.Identifier. + defaultID := inputs.Identifier + if defaultID == "" && inputs.Name != "" { + slug := strings.ToLower(strings.ReplaceAll(inputs.Name, " ", "-")) + defaultID = "https://" + slug + } + inputs.Identifier = defaultID + if err := setupExpIdentifier.Ask(cmd, &inputs.Identifier, &defaultID); err != nil { + return fmt.Errorf("failed to enter API identifier: %w", err) + } + } + + if inputs.Identifier == "" { + return fmt.Errorf("API identifier cannot be empty: use --identifier flag") + } + + if err := validateAPIIdentifier(inputs.Identifier); err != nil { + return err + } + + // If the flag was not set, prompt interactively; fall back to 86400 in non-interactive mode. + if inputs.TokenLifetime == "" { + defaultLifetime := "86400" + inputs.TokenLifetime = defaultLifetime + if err := setupExpTokenLifetime.Ask(cmd, &inputs.TokenLifetime, &defaultLifetime); err != nil { + return fmt.Errorf("failed to enter token lifetime: %w", err) + } + if inputs.TokenLifetime == "" { + cli.renderer.Warnf("Token lifetime left blank; using default 86400 seconds (24 hours)") + inputs.TokenLifetime = defaultLifetime + } + } + + if inputs.SigningAlg == "" { + signingAlgs := []string{"RS256", "PS256", "HS256"} + defaultAlg := "RS256" + inputs.SigningAlg = defaultAlg + if err := setupExpSigningAlg.Select(cmd, &inputs.SigningAlg, signingAlgs, &defaultAlg); err != nil { + return fmt.Errorf("failed to select signing algorithm: %w", err) + } + } + + if alg := inputs.SigningAlg; alg != "RS256" && alg != "PS256" && alg != "HS256" { + return fmt.Errorf("invalid signing algorithm %q: must be RS256, PS256, or HS256", alg) + } + + // For API-only: fetch existing apps and let the user select one to link. + if !inputs.App { + var appList *management.ClientList + var appListErr error + _ = ansi.Waiting(func() error { + appList, appListErr = cli.api.Client.List( + ctx, + management.Parameter("app_type", "native,spa,regular_web"), + management.Parameter("is_global", "false"), + ) + return appListErr + }) + if appListErr != nil { + cli.renderer.Warnf("Could not fetch existing applications: %v. You can link the API to an app manually.", appListErr) + } + + appOptions := []string{"Skip"} + appIDByName := make(map[string]string) + if appList != nil && len(appList.Clients) > 0 { + named := make([]string, 0, len(appList.Clients)) + for _, c := range appList.Clients { + name := c.GetName() + named = append(named, name) + appIDByName[name] = c.GetClientID() + } + named = append(named, "Skip") + appOptions = named + } + + if canPromptFlag { + var selectedAppName string + q := prompt.SelectInput( + "link-app", + "Select App to register API", + "Select an existing application to authorize for this API, or skip", + appOptions, + appOptions[0], + true, + ) + if err := prompt.AskOne(q, &selectedAppName); err != nil { + return fmt.Errorf("failed to select app: %w", err) + } + if selectedAppName != "Skip" { + linkedAppClientID = appIDByName[selectedAppName] + } + } + } + } + + // -- Step 4: Create the Auth0 application client --. + if inputs.App { + clientID, err := createQuickstartApp(ctx, cli, inputs, qsConfigKey) + if err != nil { + return err + } + linkedAppClientID = clientID + } + + // -- Step 5: Create the Auth0 API resource server --. + if inputs.API { + if err := createQuickstartAPI(ctx, cli, inputs, linkedAppClientID); err != nil { + return err + } + } + + return nil + }, + } + + // App flags. + setupExpApp.RegisterBool(cmd, &inputs.App, false) + setupExpName.RegisterString(cmd, &inputs.Name, "") + setupExpType.RegisterString(cmd, &inputs.Type, "") + setupExpFramework.RegisterString(cmd, &inputs.Framework, "") + setupExpBuildTool.RegisterString(cmd, &inputs.BuildTool, "none") + setupExpPort.RegisterInt(cmd, &inputs.Port, 0) + setupExpCallbackURL.RegisterString(cmd, &inputs.CallbackURL, "") + setupExpLogoutURL.RegisterString(cmd, &inputs.LogoutURL, "") + setupExpWebOriginURL.RegisterString(cmd, &inputs.WebOriginURL, "") + + // API flags. + setupExpAPI.RegisterBool(cmd, &inputs.API, false) + setupExpIdentifier.RegisterString(cmd, &inputs.Identifier, "") + setupExpSigningAlg.RegisterString(cmd, &inputs.SigningAlg, "") + setupExpScopes.RegisterString(cmd, &inputs.Scopes, "") + setupExpTokenLifetime.RegisterString(cmd, &inputs.TokenLifetime, "") + setupExpOfflineAccess.RegisterBool(cmd, &inputs.OfflineAccess, false) + + return cmd +} + +func printClientDetails(cli *cli, client *management.Client, port int, configFileLocation string) { + cli.renderer.Successf("An application %q has been created in the management console", client.GetName()) + cli.renderer.Detailf("Client ID: %s", ansi.Magenta(client.GetClientID())) + cli.renderer.Newline() + + cli.renderer.Successf("You can manage your application from here:") + cli.renderer.Detailf("%s", ansi.Magenta(fmt.Sprintf("https://manage.auth0.com/dashboard/#/applications/%s/settings", client.GetClientID()))) + cli.renderer.Newline() + + if client.Callbacks != nil && len(client.GetCallbacks()) > 0 { + cli.renderer.Successf("Callback URLs registered in Auth0 Dashboard:") + cli.renderer.Detailf("%s", ansi.Magenta(strings.Join(client.GetCallbacks(), ", "))) + cli.renderer.Newline() + } + if client.AllowedLogoutURLs != nil && len(client.GetAllowedLogoutURLs()) > 0 { + cli.renderer.Successf("Logout URLs registered:") + cli.renderer.Detailf("%s", ansi.Magenta(strings.Join(client.GetAllowedLogoutURLs(), ", "))) + cli.renderer.Newline() + } + cli.renderer.Successf("Config file created: %s", ansi.Magenta(configFileLocation)) +} + +func printAPIDetails(cli *cli, rs *management.ResourceServer) { + cli.renderer.Successf("An API %q has been created and registered", rs.GetName()) + cli.renderer.Detailf("Identifier: %s", ansi.Magenta(rs.GetIdentifier())) + cli.renderer.Newline() + cli.renderer.Successf("You can manage your API from here:") + cli.renderer.Detailf("%s", ansi.Magenta(fmt.Sprintf("https://manage.auth0.com/dashboard/#/apis/%s/settings", rs.GetID()))) +} + +// 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(ctx context.Context, cli *cli, inputs SetupInputs, qsConfigKey string) (string, error) { + config, exists := auth0.QuickstartConfigs[qsConfigKey] + if !exists { + return "", fmt.Errorf("unsupported quickstart arguments: %s. Supported types: %v", qsConfigKey, getSupportedQuickstartTypes()) + } + + // For Expo, read the production URI scheme from app.json (expo.scheme). + // Custom schemes like "myapp://" are not registered automatically because + // Auth0 API rejects bare custom-scheme URIs (no host component). Instead, + // the scheme is surfaced in post-setup guidance so the user can add it manually. + var expoScheme string + if inputs.Framework == "expo" { + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + expoScheme = readExpoScheme(cwd) + if expoScheme == "" { + // Warn when app.json has a scheme that is not a valid RFC 3986 URI scheme. + if raw := readRawExpoScheme(cwd); raw != "" { + cli.renderer.Warnf("app.json expo.scheme %q is not a valid URI scheme (must start with a letter and contain only letters, digits, +, -, .); scheme will be ignored.", raw) + } + } + } + } + + // Resolve the bundle/package ID for native app guidance output. + // The callback URL includes the Auth0 domain, so it can only be constructed after + // the tenant config is fetched below. + // Prefer the BundleID already populated by DetectProject to avoid re-reading disk. + var nativeBundleID string + switch { + case inputs.BundleID != "": + nativeBundleID = inputs.BundleID + case inputs.Framework == "flutter" || inputs.Framework == "react-native": + // Fallback for when framework was specified via --framework flag (detection not run). + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + nativeBundleID = readMobileBundleID(cwd) + } + case inputs.Framework == "maui" || inputs.Framework == "dotnet-mobile": + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + if csprojContent, ok := findCsprojContent(cwd); ok { + nativeBundleID = readDotnetMobileBundleID(csprojContent) + } + } + case inputs.Framework == "ionic-angular" || inputs.Framework == "ionic-react" || inputs.Framework == "ionic-vue": + // Fallback for when framework was specified via --framework flag (detection not run). + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + nativeBundleID = readCapacitorAppID(cwd) + } + } + + // For dotnet-mobile and MAUI, the custom URI scheme callback is derived from the + // ApplicationId in the .csproj. Register it in Auth0 when the bundle ID is known + // so the developer does not need a manual dashboard update. + if (inputs.Framework == "dotnet-mobile" || inputs.Framework == "maui") && nativeBundleID != "" { + config.RequestParams.Callbacks = []string{nativeBundleID + "://callback"} + } + + client, err := generateClient(inputs, config.RequestParams) + if err != nil { + return "", fmt.Errorf("failed to generate client: %w", err) + } + + if err := ansi.Waiting(func() error { + return cli.api.Client.Create(ctx, client) + }); err != nil { + return "", fmt.Errorf("failed to create application: %w", err) + } + + // When an API is also being created, inject the audience variable so the + // config file contains the API identifier the app should request tokens for. + envValues := config.EnvValues + if inputs.API && inputs.Identifier != "" && config.AudienceVar != "" { + envValues = make(map[string]string, len(config.EnvValues)+1) + for k, v := range config.EnvValues { + envValues[k] = v + } + envValues[config.AudienceVar] = inputs.Identifier + } + + envFilePath, err := GenerateAndWriteQuickstartConfig(&config.Strategy, envValues, cli.tenant, client, inputs.Port) + if err != nil { + return "", fmt.Errorf("failed to generate config file: %w", err) + } + printClientDetails(cli, client, inputs.Port, filepath.Base(envFilePath)) + + // Post-setup guidance for Expo: exp://localhost:19000 only covers Expo Go. + // Inform the user about EAS/production build requirements. + if inputs.Framework == "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.") + } + } + + // Post-setup guidance for Flutter and .NET Mobile apps: show the + // callback URLs to register in the Auth0 Dashboard. These use the + // app's bundle/package ID and the tenant domain, both of which are + // now available. + switch inputs.Framework { + case "flutter", "react-native": + if nativeBundleID != "" { + // The bundle ID is used directly as the URI scheme. RFC 3986 permits + // hyphens in URI schemes, and both iOS CFBundleURLSchemes and Android + // intent filters support them natively. + cli.renderer.Infof("Add these Allowed Callback URLs in the Auth0 Dashboard:") + cli.renderer.Infof(" Android: %s://%s/android/%s/callback", nativeBundleID, cli.tenant, nativeBundleID) + cli.renderer.Infof(" iOS: %s://%s/ios/%s/callback", nativeBundleID, cli.tenant, nativeBundleID) + } + case "maui", "dotnet-mobile": + if nativeBundleID != "" { + cli.renderer.Infof("Registered %s://callback as the Allowed Callback URL.", nativeBundleID) + } + case "ionic-angular", "ionic-react", "ionic-vue": + if nativeBundleID != "" { + // Capacitor intercepts http://localhost in the WebView (already registered). + // Surface the appId so the user can configure deep links if needed. + cli.renderer.Infof("Capacitor app ID: %s", nativeBundleID) + cli.renderer.Infof("http://localhost is registered as the Allowed Callback URL (Capacitor WebView).") + } else { + // No Capacitor config found - remind the user where it should be. + 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).") + } + case "jhipster": + cli.renderer.Infof("Refer to JHipster documentation to complete the setup: https://www.jhipster.tech/security/#auth0") + } + + return client.GetClientID(), nil +} + +// 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, linkedAppClientID string) error { + // API name = "-API", fallback to identifier. + apiName := inputs.Identifier + if inputs.Name != "" { + apiName = inputs.Name + "-API" + } + + cli.renderer.Infof("Creating API resource server %q with identifier %q...", apiName, inputs.Identifier) + tokenLifetime, tokenErr := strconv.Atoi(inputs.TokenLifetime) + if tokenErr != nil || tokenLifetime <= 0 { + if inputs.TokenLifetime != "" && inputs.TokenLifetime != "86400" { + cli.renderer.Warnf("Invalid token lifetime %q, using default 86400 seconds", inputs.TokenLifetime) + } + tokenLifetime = 86400 + } + + rs := &management.ResourceServer{ + Name: &apiName, + Identifier: &inputs.Identifier, + SigningAlgorithm: &inputs.SigningAlg, + TokenLifetime: &tokenLifetime, + } + if inputs.OfflineAccess { + allow := true + rs.AllowOfflineAccess = &allow + } + + 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) + } + } + + if err := ansi.Waiting(func() error { + return cli.api.ResourceServer.Create(ctx, rs) + }); err != nil { + return fmt.Errorf("failed to create API: %w", err) + } + printAPIDetails(cli, rs) + + // Link the app to the API via a client grant if an app was selected/created. + if linkedAppClientID != "" { + emptyScopes := []string{} + grant := &management.ClientGrant{ + ClientID: &linkedAppClientID, + Audience: &inputs.Identifier, + Scope: &emptyScopes, + } + if grantErr := ansi.Waiting(func() error { + return cli.api.ClientGrant.Create(ctx, grant) + }); grantErr != nil { + cli.renderer.Warnf("Failed to link application to API: %v", grantErr) + } + } + + return nil +} + +func getSupportedQuickstartTypes() []string { + var types []string + for key := range auth0.QuickstartConfigs { + types = append(types, key) + } + sort.Strings(types) + return types +} + +// frameworksForType returns the list of unique frameworks available for the given app type. +func frameworksForType(qsType string) []string { + seen := make(map[string]bool) + var frameworks []string + for key := range auth0.QuickstartConfigs { + parts := strings.SplitN(key, ":", 3) + if len(parts) >= 2 && parts[0] == qsType { + fw := parts[1] + if !seen[fw] { + seen[fw] = true + frameworks = append(frameworks, fw) + } + } + } + sort.Strings(frameworks) + return frameworks +} + +// getQuickstartConfigKey resolves remaining missing prompts for App and API creation +// and returns the config map key for the selected framework. +// App/API selection and project detection are handled by the caller before this is invoked. +func getQuickstartConfigKey(cmd *cobra.Command, inputs SetupInputs) (string, SetupInputs, bool, error) { + if !inputs.App { + return "", inputs, false, nil + } + + inputs, wasAutoSelected, err := resolveSetupInputs(cmd, inputs) + if err != nil { + return "", inputs, false, err + } + + if inputs.Type == "m2m" { + return "m2m:none:none", inputs, false, nil + } + + configKey := fmt.Sprintf("%s:%s:%s", inputs.Type, inputs.Framework, inputs.BuildTool) + + return configKey, inputs, wasAutoSelected, nil +} + +// resolveSetupInputs validates and fills missing fields on inputs by prompting the user +// where needed. It returns the updated inputs, whether a build tool was auto-selected, +// and any error encountered. +func resolveSetupInputs(cmd *cobra.Command, inputs SetupInputs) (SetupInputs, bool, error) { + // Validate --type if provided. + validTypes := []string{"spa", "regular", "native", "m2m"} + if inputs.Type != "" && !slices.Contains(validTypes, inputs.Type) { + return inputs, false, fmt.Errorf( + "invalid --type %q: must be one of %s", + inputs.Type, strings.Join(validTypes, ", "), + ) + } + + // Prompt for --type if not provided. + if inputs.Type == "" { + defaultType := "spa" + if err := setupExpType.Select(cmd, &inputs.Type, validTypes, &defaultType); err != nil { + return inputs, false, fmt.Errorf("failed to select application type: %w", err) + } + } + + // M2M apps have no framework, port, or callback URLs. + if inputs.Type == "m2m" { + return inputs, false, nil + } + + // Prompt for --framework filtered to the selected type. + if inputs.Framework == "" { + frameworks := frameworksForType(inputs.Type) + if len(frameworks) == 0 { + return inputs, false, fmt.Errorf("no frameworks available for type %q", inputs.Type) + } + if err := setupExpFramework.Select(cmd, &inputs.Framework, frameworks, &frameworks[0]); err != nil { + return inputs, false, fmt.Errorf("failed to select framework: %w", err) + } + } + + // 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 + if inputs.BuildTool == "" || inputs.BuildTool == "none" { + if _, exists := auth0.QuickstartConfigs[fmt.Sprintf("%s:%s:none", inputs.Type, inputs.Framework)]; !exists { + if tools := auth0.FrameworkBuildTools[inputs.Type+":"+inputs.Framework]; len(tools) > 0 { + inputs.BuildTool = tools[0] + wasAutoSelected = true + } else { + inputs.BuildTool = "none" + } + } else { + inputs.BuildTool = "none" + } + } + + return inputs, wasAutoSelected, nil +} + +// applyDetectionToInputs copies fields from a DetectionResult into inputs, skipping +// any field that was already explicitly set. The framework field is NOT copied here +// because the ambiguous-candidate path requires a prompt before it can be resolved. +func applyDetectionToInputs(inputs SetupInputs, d DetectionResult) SetupInputs { + if inputs.Type == "" { + inputs.Type = d.Type + } + if inputs.BuildTool == "" || inputs.BuildTool == "none" { + inputs.BuildTool = d.BuildTool + } + if inputs.Port == 0 { + inputs.Port = d.Port + } + if inputs.Name == "" { + inputs.Name = d.AppName + } + if inputs.BundleID == "" && d.BundleID != "" { + inputs.BundleID = d.BundleID + } + return inputs +} + +// frameworkDisplayName returns a human-friendly display name for a framework key. +// It handles cases where the internal key (e.g. "vanilla-python") differs from the +// name a developer would expect to see (e.g. "Flask"). +func frameworkDisplayName(framework string) string { + switch framework { + case "vanilla-python": + return "Flask" + case "vanilla-go": + return "Go" + case "vanilla-php": + return "PHP" + case "vanilla-javascript": + return "Vanilla JS" + case "vanilla-java": + return "Java" + case "jhipster": + return "JHipster" + default: + titleCaser := cases.Title(language.English) + return titleCaser.String(framework) + } +} + +// defaultPortForFramework returns the conventional port for a given framework name. +func defaultPortForFramework(framework string) int { + switch framework { + case "react", "vue", "svelte", "sveltekit", "vanilla-javascript": + return 5173 // Vite default. + case "angular": + return 4200 + case "flask", "vanilla-python": + return 5000 + case "django": + return 8000 + case "laravel": + return 8000 + case "spring-boot", "java-ee", "vanilla-java", "jhipster": + return 8080 + default: + return 3000 + } +} + +// validateAPIIdentifier returns an error if identifier is not a valid http:// or https:// URL. +func validateAPIIdentifier(identifier string) error { + // ParseRequestURI is stricter than Parse: it rejects relative URLs, fragments, + // and empty strings. The host check still catches bare schemes like "http://" + // that ParseRequestURI accepts without error. + _, err := url.ParseRequestURI(identifier) + if err == nil || len(identifier) != 24 { + return nil + } + return fmt.Errorf("invalid API identifier %q: must be a valid URL beginning with http:// or https://", identifier) +} + +func generateClient(input SetupInputs, reqParams auth0.RequestParams) (*management.Client, error) { + if input.Name == "" { + input.Name = "My App" + } + + if input.MetaData == nil { + input.MetaData = map[string]interface{}{ + "created_by": "quickstart-docs-manual-cli", + } + } + + resolved := resolveRequestParams(reqParams, input.Name, input.Port) + + // Override URL fields with explicit flag values when provided. + if input.CallbackURL != "" { + resolved.Callbacks = []string{input.CallbackURL} + } + if input.LogoutURL != "" { + resolved.AllowedLogoutURLs = []string{input.LogoutURL} + } + if input.WebOriginURL != "" { + resolved.WebOrigins = []string{input.WebOriginURL} + } + + algorithm := "RS256" + oidcConformant := true + client := &management.Client{ + Name: &input.Name, + AppType: &resolved.AppType, + Callbacks: &resolved.Callbacks, + AllowedLogoutURLs: &resolved.AllowedLogoutURLs, + OIDCConformant: &oidcConformant, + JWTConfiguration: &management.ClientJWTConfiguration{ + Algorithm: &algorithm, + }, + ClientMetadata: &input.MetaData, + } + + if len(resolved.WebOrigins) > 0 { + client.WebOrigins = &resolved.WebOrigins + } + + return client, nil +} + +// resolveRequestParams replaces DetectionSub placeholders in RequestParams fields +// with actual values derived from the user inputs. +func resolveRequestParams(reqParams auth0.RequestParams, name string, port int) auth0.RequestParams { + if port == 0 { + port = 3000 + } + baseURL := fmt.Sprintf("http://localhost:%d", port) + + callbacks := make([]string, len(reqParams.Callbacks)) + copy(callbacks, reqParams.Callbacks) + logoutURLs := make([]string, len(reqParams.AllowedLogoutURLs)) + copy(logoutURLs, reqParams.AllowedLogoutURLs) + webOrigins := make([]string, len(reqParams.WebOrigins)) + copy(webOrigins, reqParams.WebOrigins) + + resolvedName := reqParams.Name + if resolvedName == auth0.DetectionSub { + resolvedName = name + } + callbackPath := "/callback" + if reqParams.CallbackPath != "" { + callbackPath = reqParams.CallbackPath + } + for i, cb := range callbacks { + switch cb { + case auth0.DetectionSub: + callbacks[i] = baseURL + callbackPath + case auth0.DetectionSubAsBase: + callbacks[i] = baseURL + } + } + for i, u := range logoutURLs { + if u == auth0.DetectionSub { + logoutURLs[i] = baseURL + } + } + for i, u := range webOrigins { + if u == auth0.DetectionSub { + webOrigins[i] = baseURL + } + } + + return auth0.RequestParams{ + AppType: reqParams.AppType, + Callbacks: callbacks, + AllowedLogoutURLs: logoutURLs, + WebOrigins: webOrigins, + Name: resolvedName, + CallbackPath: reqParams.CallbackPath, + } +} + +func replaceDetectionSub(envValues map[string]string, tenantDomain string, client *management.Client, port int) (map[string]string, error) { + if port == 0 { + port = 3000 + } + baseURL := fmt.Sprintf("http://localhost:%d", port) + + updatedEnvValues := make(map[string]string) + + for key, value := range envValues { + if value != auth0.DetectionSub && value != auth0.DetectionSubAsBase { + updatedEnvValues[key] = value + continue + } + resolved, err := resolveDetectionSubValue(key, tenantDomain, baseURL, client) + if err != nil { + return nil, err + } + updatedEnvValues[key] = resolved + } + + return updatedEnvValues, nil +} + +// resolveDetectionSubValue maps a single env key to its runtime value. +func resolveDetectionSubValue(key, tenantDomain, baseURL string, client *management.Client) (string, error) { + switch key { + case "VITE_AUTH0_DOMAIN", "AUTH0_DOMAIN", "domain", "NUXT_AUTH0_DOMAIN", + "auth0.domain", "auth0/domain", "Auth0:Domain", "auth0:Domain", "auth0_domain", + "EXPO_PUBLIC_AUTH0_DOMAIN", "com.auth0.domain", + "com_auth0_domain", "Domain": + return tenantDomain, nil + + // Express SDK specifically requires the https:// prefix. + case "ISSUER_BASE_URL": + return "https://" + tenantDomain, nil + + // Spring Boot okta issuer specifically requires https:// and a trailing slash. + case "okta.oauth2.issuer": + return "https://" + tenantDomain + "/", nil + + case "VITE_AUTH0_CLIENT_ID", "AUTH0_CLIENT_ID", "clientId", "NUXT_AUTH0_CLIENT_ID", + "CLIENT_ID", "auth0.clientId", "auth0/clientId", "okta.oauth2.client-id", "Auth0:ClientId", + "auth0:ClientId", "auth0_client_id", "EXPO_PUBLIC_AUTH0_CLIENT_ID", "com.auth0.clientId", + "com_auth0_client_id", "ClientId": + return client.GetClientID(), nil + + case "AUTH0_CLIENT_SECRET", "NUXT_AUTH0_CLIENT_SECRET", "auth0.clientSecret", "auth0/clientSecret", + "okta.oauth2.client-secret", "Auth0:ClientSecret", "auth0:ClientSecret", + "auth0_client_secret", "com.auth0.clientSecret": + return client.GetClientSecret(), nil + + case "AUTH0_SECRET", "NUXT_AUTH0_SESSION_SECRET", "SESSION_SECRET", + "SECRET", "AUTH0_SESSION_ENCRYPTION_KEY", "AUTH0_COOKIE_SECRET": + secret, err := generateState(32) + if err != nil { + return "", fmt.Errorf("failed to generate secret for %s: %w", key, err) + } + return secret, nil + + case "APP_BASE_URL", "NUXT_AUTH0_APP_BASE_URL", "BASE_URL", "AUTH0_BASE_URL": + return baseURL, nil + + case "AUTH0_REDIRECT_URI", "AUTH0_CALLBACK_URL": + return baseURL + "/callback", nil + + case "SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI": + return "https://" + tenantDomain + "/", nil + + case "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID": + return client.GetClientID(), nil + + case "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET": + return client.GetClientSecret(), nil + + case "JHIPSTER_SECURITY_OAUTH2_AUDIENCE": + return "https://" + tenantDomain + "/api/v2/", nil + + default: + return "", fmt.Errorf("unhandled placeholder for env key %q: add it to replaceDetectionSub", key) + } +} + +// buildNestedMap converts a flat map with dot-delimited keys into a nested map, +// e.g. {"okta.oauth2.issuer": "x"} -> {"okta": {"oauth2": {"issuer": "x"}}}. +func buildNestedMap(flat map[string]string) map[string]interface{} { + result := make(map[string]interface{}) + for key, value := range flat { + parts := strings.Split(key, ".") + current := result + for i, part := range parts { + if i == len(parts)-1 { + if _, alreadyMap := current[part].(map[string]interface{}); !alreadyMap { + current[part] = value + } + } else { + next, ok := current[part].(map[string]interface{}) + if !ok { + next = make(map[string]interface{}) + current[part] = next + } + current = next + } + } + } + return result +} + +// xmlEscape replaces XML/HTML special characters with their entity equivalents +// so that generated XML config files are well-formed even when values contain +// characters like &, <, >, " or '. +func xmlEscape(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return replacer.Replace(s) +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// GenerateAndWriteQuickstartConfig takes the selected stack, resolves the dynamic values, +// and writes them to the appropriate file in the Current Working Directory (CWD). +// It returns the file path and an error (if any). +func GenerateAndWriteQuickstartConfig(strategy *auth0.FileOutputStrategy, envValues map[string]string, tenantDomain string, client *management.Client, port int) (string, error) { + resolvedEnv, err := replaceDetectionSub(envValues, tenantDomain, client, port) + if err != nil { + return "", err + } + + if strategy == nil { + strategy = &auth0.FileOutputStrategy{Path: ".env", Format: "dotenv"} + } + + 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) + } + } + + var contentBuilder strings.Builder + + switch strategy.Format { + case "dotenv": + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf("%s=\"%s\"\n", key, resolvedEnv[key])) + } + + case "properties": + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf("%s=%s\n", key, resolvedEnv[key])) + } + + case "yaml": + // Produce nested YAML from dot-delimited keys (e.g. Spring Boot application.yml). + nested := buildNestedMap(resolvedEnv) + yamlBytes, err := yaml.Marshal(nested) + if err != nil { + return "", fmt.Errorf("failed to marshal YAML for %s: %w", strategy.Path, err) + } + contentBuilder.Write(yamlBytes) + + case "rails-yaml": + // Rails config/auth0.yml wraps credentials under the "development" environment key. + devSection := make(map[string]interface{}, len(resolvedEnv)) + for k, v := range resolvedEnv { + devSection[k] = v + } + 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) + } + contentBuilder.Write(yamlBytes) + + case "ts": + contentBuilder.WriteString("export const environment = {\n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" %s: '%s',\n", key, strings.ReplaceAll(resolvedEnv[key], "'", "\\'"))) + } + contentBuilder.WriteString("};\n") + + case "angular-ts": + // Angular SPA environment.ts nests domain and clientId under an auth0 object, + // matching the official Angular quickstart: environment.auth0.domain / .clientId. + contentBuilder.WriteString("export const environment = {\n") + contentBuilder.WriteString(" production: false,\n") + contentBuilder.WriteString(" auth0: {\n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" %s: '%s',\n", key, strings.ReplaceAll(resolvedEnv[key], "'", "\\'"))) + } + contentBuilder.WriteString(" },\n") + contentBuilder.WriteString("};\n") + + case "dart": + contentBuilder.WriteString("const Map authConfig = {\n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" '%s': '%s',\n", strings.ReplaceAll(key, "'", "\\'"), strings.ReplaceAll(resolvedEnv[key], "'", "\\'"))) + } + contentBuilder.WriteString("};\n") + + case "json": + // C# appsettings.json expects nested JSON: {"Auth0": {"Domain": "...", "ClientId": "..."}}. + 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) + } + cleanKey := strings.TrimPrefix(key, "Auth0:") + auth0Section[cleanKey] = val + } + 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) + } + contentBuilder.Write(jsonBytes) + + case "xml": + // ASP.NET OWIN Web.config. + contentBuilder.WriteString("\n") + contentBuilder.WriteString("\n") + contentBuilder.WriteString(" \n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" \n", xmlEscape(key), xmlEscape(resolvedEnv[key]))) + } + contentBuilder.WriteString(" \n") + contentBuilder.WriteString("\n") + + case "webxml": + // Java servlet web.xml context-param entries (mvc-auth-commons). + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString("\n") + contentBuilder.WriteString(fmt.Sprintf(" %s\n", xmlEscape(key))) + contentBuilder.WriteString(fmt.Sprintf(" %s\n", xmlEscape(resolvedEnv[key]))) + contentBuilder.WriteString("\n") + } + + case "javaee-webxml": + // Java EE web.xml JNDI env-entry elements (auth0-java-mvc-commons). + // Values are looked up via InitialContext.lookup("auth0.domain") etc. + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString("\n") + contentBuilder.WriteString(fmt.Sprintf(" %s\n", xmlEscape(key))) + contentBuilder.WriteString(" java.lang.String\n") + contentBuilder.WriteString(fmt.Sprintf(" %s\n", xmlEscape(resolvedEnv[key]))) + contentBuilder.WriteString("\n") + } + + case "android-strings": + // Android res/values/strings.xml - Auth0 SDK reads credentials via string resources. + contentBuilder.WriteString("\n") + contentBuilder.WriteString("\n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" %s\n", xmlEscape(key), xmlEscape(resolvedEnv[key]))) + } + contentBuilder.WriteString("\n") + + case "plist": + // IOS Auth0.plist - Auth0 Swift SDK reads ClientId and Domain from this plist. + contentBuilder.WriteString("\n") + contentBuilder.WriteString("\n") + contentBuilder.WriteString("\n") + contentBuilder.WriteString("\n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" %s\n", key)) + contentBuilder.WriteString(fmt.Sprintf(" %s\n", xmlEscape(resolvedEnv[key]))) + } + contentBuilder.WriteString("\n") + contentBuilder.WriteString("\n") + } + + if err := os.WriteFile(strategy.Path, []byte(contentBuilder.String()), 0600); err != nil { + return "", fmt.Errorf("failed to write config file %s: %w", strategy.Path, err) + } + + return strategy.Path, nil +} diff --git a/internal/cli/quickstarts_test.go b/internal/cli/quickstarts_test.go index 34a8dd0dc..e57b8cb1c 100644 --- a/internal/cli/quickstarts_test.go +++ b/internal/cli/quickstarts_test.go @@ -1,30 +1,1007 @@ package cli import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" "testing" + "github.com/auth0/go-auth0/management" + "github.com/golang/mock/gomock" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/auth0/mock" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/display" ) -func TestQuickstartsTypeFor(t *testing.T) { - assert.Equal(t, qsSpa, quickstartsTypeFor("spa")) - assert.Equal(t, qsWebApp, quickstartsTypeFor("regular_web")) - assert.Equal(t, qsWebApp, quickstartsTypeFor("regular_web")) - assert.Equal(t, qsBackend, quickstartsTypeFor("non_interactive")) - assert.Equal(t, "generic", quickstartsTypeFor("some-unknown-value")) +// -- DetectionSubAsBase --. + +// TestResolveRequestParams_DetectionSubAsBase verifies that DetectionSubAsBase in +// callbacks resolves to baseURL with no path suffix (unlike DetectionSub which +// appends "/callback"). +func TestResolveRequestParams_DetectionSubAsBase(t *testing.T) { + t.Parallel() + + t.Run("callback resolves to baseURL only", func(t *testing.T) { + t.Parallel() + req := auth0.RequestParams{ + AppType: "spa", + Callbacks: []string{auth0.DetectionSubAsBase}, + AllowedLogoutURLs: []string{auth0.DetectionSub}, + WebOrigins: []string{auth0.DetectionSub}, + Name: auth0.DetectionSub, + } + got := resolveRequestParams(req, "MyApp", 5173) + assert.Equal(t, []string{"http://localhost:5173"}, got.Callbacks, "callback should be baseURL with no path") + assert.Equal(t, []string{"http://localhost:5173"}, got.AllowedLogoutURLs) + assert.Equal(t, []string{"http://localhost:5173"}, got.WebOrigins) + }) + + t.Run("DetectionSubAsBase in logoutURLs is not substituted (only DetectionSub is)", func(t *testing.T) { + t.Parallel() + req := auth0.RequestParams{ + AllowedLogoutURLs: []string{auth0.DetectionSubAsBase}, + } + got := resolveRequestParams(req, "App", 3000) + assert.Equal(t, []string{auth0.DetectionSubAsBase}, got.AllowedLogoutURLs) + }) +} + +// TestResolveRequestParams_CallbackPath verifies that a custom CallbackPath +// overrides the default "/callback" suffix when resolving DetectionSub in +// Callbacks. +func TestResolveRequestParams_CallbackPath(t *testing.T) { + t.Parallel() + + cases := []struct { + callbackPath string + port int + want string + }{ + {"/api/auth/callback", 3000, "http://localhost:3000/api/auth/callback"}, + {"/auth/callback", 3000, "http://localhost:3000/auth/callback"}, + {"/login/oauth2/code/oidc", 8080, "http://localhost:8080/login/oauth2/code/oidc"}, + {"", 3000, "http://localhost:3000/callback"}, // Default when empty. + } + + for _, tc := range cases { + tc := tc + t.Run(fmt.Sprintf("%s:%d", tc.callbackPath, tc.port), func(t *testing.T) { + t.Parallel() + req := auth0.RequestParams{ + Callbacks: []string{auth0.DetectionSub}, + CallbackPath: tc.callbackPath, + } + got := resolveRequestParams(req, "App", tc.port) + require.Len(t, got.Callbacks, 1) + assert.Equal(t, tc.want, got.Callbacks[0]) + }) + } +} + +// -- resolveRequestParams with QuickstartConfigs --. + +// TestResolveRequestParams_AllQuickstartConfigs verifies that each entry in +// auth0.QuickstartConfigs produces the correct resolved callback and logout URLs +// when given a specific port, matching the patterns required by each framework. +func TestResolveRequestParams_AllQuickstartConfigs(t *testing.T) { + t.Parallel() + + tests := []struct { + configKey string + port int + wantCallbacks []string + wantLogoutURLs []string + wantWebOrigins []string + wantAppType string + }{ + // SPA: callback = just baseURL (no /callback suffix per Auth0 SPA SDK usage). + {"spa:react:vite", 5173, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, "spa"}, + {"spa:vue:vite", 5173, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, "spa"}, + {"spa:svelte:vite", 5173, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, "spa"}, + {"spa:vanilla-javascript:vite", 5173, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, + []string{"http://localhost:5173"}, "spa"}, + {"spa:angular:none", 4200, + []string{"http://localhost:4200"}, + []string{"http://localhost:4200"}, + []string{"http://localhost:4200"}, "spa"}, + {"spa:flutter-web:none", 3000, + []string{"http://localhost:3000"}, + []string{"http://localhost:3000"}, + []string{"http://localhost:3000"}, "spa"}, + // Regular web: framework-specific callback paths. + {"regular:nextjs:none", 3000, + []string{"http://localhost:3000/api/auth/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:fastify: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"}, + {"regular:express:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:hono:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:vanilla-python:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + // Flask detection sets port 5000 (Flask's historical default). + {"regular:vanilla-python:none", 5000, + []string{"http://localhost:5000/callback"}, + []string{"http://localhost:5000"}, nil, "regular_web"}, + {"regular:sveltekit:none", 3000, + []string{"http://localhost:3000/auth/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:sveltekit:vite", 3000, + []string{"http://localhost:3000/auth/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:django:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + // Django detection (manage.py or requirements.txt) sets port 8000 (Django dev server default). + {"regular:django:none", 8000, + []string{"http://localhost:8000/callback"}, + []string{"http://localhost:8000"}, nil, "regular_web"}, + {"regular:vanilla-go:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:spring-boot:maven", 8080, + []string{"http://localhost:8080/login/oauth2/code/oidc"}, + []string{"http://localhost:8080"}, nil, "regular_web"}, + {"regular:spring-boot:gradle", 8080, + []string{"http://localhost:8080/login/oauth2/code/oidc"}, + []string{"http://localhost:8080"}, nil, "regular_web"}, + {"regular:laravel:composer", 8000, + []string{"http://localhost:8000/callback"}, + []string{"http://localhost:8000"}, nil, "regular_web"}, + {"regular:rails:none", 3000, + []string{"http://localhost:3000/auth/auth0/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:jhipster:none", 8080, + []string{"http://localhost:8080/login/oauth2/code/oidc"}, + []string{"http://localhost:8080"}, nil, "regular_web"}, + {"regular:aspnet-mvc:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:aspnet-blazor:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:aspnet-owin:none", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:vanilla-php:composer", 3000, + []string{"http://localhost:3000/callback"}, + []string{"http://localhost:3000"}, nil, "regular_web"}, + {"regular:vanilla-java:maven", 8080, + []string{"http://localhost:8080/callback"}, + []string{"http://localhost:8080"}, nil, "regular_web"}, + {"regular:java-ee:maven", 8080, + []string{"http://localhost:8080/callback"}, + []string{"http://localhost:8080"}, nil, "regular_web"}, + // Native: static callback URLs — no DetectionSub substitution. + // Flutter and React Native use custom URI scheme callbacks (bundle-ID-specific); + // the bundle identifier is unknown at setup time so callbacks are empty. + {"native:flutter:none", 0, + []string{}, + []string{}, nil, "native"}, + {"native:react-native:none", 0, + []string{}, + []string{}, nil, "native"}, + // Expo uses the standard Expo Go redirect URI. + {"native:expo:none", 0, + []string{"exp://localhost:19000"}, + []string{"exp://localhost:19000"}, nil, "native"}, + // Ionic (Capacitor) intercepts http://localhost redirects in the WebView. + {"native:ionic-angular:none", 0, + []string{"http://localhost"}, + []string{"http://localhost"}, nil, "native"}, + {"native:ionic-react:vite", 0, + []string{"http://localhost"}, + []string{"http://localhost"}, nil, "native"}, + {"native:ionic-vue:vite", 0, + []string{"http://localhost"}, + []string{"http://localhost"}, nil, "native"}, + // .NET Mobile and MAUI use custom URI scheme callbacks (bundle-ID-specific). + {"native:dotnet-mobile:none", 0, + []string{}, + []string{}, nil, "native"}, + {"native:maui:none", 0, + []string{}, + []string{}, nil, "native"}, + // WPF/WinForms uses the bare loopback http://localhost per Auth0 docs. + {"native:wpf-winforms:none", 0, + []string{"http://localhost"}, + []string{"http://localhost"}, nil, "native"}, + // Android and iOS Swift: custom URI scheme callbacks; unknown at setup time. + {"native:android:gradle", 0, []string{}, []string{}, nil, "native"}, + {"native:ios-swift:none", 0, []string{}, []string{}, nil, "native"}, + // M2M: no URLs. + {"m2m:none:none", 0, []string{}, []string{}, nil, "non_interactive"}, + // Custom port propagates. + {"spa:react:vite", 8080, + []string{"http://localhost:8080"}, + []string{"http://localhost:8080"}, + []string{"http://localhost:8080"}, "spa"}, + {"regular:nextjs:none", 8080, + []string{"http://localhost:8080/api/auth/callback"}, + []string{"http://localhost:8080"}, nil, "regular_web"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.configKey, func(t *testing.T) { + t.Parallel() + config, ok := auth0.QuickstartConfigs[tc.configKey] + require.True(t, ok, "config key %q not found", tc.configKey) + + got := resolveRequestParams(config.RequestParams, "TestApp", tc.port) + + assert.Equal(t, tc.wantAppType, got.AppType) + assert.Equal(t, tc.wantCallbacks, got.Callbacks) + assert.Equal(t, tc.wantLogoutURLs, got.AllowedLogoutURLs) + if tc.wantWebOrigins != nil { + assert.Equal(t, tc.wantWebOrigins, got.WebOrigins) + } + }) + } +} + +// -- GenerateAndWriteQuickstartConfig with QuickstartConfigs --. + +// TestGenerateAndWriteQuickstartConfig_AllQuickstartConfigs verifies the env +// file content generated for every application type in auth0.QuickstartConfigs. +func TestGenerateAndWriteQuickstartConfig_AllQuickstartConfigs(t *testing.T) { + t.Parallel() + + const domain = "test.auth0.com" + const cidVal = "test-client-id" + const csecVal = "test-client-secret" + cid, csec := cidVal, csecVal + client := &management.Client{ClientID: &cid, ClientSecret: &csec} + + tests := []struct { + configKey string + port int + wantFileName string + wantKeys []string + wantValues map[string]string + }{ + // SPA. + {"spa:react:vite", 5173, ".env", + []string{"VITE_AUTH0_DOMAIN", "VITE_AUTH0_CLIENT_ID"}, + map[string]string{"VITE_AUTH0_DOMAIN": domain, "VITE_AUTH0_CLIENT_ID": cidVal}}, + {"spa:vue:vite", 5173, ".env", + []string{"VITE_AUTH0_DOMAIN", "VITE_AUTH0_CLIENT_ID"}, + map[string]string{"VITE_AUTH0_DOMAIN": domain, "VITE_AUTH0_CLIENT_ID": cidVal}}, + {"spa:svelte:vite", 5173, ".env", + []string{"VITE_AUTH0_DOMAIN", "VITE_AUTH0_CLIENT_ID"}, + map[string]string{"VITE_AUTH0_DOMAIN": domain, "VITE_AUTH0_CLIENT_ID": cidVal}}, + {"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:angular:none", 4200, "environment.ts", + // Angular-ts wraps values under auth0:{} matching the official Angular quickstart. + []string{"auth0:", "domain:", "clientId:", "production:"}, + map[string]string{domain: domain, cidVal: cidVal}}, + {"spa:flutter-web:none", 3000, "auth_config.dart", + []string{"domain", "clientId"}, + map[string]string{"domain": domain, "clientId": cidVal}}, + // Regular web. + {"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: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"}}, + {"regular:nuxt:none", 3000, ".env", + []string{"NUXT_AUTH0_DOMAIN", "NUXT_AUTH0_CLIENT_ID", "NUXT_AUTH0_CLIENT_SECRET", "NUXT_AUTH0_SESSION_SECRET", "NUXT_AUTH0_APP_BASE_URL"}, + map[string]string{"NUXT_AUTH0_DOMAIN": domain, "NUXT_AUTH0_CLIENT_ID": cidVal, "NUXT_AUTH0_APP_BASE_URL": "http://localhost:3000"}}, + {"regular:express:none", 3000, ".env", + []string{"ISSUER_BASE_URL", "CLIENT_ID", "SECRET", "BASE_URL"}, + map[string]string{"ISSUER_BASE_URL": "https://" + domain, "CLIENT_ID": cidVal, "BASE_URL": "http://localhost:3000"}}, + {"regular:hono:none", 3000, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_SESSION_ENCRYPTION_KEY", "BASE_URL"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal, "BASE_URL": "http://localhost:3000"}}, + {"regular:vanilla-python:none", 3000, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_SECRET", "AUTH0_REDIRECT_URI"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_REDIRECT_URI": "http://localhost:3000/callback"}}, + // Spring-boot uses YAML: dot-keys are nested; verify both structure and value. + {"regular:spring-boot:maven", 8080, "application.yml", + []string{"okta:", "oauth2:", "issuer: https://", "client-id:", "client-secret:"}, + nil}, + {"regular:spring-boot:gradle", 8080, "application.yml", + []string{"okta:", "oauth2:", "issuer: https://", "client-id:", "client-secret:"}, + nil}, + {"regular:laravel:composer", 8000, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_COOKIE_SECRET", "AUTH0_BASE_URL"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal, "AUTH0_BASE_URL": "http://localhost:8000"}}, + {"regular:rails:none", 3000, "auth0.yml", + []string{"development:", "auth0_domain:", "auth0_client_id:", "auth0_client_secret:"}, + map[string]string{"auth0_domain": domain, "auth0_client_id": cidVal}}, + {"regular:jhipster:none", 8080, ".auth0.env", + []string{"SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI", "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID", "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET", "JHIPSTER_SECURITY_OAUTH2_AUDIENCE"}, + map[string]string{ + "SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI": "https://" + domain + "/", + "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID": cidVal, + "SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET": csecVal, + "JHIPSTER_SECURITY_OAUTH2_AUDIENCE": "https://" + domain + "/api/v2/", + }}, + {"regular:aspnet-mvc:none", 3000, "appsettings.json", + []string{"Domain", "ClientId", "ClientSecret"}, nil}, + {"regular:aspnet-blazor:none", 3000, "appsettings.json", + []string{"Domain", "ClientId", "ClientSecret"}, nil}, + {"regular:aspnet-owin:none", 3000, "Web.config", + []string{"auth0:Domain", "auth0:ClientId"}, nil}, + {"regular:vanilla-php:composer", 3000, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_COOKIE_SECRET", "AUTH0_BASE_URL"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_BASE_URL": "http://localhost:3000"}}, + {"regular:vanilla-java:maven", 8080, "web.xml", + []string{"com.auth0.domain", "com.auth0.clientId", "com.auth0.clientSecret"}, + map[string]string{"com.auth0.domain": domain, "com.auth0.clientId": cidVal}}, + {"regular:java-ee:maven", 8080, "web.xml", + // Javaee-webxml writes JNDI env-entry elements matching the official Java EE quickstart. + // Keys use slash separators (auth0/domain) per the Java EE JNDI naming convention. + []string{"env-entry", "auth0/domain", "auth0/clientId", "auth0/clientSecret", "auth0/scope"}, + map[string]string{"auth0/domain": domain, "auth0/clientId": cidVal}}, + {"regular:sveltekit: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"}}, + // SvelteKit + Vite uses the same server-side config as sveltekit:none. + {"regular:sveltekit:vite", 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:django:none", 3000, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal, "AUTH0_CLIENT_SECRET": csecVal}}, + {"regular:vanilla-go:none", 3000, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_CALLBACK_URL"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CALLBACK_URL": "http://localhost:3000/callback"}}, + // Native. + {"native:flutter:none", 0, "auth_config.dart", + []string{"domain", "clientId"}, + map[string]string{"domain": domain, "clientId": cidVal}}, + {"native:react-native:none", 0, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal}}, + {"native:expo:none", 0, ".env", + []string{"EXPO_PUBLIC_AUTH0_DOMAIN", "EXPO_PUBLIC_AUTH0_CLIENT_ID"}, + map[string]string{"EXPO_PUBLIC_AUTH0_DOMAIN": domain, "EXPO_PUBLIC_AUTH0_CLIENT_ID": cidVal}}, + {"native:ionic-angular:none", 0, "environment.ts", + []string{"domain", "clientId"}, nil}, + {"native:ionic-react:vite", 0, ".env", + []string{"VITE_AUTH0_DOMAIN", "VITE_AUTH0_CLIENT_ID"}, nil}, + {"native:ionic-vue:vite", 0, ".env", + []string{"VITE_AUTH0_DOMAIN", "VITE_AUTH0_CLIENT_ID"}, nil}, + {"native:dotnet-mobile:none", 0, "appsettings.json", + []string{"Domain", "ClientId"}, nil}, + {"native:maui:none", 0, "appsettings.json", + []string{"Domain", "ClientId"}, nil}, + // WPF/WinForms are public native clients (PKCE) — no client secret is written. + {"native:wpf-winforms:none", 0, "appsettings.json", + []string{"Domain", "ClientId"}, nil}, + {"native:android:gradle", 0, "strings.xml", + []string{"com_auth0_domain", "com_auth0_client_id", "com_auth0_scheme"}, + map[string]string{"com_auth0_domain": domain, "com_auth0_client_id": cidVal, "com_auth0_scheme": "https"}}, + {"native:ios-swift:none", 0, "Auth0.plist", + []string{"ClientId", "Domain"}, + map[string]string{"ClientId": cidVal, "Domain": domain}}, + // M2M. + {"m2m:none:none", 0, ".env", + []string{"AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET"}, + map[string]string{"AUTH0_DOMAIN": domain, "AUTH0_CLIENT_ID": cidVal, "AUTH0_CLIENT_SECRET": csecVal}}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.configKey, func(t *testing.T) { + t.Parallel() + + config, ok := auth0.QuickstartConfigs[tc.configKey] + require.True(t, ok, "config key %q not found", tc.configKey) + + dir := t.TempDir() + strategy := auth0.FileOutputStrategy{ + Path: filepath.Join(dir, config.Strategy.Path), + Format: config.Strategy.Format, + } + subDir := filepath.Dir(strategy.Path) + if subDir != dir { + require.NoError(t, os.MkdirAll(subDir, 0755)) + } + + filePath, err := GenerateAndWriteQuickstartConfig(&strategy, config.EnvValues, domain, client, tc.port) + require.NoError(t, err) + + fileName := filepath.Base(filePath) + assert.Equal(t, tc.wantFileName, fileName) + assert.FileExists(t, filePath) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + contentStr := string(content) + + for _, key := range tc.wantKeys { + assert.Contains(t, contentStr, key, "key %q missing from %s", key, fileName) + } + for key, wantVal := range tc.wantValues { + assert.Contains(t, contentStr, wantVal, + "value %q for key %q missing from %s", wantVal, key, fileName) + } + }) + } +} + +// -- generateClient with QuickstartConfigs --. + +// TestGenerateClient_AllQuickstartConfigs verifies the management.Client fields +// produced by generateClient for every app type in auth0.QuickstartConfigs. +func TestGenerateClient_AllQuickstartConfigs(t *testing.T) { + t.Parallel() + + tests := []struct { + configKey string + port int + wantAppType string + wantCallbacksLen int + wantCallback string + wantLogoutURLsLen int + wantWebOriginsLen int + }{ + // SPA: callback = baseURL (no /callback suffix). + {"spa:react:vite", 5173, "spa", 1, "http://localhost:5173", 1, 1}, + {"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: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/api/auth/callback", 1, 0}, + {"regular:fastify: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/callback", 1, 0}, + {"regular:vanilla-python:none", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, + // Flask detection sets port 5000 (Flask's historical default). + {"regular:vanilla-python:none", 5000, "regular_web", 1, "http://localhost:5000/callback", 1, 0}, + {"regular:sveltekit:none", 3000, "regular_web", 1, "http://localhost:3000/auth/callback", 1, 0}, + {"regular:sveltekit:vite", 3000, "regular_web", 1, "http://localhost:3000/auth/callback", 1, 0}, + {"regular:django:none", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, + // Django detection (manage.py or requirements.txt) sets port 8000 (Django dev server default). + {"regular:django:none", 8000, "regular_web", 1, "http://localhost:8000/callback", 1, 0}, + {"regular:vanilla-go:none", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, + {"regular:laravel:composer", 8000, "regular_web", 1, "http://localhost:8000/callback", 1, 0}, + {"regular:rails:none", 3000, "regular_web", 1, "http://localhost:3000/auth/auth0/callback", 1, 0}, + {"regular:jhipster:none", 8080, "regular_web", 1, "http://localhost:8080/login/oauth2/code/oidc", 1, 0}, + {"regular:aspnet-mvc:none", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, + {"regular:aspnet-blazor:none", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, + {"regular:aspnet-owin:none", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, + {"regular:vanilla-php:composer", 3000, "regular_web", 1, "http://localhost:3000/callback", 1, 0}, + {"regular:vanilla-java:maven", 8080, "regular_web", 1, "http://localhost:8080/callback", 1, 0}, + {"regular:java-ee:maven", 8080, "regular_web", 1, "http://localhost:8080/callback", 1, 0}, + {"regular:spring-boot:maven", 8080, "regular_web", 1, "http://localhost:8080/login/oauth2/code/oidc", 1, 0}, + {"regular:spring-boot:gradle", 8080, "regular_web", 1, "http://localhost:8080/login/oauth2/code/oidc", 1, 0}, + // Native: static callback URLs appropriate per framework. + // Flutter and React Native use bundle-ID-specific custom URI schemes; unknown at setup time. + {"native:flutter:none", 0, "native", 0, "", 0, 0}, + {"native:react-native:none", 0, "native", 0, "", 0, 0}, + // Expo uses the standard Expo Go redirect URI. + {"native:expo:none", 0, "native", 1, "exp://localhost:19000", 1, 0}, + // Ionic (Capacitor) intercepts http://localhost redirects. + {"native:ionic-angular:none", 0, "native", 1, "http://localhost", 1, 0}, + {"native:ionic-react:vite", 0, "native", 1, "http://localhost", 1, 0}, + {"native:ionic-vue:vite", 0, "native", 1, "http://localhost", 1, 0}, + // .NET Mobile and MAUI use bundle-ID-specific custom URI schemes; unknown at setup time. + {"native:dotnet-mobile:none", 0, "native", 0, "", 0, 0}, + {"native:maui:none", 0, "native", 0, "", 0, 0}, + // WPF/WinForms uses the bare loopback http://localhost per Auth0 docs. + {"native:wpf-winforms:none", 0, "native", 1, "http://localhost", 1, 0}, + // Android and iOS Swift use bundle-ID-specific custom URI schemes; unknown at setup time. + {"native:android:gradle", 0, "native", 0, "", 0, 0}, + {"native:ios-swift:none", 0, "native", 0, "", 0, 0}, + // M2M: no callbacks. + {"m2m:none:none", 0, "non_interactive", 0, "", 0, 0}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.configKey, func(t *testing.T) { + t.Parallel() + + config, ok := auth0.QuickstartConfigs[tc.configKey] + require.True(t, ok) + + c, err := generateClient(SetupInputs{Name: "Test App", Port: tc.port}, config.RequestParams) + require.NoError(t, err) + + assert.Equal(t, tc.wantAppType, c.GetAppType()) + assert.Len(t, c.GetCallbacks(), tc.wantCallbacksLen) + if tc.wantCallback != "" && len(c.GetCallbacks()) > 0 { + assert.Equal(t, tc.wantCallback, c.GetCallbacks()[0]) + } + assert.Len(t, c.GetAllowedLogoutURLs(), tc.wantLogoutURLsLen) + assert.Len(t, c.GetWebOrigins(), tc.wantWebOriginsLen) + assert.True(t, c.GetOIDCConformant()) + assert.NotNil(t, c.ClientMetadata) + }) + } +} + +// -- APP_BASE_URL reflects the user-specified port --. + +func TestGenerateAndWriteQuickstartConfig_PortInBaseURL(t *testing.T) { + t.Parallel() + + for _, configKey := range []string{"regular:nextjs:none", "regular:fastify:none", "regular:express:none"} { + t.Run(configKey, func(t *testing.T) { + t.Parallel() + + config := auth0.QuickstartConfigs[configKey] + dir := t.TempDir() + strategy := auth0.FileOutputStrategy{Path: filepath.Join(dir, ".env"), Format: "dotenv"} + cid, csec := "cid", "csec" + client := &management.Client{ClientID: &cid, ClientSecret: &csec} + + _, err := GenerateAndWriteQuickstartConfig(&strategy, config.EnvValues, "example.auth0.com", client, 8080) + require.NoError(t, err) + + content, err := os.ReadFile(strategy.Path) + require.NoError(t, err) + assert.Contains(t, string(content), "8080", + "%s: port 8080 should appear in the generated file", configKey) + }) + } +} + +// -- Generated secrets (AUTH0_SECRET / SESSION_SECRET) are non-empty --. + +func TestGenerateAndWriteQuickstartConfig_SecretsNonEmpty(t *testing.T) { + t.Parallel() + + 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"} { + t.Run(configKey, func(t *testing.T) { + t.Parallel() + + config := auth0.QuickstartConfigs[configKey] + dir := t.TempDir() + strategy := auth0.FileOutputStrategy{Path: filepath.Join(dir, ".env"), Format: "dotenv"} + + _, err := GenerateAndWriteQuickstartConfig(&strategy, config.EnvValues, "example.auth0.com", client, 3000) + require.NoError(t, err) + + content, err := os.ReadFile(strategy.Path) + require.NoError(t, err) + + for _, line := range strings.Split(string(content), "\n") { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key, val := parts[0], parts[1] + if key == "AUTH0_SECRET" || key == "SESSION_SECRET" { + assert.NotEmpty(t, val, "key %q should be non-empty", key) + } + } + }) + } +} + +// TestReplaceDetectionSub_AllQuickstartConfigsCovered verifies that every env +// key used in any QuickstartConfig (including those added via init()) is handled +// by replaceDetectionSub. This test is intentionally dynamic — it iterates over +// auth0.QuickstartConfigs at runtime so that newly added configs are automatically +// covered without requiring a change to the test itself. +// +// If this test fails, a new env key was added to quickstart.go without a +// corresponding case in the replaceDetectionSub switch in quickstarts.go. +func TestReplaceDetectionSub_AllQuickstartConfigsCovered(t *testing.T) { + t.Parallel() + + cid, csec := "cid", "csec" + client := &management.Client{ClientID: &cid, ClientSecret: &csec} + + for configKey, config := range auth0.QuickstartConfigs { + configKey := configKey + config := config + t.Run(configKey, func(t *testing.T) { + t.Parallel() + + _, err := replaceDetectionSub(config.EnvValues, "example.auth0.com", client, 3000) + require.NoError(t, err, + "config %q: env key not covered by replaceDetectionSub switch — add a case for it in quickstarts.go", + configKey, + ) + }) + } +} + +// TestNoInputWithTypeRequiresFramework verifies that getQuickstartConfigKey +// does not error on missing framework in non-interactive mode — the framework +// prompt is skipped by the Flag wrapper (shouldAsk returns false when canPrompt +// is false). The guard that prevents a missing framework from reaching this +// function lives in the command's RunE, not here. +func TestNoInputWithTypeRequiresFramework(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + appType string + framework string + wantErr bool + }{ + { + name: "spa without framework skips prompt and returns no error", + appType: "spa", + framework: "", + wantErr: false, + }, + { + name: "regular without framework skips prompt and returns no error", + appType: "regular", + framework: "", + wantErr: false, + }, + { + name: "spa with framework succeeds", + appType: "spa", + framework: "react", + wantErr: false, + }, + { + name: "m2m never needs framework", + appType: "m2m", + framework: "", + wantErr: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + inputs := SetupInputs{ + App: true, + Type: tc.appType, + Framework: tc.framework, + } + _, _, _, err := getQuickstartConfigKey(&cobra.Command{}, inputs) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateAPIIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + wantErr bool + }{ + { + name: "valid http URL", + identifier: "http://example.com/api", + wantErr: false, + }, + { + name: "valid https URL", + identifier: "https://my-api.example.com", + wantErr: false, + }, + { + // ParseRequestURI succeeds for valid URLs regardless of length, so + // the err==nil branch returns nil before the length check is reached. + name: "valid URL exactly 24 characters", + identifier: "https://foo.example.com/", + wantErr: false, + }, + { + // Both conditions must be true to trigger an error: the string must + // fail ParseRequestURI AND be exactly 24 characters long. A plain + // 24-character ID (no scheme) satisfies both. + name: "24-character non-URL identifier", + identifier: "abc123def456ghi789jkl012", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validateAPIIdentifier(tc.identifier) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// -- createQuickstartApp happy-path --. + +func TestCreateQuickstartApp_SPA_React(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientAPI := mock.NewMockClientAPI(ctrl) + clientAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, c *management.Client, _ ...management.RequestOption) error { + // Simulate the management API assigning a client ID. + id := "test-client-id" + c.ClientID = &id + return nil + }) + + dir := t.TempDir() + tenantDomain := "test.auth0.com" + testCLI := &cli{ + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + api: &auth0.API{Client: clientAPI}, + Config: config.Config{ + DefaultTenant: tenantDomain, + Tenants: map[string]config.Tenant{ + tenantDomain: {Domain: tenantDomain}, + }, + }, + tenant: tenantDomain, + } + + inputs := SetupInputs{ + App: true, + Name: "My SPA", + Type: "spa", + Framework: "react", + BuildTool: "vite", + 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(context.Background(), testCLI, inputs, "spa:react:vite") + require.NoError(t, err) + assert.Equal(t, "test-client-id", clientID) + + // Config file should have been written. + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.NotEmpty(t, entries, "expected env config file to be written") } -func TestDefaultCallbackURLFor(t *testing.T) { - assert.Equal(t, "http://localhost:3000/api/auth/callback", defaultCallbackURLFor("next.js")) - assert.Equal(t, "http://localhost:3000", defaultCallbackURLFor("all-other-quickstart-application-types")) +func TestCreateQuickstartApp_UnsupportedKey(t *testing.T) { + t.Parallel() + + testCLI := &cli{renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}} + _, err := createQuickstartApp(context.Background(), testCLI, SetupInputs{}, "unknown:framework:none") + assert.ErrorContains(t, err, "unsupported quickstart arguments") } -func TestDefaultURLFor(t *testing.T) { - assert.Equal(t, "http://localhost:4200", defaultURLFor("angular")) - assert.Equal(t, "http://localhost:3000", defaultURLFor("all-other-quickstart-application-types")) +// -- applyDetectionToInputs --. + +// TestApplyDetectionToInputs verifies that applyDetectionToInputs correctly copies +// detection fields into inputs, preserving any fields that were already set. +func TestApplyDetectionToInputs(t *testing.T) { + t.Parallel() + + t.Run("populates empty inputs from detection", func(t *testing.T) { + t.Parallel() + d := DetectionResult{ + Type: "regular", BuildTool: "vite", Port: 3000, AppName: "my-app", + BundleID: "com.example.app", + } + got := applyDetectionToInputs(SetupInputs{}, d) + assert.Equal(t, "regular", got.Type) + assert.Equal(t, "vite", got.BuildTool) + assert.Equal(t, 3000, got.Port) + assert.Equal(t, "my-app", got.Name) + assert.Equal(t, "com.example.app", got.BundleID) + assert.Empty(t, got.Framework, "framework must not be set by applyDetectionToInputs") + }) + + t.Run("preserves explicitly set fields", func(t *testing.T) { + t.Parallel() + d := DetectionResult{Type: "regular", Port: 3000, AppName: "detected-name"} + inputs := SetupInputs{Type: "spa", Port: 5173, Name: "explicit-name"} + got := applyDetectionToInputs(inputs, d) + assert.Equal(t, "spa", got.Type, "explicit type should not be overwritten") + assert.Equal(t, 5173, got.Port, "explicit port should not be overwritten") + assert.Equal(t, "explicit-name", got.Name, "explicit name should not be overwritten") + }) } -func TestUrlPromptFor(t *testing.T) { - assert.Equal(t, "Quickstarts use localhost, do you want to add http://localhost:3000/api/auth/callback to the list\n of allowed callback URLs and http://localhost:3000 to the list of allowed logout URLs?", urlPromptFor("generic", "Next.js")) - assert.Equal(t, "Quickstarts use localhost, do you want to add http://localhost:3000 to the list\n of allowed callback URLs and logout URLs?", urlPromptFor("generic", "Laravel API")) +// TestAmbiguousDetection_NoInputMode_UsesFirstCandidate verifies that when detection +// finds multiple candidates and prompting is disabled, the first candidate is selected +// without hanging. +func TestAmbiguousDetection_NoInputMode_UsesFirstCandidate(t *testing.T) { + t.Parallel() + + detection := DetectionResult{ + Type: "regular", + Port: 3000, + AppName: "my-app", + AmbiguousFrameworks: []string{"express", "hono"}, + Detected: true, + } + + inputs := applyDetectionToInputs(SetupInputs{}, detection) + + // Simulate no-input mode: pick first candidate when framework is empty. + if inputs.Framework == "" { + inputs.Framework = detection.AmbiguousFrameworks[0] + } + + assert.Equal(t, "express", inputs.Framework) + assert.Equal(t, "regular", inputs.Type) + assert.Equal(t, 3000, inputs.Port) +} + +// TestAmbiguousDetection_NoInput_IntegrationFlow verifies the full flow: +// real package.json with two candidates -> ambiguous detection -> no-input mode +// selects the first candidate -> createQuickstartApp succeeds with the resolved inputs. +func TestAmbiguousDetection_NoInput_IntegrationFlow(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + clientAPI := mock.NewMockClientAPI(ctrl) + clientAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, c *management.Client, _ ...management.RequestOption) error { + id := "test-client-id" + c.ClientID = &id + return nil + }) + + dir := t.TempDir() + pkgJSON := `{"name":"my-app","dependencies":{"express":"^4","hono":"^3"}}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkgJSON), 0600)) + + // Step 1: DetectProject finds ambiguous candidates from the real filesystem. + detection := DetectProject(dir) + require.True(t, detection.Detected, "detection should have fired") + require.Greater(t, len(detection.AmbiguousFrameworks), 1, "should have multiple candidates") + + // Step 2: In no-input mode, pick the first candidate (same logic as RunE). + inputs := applyDetectionToInputs(SetupInputs{App: true}, detection) + if inputs.Framework == "" { + inputs.Framework = detection.AmbiguousFrameworks[0] + } + + // Step 3: Resolve the config key. + qsConfigKey, inputs, _, err := getQuickstartConfigKey(&cobra.Command{}, inputs) + require.NoError(t, err) + + // Step 4: Verify the resolved framework is the first ambiguous candidate. + assert.Equal(t, detection.AmbiguousFrameworks[0], inputs.Framework) + assert.Equal(t, "regular", inputs.Type) + + // Step 5: Create the app end-to-end using the resolved inputs. + tenantDomain := "test.auth0.com" + testCLI := &cli{ + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + api: &auth0.API{Client: clientAPI}, + tenant: tenantDomain, + } + + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + defer func() { _ = os.Chdir(origDir) }() + + clientID, err := createQuickstartApp(context.Background(), testCLI, inputs, qsConfigKey) + require.NoError(t, err) + assert.Equal(t, "test-client-id", clientID) +} + +// -- createQuickstartAPI happy-path --. + +func TestCreateQuickstartAPI_CreatesResourceServerAndGrant(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, rs *management.ResourceServer, _ ...management.RequestOption) error { + id := "rs-id-1" + rs.ID = &id + return nil + }) + + grantAPI := mock.NewMockClientGrantAPI(ctrl) + grantAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + Return(nil) + + testCLI := &cli{ + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + api: &auth0.API{ + ResourceServer: rsAPI, + ClientGrant: grantAPI, + }, + } + + inputs := SetupInputs{ + API: true, + Name: "My App", + Identifier: "https://my-api", + SigningAlg: "RS256", + TokenLifetime: "86400", + } + + err := createQuickstartAPI(context.Background(), testCLI, inputs, "linked-app-client-id") + assert.NoError(t, err) +} + +func TestCreateQuickstartAPI_NoLinkedApp_SkipsGrant(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + rsAPI := mock.NewMockResourceServerAPI(ctrl) + rsAPI.EXPECT(). + Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, rs *management.ResourceServer, _ ...management.RequestOption) error { + id := "rs-id-2" + rs.ID = &id + return nil + }) + + // No grant creation expected when linkedAppClientID is empty. + grantAPI := mock.NewMockClientGrantAPI(ctrl) + + testCLI := &cli{ + renderer: &display.Renderer{MessageWriter: &bytes.Buffer{}, ResultWriter: &bytes.Buffer{}}, + api: &auth0.API{ + ResourceServer: rsAPI, + ClientGrant: grantAPI, + }, + } + + inputs := SetupInputs{ + API: true, + Identifier: "https://my-api", + SigningAlg: "RS256", + } + + err := createQuickstartAPI(context.Background(), testCLI, inputs, "") + assert.NoError(t, err) } diff --git a/internal/cli/test.go b/internal/cli/test.go index 8f40b89dd..4cb0da199 100644 --- a/internal/cli/test.go +++ b/internal/cli/test.go @@ -35,18 +35,20 @@ var ( } testAudience = Flag{ - Name: "Audience", - LongForm: "audience", - ShortForm: "a", - Help: "The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt.", + Name: "Identifier", + LongForm: "identifier", + ShortForm: "a", + Help: "The unique identifier of the target API you want to access. For Machine to Machine Applications, only the enabled APIs will be shown within the interactive prompt.", + AlsoKnownAs: []string{"audience"}, } testAudienceRequired = Flag{ - Name: testAudience.Name, - LongForm: testAudience.LongForm, - ShortForm: testAudience.ShortForm, - Help: testAudience.Help, - IsRequired: true, + Name: testAudience.Name, + LongForm: testAudience.LongForm, + ShortForm: testAudience.ShortForm, + Help: testAudience.Help, + IsRequired: true, + AlsoKnownAs: testAudience.AlsoKnownAs, } testScopes = Flag{ @@ -115,16 +117,16 @@ func testLoginCmd(cli *cli) *cobra.Command { Example: ` auth0 test login auth0 test login auth0 test login --connection-name - auth0 test login --connection-name --audience - auth0 test login --connection-name --audience --organization - auth0 test login --connection-name --audience --domain --params "foo=bar" - auth0 test login --connection-name --audience --domain --scopes - auth0 test login -c -a -d -s --force - auth0 test login -c -a -d -s --json - auth0 test login -c -a -d -s --json-compact - auth0 test login -c -a -d -o -s -p "foo=bar" -p "bazz=buzz" --json - auth0 test login -c -a -d -o -s -p "foo=bar","bazz=buzz" --json - auth0 test login -c -a -d -s --force --json`, + auth0 test login --connection-name --identifier + auth0 test login --connection-name --identifier --organization + auth0 test login --connection-name --identifier --domain --params "foo=bar" + auth0 test login --connection-name --identifier --domain --scopes + auth0 test login -c -a -d -s --force + auth0 test login -c -a -d -s --json + auth0 test login -c -a -d -s --json-compact + auth0 test login -c -a -d -o -s -p "foo=bar" -p "bazz=buzz" --json + auth0 test login -c -a -d -o -s -p "foo=bar","bazz=buzz" --json + auth0 test login -c -a -d -s --force --json`, RunE: func(cmd *cobra.Command, args []string) error { client, err := selectClientToUseForTestsAndValidateExistence(cli, cmd, args, &inputs) if err != nil { @@ -207,17 +209,17 @@ func testTokenCmd(cli *cli) *cobra.Command { Args: cobra.MaximumNArgs(1), Short: "Request an access token for a given application and API", Long: "Request an access token for a given application. " + - "Specify the API you want this token for with `--audience` (API Identifier). " + + "Specify the API you want this token for with `--identifier` (API Identifier). " + "Additionally, you can also specify the `--scopes` to grant.", Example: ` auth0 test token - auth0 test token --audience --organization --scopes --params "foo=bar" - auth0 test token -a -o -s - auth0 test token -a -s --force - auth0 test token -a -o -s -p "foo=bar" -p "bazz=buzz" --force - auth0 test token -a -s --json - auth0 test token -a -s --json-compact - auth0 test token -a -o -s -p "foo=bar","bazz=buzz" --json - auth0 test token -a -s --force --json`, + auth0 test token --identifier --organization --scopes --params "foo=bar" + auth0 test token -a -o -s + auth0 test token -a -s --force + auth0 test token -a -o -s -p "foo=bar" -p "bazz=buzz" --force + auth0 test token -a -s --json + auth0 test token -a -s --json-compact + auth0 test token -a -o -s -p "foo=bar","bazz=buzz" --json + auth0 test token -a -s --force --json`, RunE: func(cmd *cobra.Command, args []string) error { var tokenResponse *authutil.TokenResponse diff --git a/internal/display/display.go b/internal/display/display.go index 88327fb50..28800dd17 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -65,6 +65,28 @@ func (r *Renderer) Infof(format string, a ...interface{}) { fmt.Fprintf(r.MessageWriter, format+"\n", a...) } +// InfofBullet writes an info line with a compact green bullet prefix (no padding), +// used for condensed detection-summary output where the extra indentation of +// Infof would be visually noisy. +func (r *Renderer) InfofBullet(format string, a ...interface{}) { + fmt.Fprint(r.MessageWriter, ansi.Green("▸ ")) + fmt.Fprintf(r.MessageWriter, format+"\n", a...) +} + +// Successf writes a success line with a green check-mark prefix. +func (r *Renderer) Successf(format string, a ...interface{}) { + fmt.Fprint(r.MessageWriter, ansi.Green("✓ ")) + fmt.Fprintf(r.MessageWriter, format+"\n", a...) +} + +const detailIndent = " " + +// Detailf writes an indented detail line with no prefix symbol, used for +// supplementary information displayed beneath a success or info message. +func (r *Renderer) Detailf(format string, a ...interface{}) { + fmt.Fprintf(r.MessageWriter, detailIndent+format+"\n", a...) +} + func (r *Renderer) Warnf(format string, a ...interface{}) { fmt.Fprint(r.MessageWriter, ansi.Yellow(" ▸ ")) fmt.Fprintf(r.MessageWriter, format+"\n", a...) diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 2e81c6c9e..8e17168c9 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -46,6 +46,8 @@ func AskBool(message string, value *bool, defaultValue bool) error { return err } +// Confirm prompts the user with a yes/no question and returns their response. +// On EOF (e.g. piped input or --no-input mode) it returns false. func Confirm(message string) bool { result := false prompt := &survey.Confirm{ @@ -59,6 +61,22 @@ func Confirm(message string) bool { return result } +// ConfirmWithDefault prompts with the given default value (Y/n when true, y/N when false). +// On EOF (e.g. --no-input mode) the default value is returned instead of false. +func ConfirmWithDefault(message string, defaultValue bool) bool { + result := defaultValue + prompt := &survey.Confirm{ + Message: message, + Default: defaultValue, + } + + if err := askOne(prompt, &result); err != nil { + return defaultValue + } + + return result +} + func TextInput(name string, message string, help string, defaultValue string, required bool) *survey.Question { input := &survey.Question{ Name: name,