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..221c1afec --- /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 Alias for --identifier (unique audience URL for the 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 Comma-separated list of permission scopes for the API + --signing-alg string Token signing algorithm: RS256, PS256, or HS256 (leave blank to be prompted interactively) + --token-lifetime string Access token lifetime in seconds (default: 86400 = 24 hours) (default "86400") + --type string Application type: spa, regular, or native + --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/internal/auth0/client_grant.go b/internal/auth0/client_grant.go index 0aa5d7eef..8b3a7f66d 100644 --- a/internal/auth0/client_grant.go +++ b/internal/auth0/client_grant.go @@ -7,6 +7,9 @@ import ( ) type ClientGrantAPI interface { + // Create a client grant. + Create(ctx context.Context, g *management.ClientGrant, opts ...management.RequestOption) error + // List all client grants. 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..e822f543b 100644 --- a/internal/auth0/quickstart.go +++ b/internal/auth0/quickstart.go @@ -14,7 +14,6 @@ import ( "github.com/auth0/go-auth0/management" "github.com/auth0/auth0-cli/internal/buildinfo" - "github.com/auth0/auth0-cli/internal/utils" ) @@ -174,3 +173,516 @@ func (q Quickstarts) Stacks() []string { return stacks } + +const DetectionSub = "DETECTION_SUB" + +type FileOutputStrategy struct { + Path string + Format string +} + +type RequestParams struct { + AppType string + Callbacks []string + AllowedLogoutURLs []string + WebOrigins []string + Name string +} + +type AppConfig struct { + EnvValues map[string]string + RequestParams RequestParams + Strategy FileOutputStrategy +} + +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{"http://localhost:5173/callback"}, + AllowedLogoutURLs: []string{"http://localhost:5173"}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "spa:angular:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{"http://localhost:4200/callback"}, + AllowedLogoutURLs: []string{"http://localhost:4200"}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "src/environments/environment.ts", Format: "ts"}, + }, + "spa:vue:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{"http://localhost:5173/callback"}, + AllowedLogoutURLs: []string{"http://localhost:5173"}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "spa:svelte:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{"http://localhost:5173/callback"}, + AllowedLogoutURLs: []string{"http://localhost:5173"}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "spa:vanilla-javascript:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{"http://localhost:5173/callback"}, + AllowedLogoutURLs: []string{"http://localhost:5173"}, + WebOrigins: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "spa:flutter-web:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "spa", + Callbacks: []string{DetectionSub}, + 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{"http://localhost:3000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:3000"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "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{"http://localhost:3000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:3000"}, + Name: DetectionSub, + }, + 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{"http://localhost:3000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:3000"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "regular:sveltekit:none": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + 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{"http://localhost:3000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:3000"}, + 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{"http://localhost:3000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:3000"}, + 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{"http://localhost:5000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:5000"}, + 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{ + "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: "src/main/resources/application.properties", Format: "properties"}, + }, + "regular:java-ee:maven": { + 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: "src/main/resources/META-INF/microprofile-config.properties", Format: "properties"}, + }, + "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{"http://localhost:8000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:8000"}, + Name: DetectionSub, + }, + 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, + }, + 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, + }, + 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, + }, + RequestParams: RequestParams{ + AppType: "regular_web", + Callbacks: []string{"http://localhost:8000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:8000"}, + 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{"http://localhost:3000/callback"}, + AllowedLogoutURLs: []string{"http://localhost:3000"}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + + // ==========================================. + "native:flutter:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + 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{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + 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, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "native:ionic-angular:none": { + EnvValues: map[string]string{ + "domain": DetectionSub, + "clientId": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + 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, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{DetectionSub}, + Name: DetectionSub, + AllowedLogoutURLs: []string{DetectionSub}, + }, + Strategy: FileOutputStrategy{Path: ".env", Format: "dotenv"}, + }, + "native:ionic-vue:vite": { + EnvValues: map[string]string{ + "VITE_AUTH0_DOMAIN": DetectionSub, + "VITE_AUTH0_CLIENT_ID": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + 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{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + 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{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "appsettings.json", Format: "json"}, + }, + "native:wpf-winforms:none": { + EnvValues: map[string]string{ + "Auth0:Domain": DetectionSub, + "Auth0:ClientId": DetectionSub, + "Auth0:ClientSecret": DetectionSub, + }, + RequestParams: RequestParams{ + AppType: "native", + Callbacks: []string{DetectionSub}, + AllowedLogoutURLs: []string{DetectionSub}, + Name: DetectionSub, + }, + Strategy: FileOutputStrategy{Path: "appsettings.json", Format: "json"}, + }, + + // ==========================================. + // 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..1f1a5e571 --- /dev/null +++ b/internal/cli/quickstart_detect.go @@ -0,0 +1,584 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// DetectionResult holds the values resolved by scanning the working directory. +// Fields are empty/zero when not detected. AmbiguousCandidates 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. + Detected bool // True if any signal file matched. + AmbiguousCandidates []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 + port int +} + +// 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. 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.Detected = true + return result + } + if hasDep(earlyDeps, "@ionic/react") { + result.Framework = "ionic-react" + result.Type = "native" + result.BuildTool = "vite" + result.Detected = true + return result + } + if hasDep(earlyDeps, "@ionic/vue") { + result.Framework = "ionic-vue" + result.Type = "native" + result.BuildTool = "vite" + result.Detected = true + return result + } + + // ── 2. Angular.json ────────────────────────────────────────────────────. + if fileExists(dir, "angular.json") { + result.Framework = "angular" + result.Type = "spa" + result.Port = 4200 + result.Detected = true + return result + } + + // ── 3. 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. + if dirExists(dir, "android") || dirExists(dir, "ios") { + result.Framework = "flutter" + result.Type = "native" + } else { + result.Framework = "flutter-web" + result.Type = "spa" + } + return result + } + } + + // ── 4. 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 + } + + // ── 5. 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.Detected = true + return result + } + + // ── 6. Vite.config.[ts|js] + package.json deps ──────────────────────────. + if fileExistsAny(dir, "vite.config.ts", "vite.config.js") { + result.Type = "spa" + result.BuildTool = "vite" + result.Port = 5173 + 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" + } + return result + } + + // ── 7. 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 = 3000 + result.Detected = true + return result + } + + // ── 8. Nuxt.config.[ts|js] ──────────────────────────────────────────────. + if fileExistsAny(dir, "nuxt.config.ts", "nuxt.config.js") { + result.Framework = "nuxt" + result.Type = "regular" + result.Port = 3000 + result.Detected = true + return result + } + + // ── 9. Svelte.config.[js|ts] ────────────────────────────────────────────. + if fileExistsAny(dir, "svelte.config.js", "svelte.config.ts") { + result.Framework = "sveltekit" + result.Type = "regular" + 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 + } + + // ── 11. .csproj ──────────────────────────────────────────────────────────. + if content, ok := findCsprojContent(dir); ok { + if fw, qsType, found := detectFromCsproj(content); found { + result.Framework = fw + result.Type = qsType + result.Detected = true + return result + } + } + + // ── 12. 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 + } + + // ── 13. Go.mod ──────────────────────────────────────────────────────────. + if fileExists(dir, "go.mod") { + result.Framework = "vanilla-go" + result.Type = "regular" + result.Detected = true + return result + } + + // ── 14. 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 + } + } + + // ── 15. Requirements.txt / pyproject.toml (Python / Flask) ──────────────. + for _, pyFile := range []string{"requirements.txt", "pyproject.toml"} { + if data, ok := readFileContent(dir, pyFile); ok { + if strings.Contains(strings.ToLower(data), "flask") { + result.Framework = "vanilla-python" + result.Type = "regular" + result.Port = 5000 + result.Detected = true + return result + } + } + } + + // ── 16. Package.json dep scanning (lowest priority) ─────────────────────. + // Note: Ionic deps are already handled above (step 1). + 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 = c.port + result.Detected = true + 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 := candidates[0].port + for _, c := range candidates { + if c.port != commonPort { + commonPort = 0 + break + } + } + result.Port = commonPort + for _, c := range candidates { + result.AmbiguousCandidates = append(result.AmbiguousCandidates, c.framework) + } + } + } + } + + return result +} + +// collectPackageJSONCandidates returns all framework candidates found in deps. +func collectPackageJSONCandidates(deps map[string]bool) []detectionCandidate { + var candidates []detectionCandidate + if hasDep(deps, "@ionic/angular") { + candidates = append(candidates, detectionCandidate{framework: "ionic-angular", qsType: "native"}) + } + if hasDep(deps, "@ionic/react") { + candidates = append(candidates, detectionCandidate{framework: "ionic-react", qsType: "native", buildTool: "vite"}) + } + if hasDep(deps, "@ionic/vue") { + candidates = append(candidates, detectionCandidate{framework: "ionic-vue", qsType: "native", buildTool: "vite"}) + } + // 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", port: 3000}) + } + if hasDep(deps, "hono") { + candidates = append(candidates, detectionCandidate{framework: "hono", qsType: "regular", port: 3000}) + } + if hasDep(deps, "fastify") { + candidates = append(candidates, detectionCandidate{framework: "fastify", qsType: "regular", port: 3000}) + } + 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") || + strings.Contains(content, "-android") || + strings.Contains(content, "-ios"): + return "maui", "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"): + 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 + } +} + +// Create-expo-app has generated app.json (not expo.json) since SDK 46 in 2022. +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 +} + +// dirExists returns true if the named entry in dir is a directory. +func dirExists(dir, name string) bool { + info, err := os.Stat(filepath.Join(dir, name)) + return err == nil && info.IsDir() +} + +// fileExists returns true if the named file exists in dir. +func fileExists(dir, name string) bool { + _, err := os.Stat(filepath.Join(dir, name)) + return err == nil +} + +// fileExistsAny returns true if any of the named files exist in dir. +func fileExistsAny(dir string, names ...string) bool { + for _, name := range names { + if fileExists(dir, name) { + return true + } + } + return false +} + +// readFileContent reads a file and returns its content as a string. +func readFileContent(dir, name string) (string, bool) { + data, err := os.ReadFile(filepath.Join(dir, name)) + 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 nil + } + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + 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 first value from pom.xml. +func readPomArtifactID(dir string) string { + data, ok := readFileContent(dir, "pom.xml") + if !ok { + return "" + } + 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]) +} + +// hasDep returns true if the named dependency is in the deps set. +func hasDep(deps map[string]bool, name string) bool { + return deps[name] +} + +// findCsprojContent finds the first .csproj file in dir and returns its content. +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, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil { + return string(data), true + } + } + } + return "", false +} + +// 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 +} + +// 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..97cff3b53 --- /dev/null +++ b/internal/cli/quickstart_detect_test.go @@ -0,0 +1,2285 @@ +package cli + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/auth0/go-auth0/management" + "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)) +} + +// ── 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() + writeTestFile(t, dir, "pubspec.yaml", "name: my_flutter_web\nflutter:\n sdk: flutter\n") + 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-web", got.Framework) + assert.Equal(t, "spa", 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.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.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.Equal(t, "nuxt", got.Framework) +} + +// 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) +} + +// Auth0 qs setup --app --type native --framework maui (.NET Android/iOS). +func TestDetectProject_MAUI_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, "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) +} + +// ── 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. +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.AmbiguousCandidates, 2) + assert.Contains(t, got.AmbiguousCandidates, "express") + assert.Contains(t, got.AmbiguousCandidates, "hono") +} + +// ── 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) +} + +// ── 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, + }, + { + name: "maui_android_target", + content: `net8.0-android`, + wantFw: "maui", + wantType: "native", + wantFound: true, + }, + { + name: "maui_ios_target", + content: `net8.0-ios`, + 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("ionic_angular", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"@ionic/angular": true}) + require.Len(t, got, 1) + assert.Equal(t, "ionic-angular", got[0].framework) + assert.Equal(t, "native", got[0].qsType) + assert.Empty(t, got[0].buildTool) + }) + + t.Run("ionic_react_has_vite_build_tool", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"@ionic/react": true}) + require.Len(t, got, 1) + assert.Equal(t, "ionic-react", got[0].framework) + assert.Equal(t, "native", got[0].qsType) + assert.Equal(t, "vite", got[0].buildTool) + }) + + t.Run("ionic_vue_has_vite_build_tool", func(t *testing.T) { + got := collectPackageJSONCandidates(map[string]bool{"@ionic/vue": true}) + require.Len(t, got, 1) + assert.Equal(t, "ionic-vue", got[0].framework) + assert.Equal(t, "vite", got[0].buildTool) + }) + + 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) + assert.Equal(t, 3000, got[0].port) + }) + + 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) + assert.Equal(t, 3000, got[0].port) + }) + + 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) + assert.Equal(t, 3000, got[0].port) + }) + + 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)) + }) +} + +// ── 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", 3000}, + {"rails", 3000}, + {"vanilla-go", 3000}, + {"django", 3000}, + // 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(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(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", + } + 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", + "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", + "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) + }) +} + +// ── 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, ` 1 { + // Multiple package.json deps matched — show partial summary and ask user to disambiguate. + cli.renderer.Infof("Detected in current directory") + cli.renderer.Infof("%-12s%s", "Framework", "Could not be determined") + cli.renderer.Infof("%-12s%s", "App type", detectionFriendlyAppType(detection.Type)) + cli.renderer.Infof("%-12s%s", "App name", detection.AppName) + if detection.Port > 0 { + cli.renderer.Infof("%-12s%d", "Port", detection.Port) + } + noInputMode := !canPrompt(cmd) + if noInputMode || prompt.ConfirmWithDefault("Do you want to proceed with the detected values?", true) { + if inputs.Type == "" { + inputs.Type = detection.Type + } + if inputs.Port == 0 { + inputs.Port = detection.Port + } + if inputs.Name == "" { + inputs.Name = detection.AppName + } + if inputs.Framework == "" { + q := prompt.SelectInput("framework", "Select your framework", "", + detection.AmbiguousCandidates, detection.AmbiguousCandidates[0], true) + if err := prompt.AskOne(q, &inputs.Framework); err != nil { + return fmt.Errorf("failed to select framework: %v", err) + } + } + } + } else if detection.Framework != "" { + // Single clear detection — show summary and confirm. + titleCaser := cases.Title(language.English) + frameworkDisplay := titleCaser.String(detection.Framework) + if detection.BuildTool != "" && detection.BuildTool != "none" { + frameworkDisplay += " \u00b7 " + titleCaser.String(detection.BuildTool) + } + cli.renderer.Infof("Detected in current directory") + cli.renderer.Infof("%-12s%s", "Framework", frameworkDisplay) + cli.renderer.Infof("%-12s%s", "App type", detectionFriendlyAppType(detection.Type)) + cli.renderer.Infof("%-12s%s", "App name", detection.AppName) + if detection.Port > 0 { + cli.renderer.Infof("%-12s%d", "Port", detection.Port) + } + + noInputModeSingle := !canPrompt(cmd) + if noInputModeSingle || prompt.ConfirmWithDefault("Do you want to proceed with the detected values?", true) { + if inputs.Type == "" { + inputs.Type = detection.Type + } + if inputs.Framework == "" { + inputs.Framework = detection.Framework + } + if inputs.BuildTool == "" || inputs.BuildTool == "none" { + inputs.BuildTool = detection.BuildTool + } + if inputs.Port == 0 { + inputs.Port = detection.Port + } + if inputs.Name == "" { + inputs.Name = detection.AppName + } + } + } + default: + // No detection signal found — notify the user and pre-fill name from directory. + 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 ───────────────. + qsConfigKey, updatedInputs, wasAutoSelected, err := getQuickstartConfigKey(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 !cmd.Flags().Changed("name") { + defaultName := inputs.Name + if defaultName == "" { + defaultName = "My App" + } + q := prompt.TextInput("name", "Application name", "Name for the Auth0 application", defaultName, true) + if err := prompt.AskOne(q, &inputs.Name); err != nil { + return fmt.Errorf("failed to enter application name: %v", err) + } + if inputs.Name == "" { + return fmt.Errorf("application name cannot be empty") + } + if !prompt.Confirm(fmt.Sprintf("Create application with name %q?", inputs.Name)) { + return fmt.Errorf("setup cancelled: no resources were created") + } + } + if inputs.Name == "" { + return fmt.Errorf("application name cannot be empty") + } + } + + // ── 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 == "" && !cmd.Flags().Changed("name") { + cwd, _ := os.Getwd() + defaultName := filepath.Base(cwd) + if defaultName == "" || defaultName == "." { + defaultName = "my-api" + } + q := prompt.TextInput("name", "Application Name", "Name for the Auth0 API", defaultName, true) + if err := prompt.AskOne(q, &inputs.Name); err != nil { + return fmt.Errorf("failed to enter application name: %v", err) + } + } + } + + if inputs.API { + // Prompt for the identifier if not explicitly provided via flag. + if !cmd.Flags().Changed("identifier") && !cmd.Flags().Changed("audience") { + // Compute a suggested default without pre-populating inputs.Identifier. + defaultID := inputs.Identifier + if defaultID == "" { + defaultID = inputs.Audience + } + if defaultID == "" && inputs.Name != "" { + slug := strings.ToLower(strings.ReplaceAll(inputs.Name, " ", "-")) + defaultID = "https://" + slug + } + q := prompt.TextInput( + "identifier", + "Enter API Identifier (audience URL)", + "A unique URL that identifies your API. Must be unique across your Auth0 tenant.", + defaultID, + true, + ) + if err := prompt.AskOne(q, &inputs.Identifier); err != nil { + return fmt.Errorf("failed to enter API identifier: %v", err) + } + // Confirm the API identifier (uniqueness reminder included in the prompt). + if !prompt.Confirm(fmt.Sprintf("Register API with identifier %q? (identifiers must be unique within your tenant)", inputs.Identifier)) { + return fmt.Errorf("setup cancelled: no resources were created") + } + } else if inputs.Identifier == "" { + inputs.Identifier = inputs.Audience + } + + // Prompt for signing algorithm if not provided via flag. + if inputs.SigningAlg == "" { + if canPrompt(cmd) { + signingAlgs := []string{"RS256", "PS256", "HS256"} + q := prompt.SelectInput("signing-alg", "Select the signing algorithm", "", signingAlgs, "RS256", true) + if err := prompt.AskOne(q, &inputs.SigningAlg); err != nil { + return fmt.Errorf("failed to select signing algorithm: %v", err) + } + } else { + inputs.SigningAlg = "RS256" + } + } + + // Inputs.TokenLifetime already has "86400" from flag default; only prompt interactively. + if !cmd.Flags().Changed("token-lifetime") && canPrompt(cmd) { + defaultLifetime := "86400" + q := prompt.TextInput("token-lifetime", "Access token lifetime (seconds)", "How long access tokens remain valid (default: 86400 = 24 hours)", defaultLifetime, true) + if err := prompt.AskOne(q, &inputs.TokenLifetime); err != nil { + return fmt.Errorf("failed to enter token lifetime: %v", err) + } + } + + // 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 + } + + 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: %v", err) + } + if selectedAppName != "Skip" { + linkedAppClientID = appIDByName[selectedAppName] + } + } + } + + // ── Step 4: Create the Auth0 application client ───────────────────. + if inputs.App { + config, exists := auth0.QuickstartConfigs[qsConfigKey] + if !exists { + return fmt.Errorf("unsupported quickstart arguments: %s. Supported types: %v", qsConfigKey, getSupportedQuickstartTypes()) + } + + 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) + } + + tenant, err := cli.Config.GetTenant(cli.tenant) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + envFileName, _, err := GenerateAndWriteQuickstartConfig(&config.Strategy, config.EnvValues, tenant.Domain, client, inputs.Port) + if err != nil { + return fmt.Errorf("failed to generate config file: %w", err) + } + printClientDetails(cli, client, inputs.Port, envFileName) + + // Track the created app's client ID so we can link it to the API below. + linkedAppClientID = client.GetClientID() + } + + // ── Step 5: Create the Auth0 API resource server ──────────────────. + if inputs.API { + // API name = "-API", fallback to identifier. + apiName := inputs.Identifier + if inputs.Name != "" { + apiName = inputs.Name + "-API" + } + + fmt.Printf("Creating API resource server %q with identifier %q...\n", 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 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 + }, + } + + // App flags. + cmd.Flags().BoolVar(&inputs.App, "app", false, "Create an Auth0 application (SPA, regular web, or native)") + cmd.Flags().StringVar(&inputs.Name, "name", "", "Name of the Auth0 application") + cmd.Flags().StringVar(&inputs.Type, "type", "", "Application type: spa, regular, or native") + cmd.Flags().StringVar(&inputs.Framework, "framework", "", "Framework to configure (e.g., react, nextjs, vue, express)") + cmd.Flags().StringVar(&inputs.BuildTool, "build-tool", "none", "Build tool used by the project (vite, webpack, cra, none)") + cmd.Flags().IntVar(&inputs.Port, "port", 0, "Local port the application runs on (default varies by framework, e.g. 3000, 5173)") + cmd.Flags().StringVar(&inputs.CallbackURL, "callback-url", "", "Override the allowed callback URL for the application") + cmd.Flags().StringVar(&inputs.LogoutURL, "logout-url", "", "Override the allowed logout URL for the application") + cmd.Flags().StringVar(&inputs.WebOriginURL, "web-origin-url", "", "Override the allowed web origin URL for the application") + + // API flags. + cmd.Flags().BoolVar(&inputs.API, "api", false, "Create an Auth0 API resource server") + cmd.Flags().StringVar(&inputs.Identifier, "identifier", "", "Unique URL identifier for the API (audience), e.g. https://my-api") + cmd.Flags().StringVar(&inputs.Audience, "audience", "", "Alias for --identifier (unique audience URL for the API)") + cmd.Flags().StringVar(&inputs.SigningAlg, "signing-alg", "", "Token signing algorithm: RS256, PS256, or HS256 (leave blank to be prompted interactively)") + cmd.Flags().StringVar(&inputs.Scopes, "scopes", "", "Comma-separated list of permission scopes for the API") + cmd.Flags().StringVar(&inputs.TokenLifetime, "token-lifetime", "86400", "Access token lifetime in seconds (default: 86400 = 24 hours)") + cmd.Flags().BoolVar(&inputs.OfflineAccess, "offline-access", false, "Allow offline access (enables refresh tokens)") + + 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", client.GetClientID()) + cli.renderer.Newline() + + cli.renderer.Successf("You can manage your application from here:") + cli.renderer.Detailf("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", 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", strings.Join(client.GetAllowedLogoutURLs(), ", ")) + cli.renderer.Newline() + } + cli.renderer.Successf("Config file created: %s", configFileLocation) +} + +func printAPIDetails(cli *cli, rs *management.ResourceServer) { + cli.renderer.Successf("An API application %q has been created and registered", rs.GetName()) + cli.renderer.Newline() + cli.renderer.Successf("You can manage your API from here:") + cli.renderer.Detailf("https://manage.auth0.com/dashboard/#/apis/%s/settings", rs.GetID()) +} + +// Helper function to get supported quickstart types. +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(inputs SetupInputs) (string, SetupInputs, bool, error) { + // Handle application creation inputs. + if inputs.App { + // Validate --type if provided (Bug 12). + validTypes := []string{"spa", "regular", "native", "m2m"} + if inputs.Type != "" { + valid := false + for _, t := range validTypes { + if inputs.Type == t { + valid = true + break + } + } + if !valid { + 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 == "" { + q := prompt.SelectInput("type", "Select the application type", "", validTypes, "spa", true) + if err := prompt.AskOne(q, &inputs.Type); err != nil { + return "", inputs, false, fmt.Errorf("failed to select application type: %v", err) + } + } + + // M2M apps have no framework, port, or callback URLs (Bug 6). + if inputs.Type == "m2m" { + return "m2m:none:none", 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) + } + q := prompt.SelectInput("framework", "Select the framework", "", frameworks, frameworks[0], true) + if err := prompt.AskOne(q, &inputs.Framework); err != nil { + return "", inputs, false, fmt.Errorf("failed to select framework: %v", err) + } + } + + // Resolve port from framework default before prompting (Bug 11). + // The spec says "--port: default value used if not given", so we never prompt. + if inputs.Port == 0 { + inputs.Port = defaultPortForFramework(inputs.Framework) + // Port stays 0 for native apps (react-native, expo, flutter) — no port needed. + } + } + + // Config key is only meaningful when an app is being created. + if !inputs.App { + return "", inputs, false, nil + } + + // Fallback to "none" if build tool wasn't asked/selected to match the config map keys. + buildToolKey := inputs.BuildTool + if buildToolKey == "" { + buildToolKey = "none" + } + + configKey := fmt.Sprintf("%s:%s:%s", inputs.Type, inputs.Framework, buildToolKey) + + // When build tool is "none" and no exact match exists, find the first available config + // for this type+framework combination (e.g. spa:react only has a :vite variant). + wasAutoSelected := false + if _, exists := auth0.QuickstartConfigs[configKey]; !exists && buildToolKey == "none" { + prefix := fmt.Sprintf("%s:%s:", inputs.Type, inputs.Framework) + var candidates []string + for k := range auth0.QuickstartConfigs { + if strings.HasPrefix(k, prefix) { + candidates = append(candidates, k) + } + } + if len(candidates) > 0 { + // Sort by priority (vite > webpack > cra > others alphabetically) so modern + // build tools are preferred over legacy ones. + buildToolPriority := map[string]int{"vite": 0, "webpack": 1, "cra": 2} + sort.Slice(candidates, func(i, j int) bool { + pi, pj := len(buildToolPriority)+1, len(buildToolPriority)+1 + if parts := strings.SplitN(candidates[i], ":", 3); len(parts) == 3 { + if p, ok := buildToolPriority[parts[2]]; ok { + pi = p + } + } + if parts := strings.SplitN(candidates[j], ":", 3); len(parts) == 3 { + if p, ok := buildToolPriority[parts[2]]; ok { + pj = p + } + } + if pi != pj { + return pi < pj + } + return candidates[i] < candidates[j] + }) + configKey = candidates[0] + // Update inputs.BuildTool so the caller can notify the user of the auto-selection. + parts := strings.SplitN(configKey, ":", 3) + if len(parts) == 3 { + inputs.BuildTool = parts[2] + } + wasAutoSelected = true + } + } + + return configKey, inputs, wasAutoSelected, nil +} + +// defaultPortForFramework returns the conventional port for a given framework name. +func defaultPortForFramework(framework string) int { + switch framework { + case "react", "vue", "svelte", "vanilla-javascript": + return 5173 // Vite default. + case "angular": + return 4200 + case "flask", "vanilla-python": + return 5000 + case "laravel": + return 8000 + case "spring-boot", "java-ee", "vanilla-java": + return 8080 + default: + return 3000 + } +} + +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 (Bug 7). + 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 + } + for i, cb := range callbacks { + if cb == auth0.DetectionSub { + callbacks[i] = baseURL + "/callback" + } + } + 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, + } +} + +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 { + updatedEnvValues[key] = value + continue + } + + switch key { + case "VITE_AUTH0_DOMAIN", "AUTH0_DOMAIN", "domain", "NUXT_AUTH0_DOMAIN", + "auth0.domain", "Auth0:Domain", "auth0:Domain", "auth0_domain", + "EXPO_PUBLIC_AUTH0_DOMAIN": + updatedEnvValues[key] = tenantDomain + + // Express SDK specifically requires the https:// prefix. + case "ISSUER_BASE_URL": + updatedEnvValues[key] = "https://" + tenantDomain + + // Spring Boot okta issuer specifically requires https:// and a trailing slash. + case "okta.oauth2.issuer": + updatedEnvValues[key] = "https://" + tenantDomain + "/" + + case "VITE_AUTH0_CLIENT_ID", "AUTH0_CLIENT_ID", "clientId", "NUXT_AUTH0_CLIENT_ID", + "CLIENT_ID", "auth0.clientId", "okta.oauth2.client-id", "Auth0:ClientId", + "auth0:ClientId", "auth0_client_id", "EXPO_PUBLIC_AUTH0_CLIENT_ID": + updatedEnvValues[key] = client.GetClientID() + + case "AUTH0_CLIENT_SECRET", "NUXT_AUTH0_CLIENT_SECRET", "auth0.clientSecret", + "okta.oauth2.client-secret", "Auth0:ClientSecret", "auth0:ClientSecret", + "auth0_client_secret": + updatedEnvValues[key] = client.GetClientSecret() + + 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 nil, fmt.Errorf("failed to generate secret for %s: %w", key, err) + } + updatedEnvValues[key] = secret + + case "APP_BASE_URL", "NUXT_AUTH0_APP_BASE_URL", "BASE_URL": + updatedEnvValues[key] = baseURL + + case "AUTH0_REDIRECT_URI", "AUTH0_CALLBACK_URL": + updatedEnvValues[key] = baseURL + "/callback" + + default: + updatedEnvValues[key] = value + } + } + + return updatedEnvValues, nil +} + +// 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 { + current[part] = value + } else { + if _, exists := current[part]; !exists { + current[part] = make(map[string]interface{}) + } + current = current[part].(map[string]interface{}) + } + } + } + return result +} + +// sortedKeys returns the keys of a map in sorted order. +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 generated file name, 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, string, error) { + // 1. Resolve the environment variables. + resolvedEnv, err := replaceDetectionSub(envValues, tenantDomain, client, port) + if err != nil { + return "", "", err + } + + // 2. Determine output file path and format. + if strategy == nil { + strategy = &auth0.FileOutputStrategy{Path: ".env", Format: "dotenv"} + } + + // 3. Ensure the directory path exists. + 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) + } + } + + // 4. Format the file content based on the target framework's requirement. + var contentBuilder strings.Builder + + switch strategy.Format { + case "dotenv", "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 "ts": + contentBuilder.WriteString("export const environment = {\n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" %s: '%s',\n", key, resolvedEnv[key])) + } + contentBuilder.WriteString("};\n") + + case "dart": + contentBuilder.WriteString("const Map authConfig = {\n") + for _, key := range sortedKeys(resolvedEnv) { + contentBuilder.WriteString(fmt.Sprintf(" '%s': '%s',\n", key, 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 { + 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", key, resolvedEnv[key])) + } + contentBuilder.WriteString(" \n") + contentBuilder.WriteString("\n") + } + + // 5. Write the generated content to disk. + 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) + } + + // 6. Return the base file name and full path. + fileName := filepath.Base(strategy.Path) + return fileName, strategy.Path, nil +} diff --git a/internal/display/display.go b/internal/display/display.go index 88327fb50..deb8f9db0 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -65,6 +65,15 @@ func (r *Renderer) Infof(format string, a ...interface{}) { fmt.Fprintf(r.MessageWriter, format+"\n", a...) } +func (r *Renderer) Successf(format string, a ...interface{}) { + fmt.Fprint(r.MessageWriter, ansi.Green("✓ ")) + fmt.Fprintf(r.MessageWriter, format+"\n", a...) +} + +func (r *Renderer) Detailf(format string, a ...interface{}) { + fmt.Fprintf(r.MessageWriter, " "+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..864d0a73a 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -59,6 +59,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,