diff --git a/docs/auth0_acul.md b/docs/auth0_acul.md index 00bc2bf03..c45bb9e92 100644 --- a/docs/auth0_acul.md +++ b/docs/auth0_acul.md @@ -10,6 +10,7 @@ Customize the Universal Login experience. This requires a custom domain to be co ## Commands - [auth0 acul config](auth0_acul_config.md) - Configure Advanced Customizations for Universal Login screens. +- [auth0 acul dev](auth0_acul_dev.md) - Start development mode for ACUL project with automatic building and asset watching. - [auth0 acul init](auth0_acul_init.md) - Generate a new ACUL project from a template - [auth0 acul screen](auth0_acul_screen.md) - Manage individual screens for Advanced Customizations for Universal Login. diff --git a/docs/auth0_acul_config_list.md b/docs/auth0_acul_config_list.md index 6eeab9b89..06c2387ca 100644 --- a/docs/auth0_acul_config_list.md +++ b/docs/auth0_acul_config_list.md @@ -24,7 +24,7 @@ auth0 acul config list [flags] ``` --fields string Comma-separated list of fields to include or exclude in the result (based on value provided for include_fields) - --include-fields Whether specified fields are to be included (default: true) or excluded (false). (default true) + --include-fields Whether specified fields are to be included (true) or excluded (false). (default true) --include-totals Return results inside an object that contains the total result count (true) or as a direct array of results (false). --json Output in json format. --json-compact Output in compact json format. diff --git a/docs/auth0_acul_dev.md b/docs/auth0_acul_dev.md new file mode 100644 index 000000000..889b57d8d --- /dev/null +++ b/docs/auth0_acul_dev.md @@ -0,0 +1,78 @@ +--- +layout: default +parent: auth0 acul +has_toc: false +--- +# auth0 acul dev + +Start development mode for an ACUL project. This command: +- Runs 'npm run build' to build the project initially +- Watches the dist directory for asset changes +- Automatically patches screen assets when new builds are created +- Supports both single screen development and all screens + +The project directory must contain package.json with a build script. + +DEV MODE (default): +- Requires: --port flag for the local development server +- Runs your build process (e.g., npm run screen ) for HMR development + +CONNECTED MODE (--connected): +- Requires: --screen flag to specify screens to patch in Auth0 tenant +- Updates advance rendering settings of the chosen screens in your Auth0 tenant +- Runs initial build and expects you to host assets locally +- Optionally runs build:watch in the background for continuous asset updates +- Watches and patches assets automatically when changes are detected + +⚠️ Connected mode should only be used on stage/dev tenants, not production! + +## Usage +``` +auth0 acul dev [flags] +``` + +## Examples + +``` + # Dev mode + auth0 acul dev --port 3000 + auth0 acul dev -p 8080 --dir ./my_project + + # Connected mode + auth0 acul dev --connected + auth0 acul dev --connected --debug --dir ./my_project + auth0 acul dev --connected --screen all + auth0 acul dev -c --dir ./my_project + auth0 acul dev --connected --screen login-id + auth0 acul dev -c -s login-id,signup +``` + + +## Flags + +``` + -c, --connected Enable connected mode to update advance rendering settings of Auth0 tenant. Use only on stage/dev tenants. + -d, --dir string Path to the ACUL project directory (must contain package.json). + -p, --port string Port for the local development server. + -s, --screen strings Specific screens to develop and watch. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 acul config](auth0_acul_config.md) - Configure Advanced Customizations for Universal Login screens. +- [auth0 acul dev](auth0_acul_dev.md) - Start development mode for ACUL project with automatic building and asset watching. +- [auth0 acul init](auth0_acul_init.md) - Generate a new ACUL project from a template +- [auth0 acul screen](auth0_acul_screen.md) - Manage individual screens for Advanced Customizations for Universal Login. + + diff --git a/docs/auth0_acul_init.md b/docs/auth0_acul_init.md index 18a682162..875260f42 100644 --- a/docs/auth0_acul_init.md +++ b/docs/auth0_acul_init.md @@ -45,6 +45,7 @@ auth0 acul init [flags] ## Related Commands - [auth0 acul config](auth0_acul_config.md) - Configure Advanced Customizations for Universal Login screens. +- [auth0 acul dev](auth0_acul_dev.md) - Start development mode for ACUL project with automatic building and asset watching. - [auth0 acul init](auth0_acul_init.md) - Generate a new ACUL project from a template - [auth0 acul screen](auth0_acul_screen.md) - Manage individual screens for Advanced Customizations for Universal Login. diff --git a/internal/auth0/branding_prompt.go b/internal/auth0/branding_prompt.go index 8a6bafc1a..dc424c52a 100644 --- a/internal/auth0/branding_prompt.go +++ b/internal/auth0/branding_prompt.go @@ -49,6 +49,11 @@ type PromptAPI interface { // See: https://auth0.com/docs/api/management/v2/prompts/patch-rendering UpdateRendering(ctx context.Context, prompt management.PromptType, screen management.ScreenName, c *management.PromptRendering, opts ...management.RequestOption) error + // BulkUpdateRendering updates multiple rendering settings in a single operation. + // + // See: https://auth0.com/docs/api/management/v2/prompts/patch-bulk-rendering + BulkUpdateRendering(ctx context.Context, c *management.PromptRenderingBulkUpdate, opts ...management.RequestOption) error + // ListRendering retrieves the settings for the ACUL. // ListRendering(ctx context.Context, opts ...management.RequestOption) (c *management.PromptRenderingList, err error) diff --git a/internal/auth0/mock/branding_prompt_mock.go b/internal/auth0/mock/branding_prompt_mock.go index 08a4754c5..a1c67d76d 100644 --- a/internal/auth0/mock/branding_prompt_mock.go +++ b/internal/auth0/mock/branding_prompt_mock.go @@ -35,6 +35,25 @@ func (m *MockPromptAPI) EXPECT() *MockPromptAPIMockRecorder { return m.recorder } +// BulkUpdateRendering mocks base method. +func (m *MockPromptAPI) BulkUpdateRendering(ctx context.Context, c *management.PromptRenderingBulkUpdate, opts ...management.RequestOption) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, c} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "BulkUpdateRendering", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// BulkUpdateRendering indicates an expected call of BulkUpdateRendering. +func (mr *MockPromptAPIMockRecorder) BulkUpdateRendering(ctx, c interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, c}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkUpdateRendering", reflect.TypeOf((*MockPromptAPI)(nil).BulkUpdateRendering), varargs...) +} + // CustomText mocks base method. func (m *MockPromptAPI) CustomText(ctx context.Context, p, l string, opts ...management.RequestOption) (map[string]interface{}, error) { m.ctrl.T.Helper() diff --git a/internal/cli/acul.go b/internal/cli/acul.go index dee5f132b..c562657bf 100644 --- a/internal/cli/acul.go +++ b/internal/cli/acul.go @@ -18,6 +18,7 @@ func aculCmd(cli *cli) *cobra.Command { cmd.AddCommand(aculConfigureCmd(cli)) cmd.AddCommand(aculInitCmd(cli)) cmd.AddCommand(aculScreenCmd(cli)) + cmd.AddCommand(aculDevCmd(cli)) return cmd } diff --git a/internal/cli/acul_app_scaffolding.go b/internal/cli/acul_app_scaffolding.go index bde1e42fd..a0cc093c6 100644 --- a/internal/cli/acul_app_scaffolding.go +++ b/internal/cli/acul_app_scaffolding.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "regexp" + "slices" "strconv" "strings" "time" @@ -36,10 +37,11 @@ type Template struct { } type Screens struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Path string `json:"path"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Path string `json:"path"` + ExtraFiles []string `json:"extra_files"` } type Metadata struct { @@ -109,8 +111,7 @@ func getLatestReleaseTag() (string, error) { return "", fmt.Errorf("no tags found in repository") } - // TODO: return tags[0].Name, nil. - return "monorepo-sample", nil + return tags[0].Name, nil } var ( @@ -187,7 +188,12 @@ func runScaffold(cli *cli, cmd *cobra.Command, args []string, inputs *struct { return err } - selectedScreens, err := validateAndSelectScreens(cli, manifest.Templates[chosenTemplate].Screens, inputs.Screens) + var availableScreenIDs []string + for _, s := range manifest.Templates[chosenTemplate].Screens { + availableScreenIDs = append(availableScreenIDs, s.ID) + } + + selectedScreens, err := validateAndSelectScreens(cli, availableScreenIDs, inputs.Screens, true) if err != nil { return err } @@ -267,78 +273,81 @@ func selectTemplate(cmd *cobra.Command, manifest *Manifest, providedTemplate str return nameToKey[chosenTemplateName], nil } -func validateAndSelectScreens(cli *cli, screens []Screens, providedScreens []string) ([]string, error) { - var availableScreenIDs []string - for _, s := range screens { - availableScreenIDs = append(availableScreenIDs, s.ID) +// ValidateAndSelectScreens validates provided screens or prompts for selection. +func validateAndSelectScreens(cli *cli, screenIDs, providedScreens []string, multiSelect bool) ([]string, error) { + if len(screenIDs) == 0 { + return nil, fmt.Errorf("no available screens found") } - if len(providedScreens) > 0 { - var validScreens []string - var invalidScreens []string - - for _, providedScreen := range providedScreens { - if strings.TrimSpace(providedScreen) == "" { - continue - } + var valid []string + var invalid []string - found := false - for _, availableScreen := range availableScreenIDs { - if providedScreen == availableScreen { - validScreens = append(validScreens, providedScreen) - found = true - break - } - } - if !found { - invalidScreens = append(invalidScreens, providedScreen) - } + for _, s := range providedScreens { + screen := strings.TrimSpace(s) + if screen == "" { + continue + } + if slices.Contains(screenIDs, screen) { + valid = append(valid, screen) + } else { + invalid = append(invalid, screen) } + } - if len(invalidScreens) > 0 { - cli.renderer.Warnf("⚠️ The following screens are not supported for the chosen template: %s", - ansi.Bold(ansi.Red(strings.Join(invalidScreens, ", ")))) - cli.renderer.Infof("Available screens: %s", - ansi.Bold(ansi.Cyan(strings.Join(availableScreenIDs, ", ")))) - cli.renderer.Infof("%s We're planning to support all screens in the future.", - ansi.Blue("Note:")) + // If user provided screens. + if len(providedScreens) > 0 { + if len(invalid) > 0 { + cli.renderer.Warnf("Unsupported screens: %s", ansi.Red(strings.Join(invalid, ", "))) } - if len(validScreens) == 0 { - cli.renderer.Warnf("%s %s", - ansi.Bold(ansi.Yellow("⚠️")), - ansi.Bold("None of the provided screens are valid for this template.")) - } else { - return validScreens, nil + if len(valid) > 0 { + // If single-select, only return the first match. + if !multiSelect { + return []string{valid[0]}, nil + } + return valid, nil } + + cli.renderer.Warnf("No valid screen(s) found. Please select from the available options.") } - // If no screens provided via flag or no valid screens, prompt for multi-select. - var selectedScreens []string - err := prompt.AskMultiSelect("Select screens to include:", &selectedScreens, availableScreenIDs...) + // No valid provided screens — fall back to interactive selection. + if multiSelect { + var selected []string + if err := prompt.AskMultiSelect("Select screens:", &selected, screenIDs...); err != nil { + return nil, err + } + if len(selected) == 0 { + return nil, fmt.Errorf("at least one screen must be selected") + } + return selected, nil + } - if len(selectedScreens) == 0 { - return nil, fmt.Errorf("at least one screen must be selected") + // Single select. + var selected string + q := prompt.SelectInput("screen", "Select a screen:", "", screenIDs, screenIDs[0], true) + if err := prompt.AskOne(q, &selected); err != nil { + return nil, err } - return selectedScreens, err + return []string{selected}, nil } func getDestDir(args []string) string { if len(args) < 1 { return "acul-sample-app" } + return args[0] } func downloadAndUnzipSampleRepo() (string, error) { - _, err := getLatestReleaseTag() + latestTag, err := getLatestReleaseTag() if err != nil { return "", fmt.Errorf("failed to get latest release tag: %w", err) } - // TODO: repoURL := fmt.Sprintf("https://github.com/auth0-samples/auth0-acul-samples/archive/refs/tags/%s.zip", latestTag). - repoURL := "https://github.com/auth0-samples/auth0-acul-samples/archive/refs/heads/monorepo-sample.zip" + repoURL := fmt.Sprintf("https://github.com/auth0-samples/auth0-acul-samples/archive/refs/tags/%s.zip", latestTag) tempZipFile, err := downloadFile(repoURL) if err != nil { return "", fmt.Errorf("error downloading sample repo: %w", err) @@ -462,6 +471,11 @@ func copyProjectScreens(cli *cli, screens []Screens, selectedScreens []string, c if err := copyDir(srcPath, destPath); err != nil { return fmt.Errorf("error copying screen directory %s: %w", screen.Path, err) } + + err = copyProjectTemplateFiles(cli, screen.ExtraFiles, chosenTemplate, tempUnzipDir, destDir) + if err != nil { + return err + } } return nil @@ -582,41 +596,49 @@ func createScreenMap(screens []Screens) map[string]Screens { // success message, documentation, Node version check, next steps, and available commands. func showPostScaffoldingOutput(cli *cli, destDir, successMessage string) { cli.renderer.Output("") - cli.renderer.Infof("%s %s in %s!", - ansi.Bold(ansi.Green("🎉")), successMessage, ansi.Bold(ansi.Cyan(fmt.Sprintf("'%s'", destDir)))) + cli.renderer.Infof("🎉 %s in %s!", + successMessage, ansi.Bold(ansi.Cyan(fmt.Sprintf("'%s'", destDir)))) cli.renderer.Output("") cli.renderer.Infof("📖 Explore the sample app: %s", ansi.Blue("https://github.com/auth0-samples/auth0-acul-samples")) cli.renderer.Output("") + // Show next steps and related commands. + fmt.Println() + fmt.Println(ansi.Bold("Next Steps:")) + fmt.Printf(" Navigate to %s\n", ansi.Cyan(destDir)) + fmt.Printf(" Run %s if dependencies are not installed\n", ansi.Cyan("npm install")) + fmt.Printf(" Start the local dev server using %s\n", ansi.Cyan("auth0 acul dev")) + + printAvailableCommands() checkNodeVersion(cli) +} - // Show next steps and related commands. - cli.renderer.Infof("%s Next Steps: Navigate to %s and run:", ansi.Bold("🚀"), ansi.Bold(ansi.Cyan(destDir))) - cli.renderer.Infof(" %s if not yet installed", ansi.Bold(ansi.Cyan("npm install"))) - cli.renderer.Infof(" %s", ansi.Bold(ansi.Cyan("auth0 acul dev"))) - cli.renderer.Output("") +func printAvailableCommands() { + fmt.Println() + fmt.Println(ansi.Bold("📋 Available Commands:")) + fmt.Println() - fmt.Printf("%s Available Commands:\n", ansi.Bold("📋")) - fmt.Printf(" %s - Add authentication screens\n", + fmt.Printf(" %s - Add authentication screens\n", ansi.Bold(ansi.Green("auth0 acul screen add "))) - fmt.Printf(" %s - Local development with hot-reload\n", + fmt.Printf(" %s - Local development with hot-reload\n", ansi.Bold(ansi.Green("auth0 acul dev"))) - fmt.Printf(" %s - Live sync changes to Auth0 tenant\n", + fmt.Printf(" %s - Live sync changes to Auth0 tenant\n", ansi.Bold(ansi.Green("auth0 acul dev --connected"))) - fmt.Printf(" %s - Create starter config template\n", + fmt.Printf(" %s - Create starter config template\n", ansi.Bold(ansi.Green("auth0 acul config generate "))) - fmt.Printf(" %s - Pull current Auth0 settings\n", + fmt.Printf(" %s - Pull current Auth0 settings\n", ansi.Bold(ansi.Green("auth0 acul config get "))) - fmt.Printf(" %s - Push local config to Auth0\n", + fmt.Printf(" %s - Push local config to Auth0\n", ansi.Bold(ansi.Green("auth0 acul config set "))) - fmt.Printf(" %s - List all configurable screens\n", + fmt.Printf(" %s - List all configurable screens\n", ansi.Bold(ansi.Green("auth0 acul config list"))) - fmt.Println() - fmt.Printf("%s %s: Use %s to see all available commands\n", - ansi.Bold("💡"), ansi.Bold("Tip"), ansi.Bold(ansi.Cyan("'auth0 acul --help'"))) + fmt.Println() + fmt.Printf("%s Use %s to see all available commands\n", + ansi.Yellow("💡 Tip:"), ansi.Cyan("auth0 acul --help")) + fmt.Println() } type AculConfig struct { @@ -650,7 +672,7 @@ func checkNodeVersion(cli *cli) { re := regexp.MustCompile(`v?(\d+)\.`) matches := re.FindStringSubmatch(version) if len(matches) < 2 { - cli.renderer.Warnf("Unable to parse Node version: %s. Please ensure Node v22+ is installed.", version) + cli.renderer.Warnf(ansi.Yellow(fmt.Sprintf("Unable to parse Node version: %s. Please ensure Node v22+ is installed.", version))) return } diff --git a/internal/cli/acul_config.go b/internal/cli/acul_config.go index d20f61a94..268827f52 100644 --- a/internal/cli/acul_config.go +++ b/internal/cli/acul_config.go @@ -48,7 +48,7 @@ var ( includeFieldsFlag = Flag{ Name: "Include Fields", LongForm: "include-fields", - Help: "Whether specified fields are to be included (default: true) or excluded (false).", + Help: "Whether specified fields are to be included (true) or excluded (false).", IsRequired: false, } includeTotalsFlag = Flag{ @@ -217,15 +217,13 @@ func aculConfigGenerateCmd(cli *cli) *cobra.Command { return err } - if len(args) == 0 { - cli.renderer.Output(ansi.Yellow("🔍 Type any part of the screen name (e.g., 'login', 'mfa') to filter results.")) - if err := screenName.Select(cmd, &input.screenName, utils.FetchKeys(ScreenPromptMap), nil); err != nil { - return handleInputError(err) - } - } else { - input.screenName = args[0] + screens, err := validateAndSelectScreens(cli, utils.FetchKeys(ScreenPromptMap), args, false) + if err != nil { + return err } + input.screenName = screens[0] + if err := ensureConfigFilePath(&input, cli); err != nil { return err } @@ -275,15 +273,13 @@ func aculConfigGetCmd(cli *cli) *cobra.Command { return err } - if len(args) == 0 { - cli.renderer.Output(ansi.Yellow("🔍 Type any part of the screen name (e.g., 'login', 'mfa') to filter options.")) - if err := screenName.Select(cmd, &input.screenName, utils.FetchKeys(ScreenPromptMap), nil); err != nil { - return handleInputError(err) - } - } else { - input.screenName = args[0] + screens, err := validateAndSelectScreens(cli, utils.FetchKeys(ScreenPromptMap), args, false) + if err != nil { + return err } + input.screenName = screens[0] + existingRenderSettings, err := cli.api.Prompt.ReadRendering(ctx, management.PromptType(ScreenPromptMap[input.screenName]), management.ScreenName(input.screenName)) if err != nil { return fmt.Errorf("failed to fetch the existing render settings: %w", err) @@ -314,7 +310,7 @@ func aculConfigGetCmd(cli *cli) *cobra.Command { } cli.renderer.Infof("Configuration downloaded and saved at '%s'.", ansi.Green(input.filePath)) - cli.renderer.Output(ansi.Yellow("💡 Tip: Use `auth0 acul config set` to sync local config to remote, or `auth0 acul config list` to view all ACUL screens.")) + cli.renderer.Output(ansi.Yellow("💡 Tip: Use `auth0 acul config set` to sync local config to remote or `auth0 acul config list` to view all ACUL screens.")) return nil }, } @@ -355,15 +351,13 @@ func aculConfigSetCmd(cli *cli) *cobra.Command { return err } - if len(args) == 0 { - cli.renderer.Output(ansi.Yellow("🔍 Type any part of the screen name to filter options.")) - if err := screenName.Select(cmd, &input.screenName, utils.FetchKeys(ScreenPromptMap), nil); err != nil { - return handleInputError(err) - } - } else { - input.screenName = args[0] + screens, err := validateAndSelectScreens(cli, utils.FetchKeys(ScreenPromptMap), args, false) + if err != nil { + return err } + input.screenName = screens[0] + cli.renderer.Output(ansi.Yellow("📖 Customization Guide: https://github.com/auth0/auth0-cli/blob/main/CUSTOMIZATION_GUIDE.md")) return advanceCustomize(cmd, cli, input) diff --git a/internal/cli/acul_dev.go b/internal/cli/acul_dev.go new file mode 100644 index 000000000..9768762b7 --- /dev/null +++ b/internal/cli/acul_dev.go @@ -0,0 +1,839 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "reflect" + "strconv" + "strings" + "syscall" + "time" + + "github.com/auth0/go-auth0/management" + "github.com/fsnotify/fsnotify" + "github.com/pkg/browser" + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/prompt" +) + +var ( + projectDirFlag = Flag{ + Name: "Project Directory", + LongForm: "dir", + ShortForm: "d", + Help: "Path to the ACUL project directory (must contain package.json).", + IsRequired: false, + } + screenDevFlag = Flag{ + Name: "Screens", + LongForm: "screen", + ShortForm: "s", + Help: "Specific screens to develop and watch.", + IsRequired: false, + AlwaysPrompt: false, + } + + portFlag = Flag{ + Name: "Port", + LongForm: "port", + ShortForm: "p", + Help: "Port for the local development server.", + IsRequired: false, + } + + connectedFlag = Flag{ + Name: "Connected", + LongForm: "connected", + ShortForm: "c", + Help: "Enable connected mode to update advance rendering settings of Auth0 tenant. Use only on stage/dev tenants.", + IsRequired: false, + } +) + +func aculDevCmd(cli *cli) *cobra.Command { + var projectDir, port string + var screenDirs []string + var connected bool + + cmd := &cobra.Command{ + Use: "dev", + Short: "Start development mode for ACUL project with automatic building and asset watching.", + Long: `Start development mode for an ACUL project. This command: +- Runs 'npm run build' to build the project initially +- Watches the dist directory for asset changes +- Automatically patches screen assets when new builds are created +- Supports both single screen development and all screens + +The project directory must contain package.json with a build script. + +DEV MODE (default): +- Requires: --port flag for the local development server +- Runs your build process (e.g., npm run screen ) for HMR development + +CONNECTED MODE (--connected): +- Requires: --screen flag to specify screens to patch in Auth0 tenant +- Updates advance rendering settings of the chosen screens in your Auth0 tenant +- Runs initial build and expects you to host assets locally +- Optionally runs build:watch in the background for continuous asset updates +- Watches and patches assets automatically when changes are detected + +⚠️ Connected mode should only be used on stage/dev tenants, not production!`, + Example: ` # Dev mode + auth0 acul dev --port 3000 + auth0 acul dev -p 8080 --dir ./my_project + + # Connected mode + auth0 acul dev --connected + auth0 acul dev --connected --debug --dir ./my_project + auth0 acul dev --connected --screen all + auth0 acul dev -c --dir ./my_project + auth0 acul dev --connected --screen login-id + auth0 acul dev -c -s login-id,signup`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureACULPrerequisites(cmd.Context(), cli.api); err != nil { + return err + } + + pwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %v", err) + } + + if projectDir == "" { + err = projectDirFlag.Ask(cmd, &projectDir, &pwd) + if err != nil { + return err + } + } else { + fmt.Printf("📂 Project: %s\n", ansi.Yellow(projectDir)) + } + + if err = validateAculProject(projectDir); err != nil { + return fmt.Errorf("invalid ACUL project: %w", err) + } + + if connected { + if confirmed := showConnectedModeInformation(); !confirmed { + fmt.Println(ansi.Red("Connected mode cancelled.")) + return nil + } + + fmt.Println("") + fmt.Println("⚠️ " + ansi.Bold(ansi.Yellow("🌟 CONNECTED MODE ENABLED 🌟"))) + fmt.Println("") + + screensToWatch, err := selectScreensSimple(cli, projectDir, screenDirs) + if err != nil { + return fmt.Errorf("failed to determine screens to watch: %w", err) + } + + return runConnectedMode(cmd.Context(), cli, projectDir, port, screensToWatch) + } + + if port == "" { + err = portFlag.Ask(cmd, &port, auth0.String("8080")) + if err != nil { + return err + } + } else { + fmt.Printf("🖥️ Server: %s\n", ansi.Cyan(fmt.Sprintf("http://localhost:%s", port))) + } + return runNormalMode(cli, projectDir, port) + }, + } + + projectDirFlag.RegisterString(cmd, &projectDir, "") + screenDevFlag.RegisterStringSlice(cmd, &screenDirs, nil) + portFlag.RegisterString(cmd, &port, "") + connectedFlag.RegisterBool(cmd, &connected, false) + + return cmd +} + +func runNormalMode(cli *cli, projectDir, port string) error { + // 1. Set up the command. + cmd := exec.Command("npm", "run", "dev", "--", "--port", port) + cmd.Dir = projectDir + + // 2. Set up output pipes/redirection. + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to capture stdout: %w", err) + } + + if cli.debug { + cmd.Stderr = os.Stderr + fmt.Println("\n🔄 Executing:", ansi.Cyan("npm run dev -- --port "+port)) + } + + // 3. Start the command asynchronously. + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start 'npm run dev -- --port %s': %w", port, err) + } + + // 4. Print the success/info logs immediately after starting the server process. + server := fmt.Sprintf("http://localhost:%s", port) + + fmt.Println("💡 " + ansi.Italic("Make changes to your code and view the live changes as we have HMR enabled!")) + + // 5. Wait for the command to exit and handle intentional stops (Ctrl+C). + readyChan := make(chan struct{}) + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) + if strings.Contains(line, "Local:") && strings.Contains(line, "http") { + close(readyChan) + return + } + } + }() + + select { + case <-readyChan: + _ = browser.OpenURL(server) + + case <-time.After(20 * time.Second): + fmt.Println("⏳ Dev server is taking longer than expected to start...") + fmt.Println("ℹ️ You can manually open the browser if needed.") + } + + if err = cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 { + fmt.Println(ansi.Bold("\n👋 Server stopped intentionally (Ctrl+C).")) + return nil + } + + return fmt.Errorf("dev server exited with an error") + } + + fmt.Println(ansi.Bold("\n'npm run dev' finished gracefully.")) + return nil +} + +func showConnectedModeInformation() bool { + fmt.Println("") + fmt.Println("📢 " + ansi.Bold(ansi.Cyan("Connected Mode Information"))) + fmt.Println("") + fmt.Println("ℹ️ " + ansi.Cyan("This mode updates advanced rendering settings for selected screens in your Auth0 tenant.")) + fmt.Println("") + fmt.Println("🚨 " + ansi.Bold(ansi.Red("IMPORTANT: Never use on production tenants!"))) + fmt.Println(" " + ansi.Yellow("• Production may break sessions or incur unexpected charges with local assets.")) + fmt.Println(" " + ansi.Yellow("• Use ONLY for dev/stage tenants.")) + + fmt.Println("") + fmt.Println("⚙️ " + ansi.Bold(ansi.Magenta("Technical Requirements:"))) + fmt.Println(" " + ansi.Cyan("• Requires sample apps with viteConfig.ts configured for asset building")) + fmt.Println(" " + ansi.Cyan("• Assets must be built in the following structure:")) + fmt.Println(" " + ansi.Green(" assets//")) + fmt.Println(" " + ansi.Green(" assets//")) + fmt.Println(" " + ansi.Green(" assets/")) + fmt.Println("") + fmt.Println("🔄 " + ansi.Bold(ansi.Magenta("How it works:"))) + fmt.Println(" " + ansi.Cyan("• Combines files from screen-specific, shared, and main asset folders")) + fmt.Println(" " + ansi.Cyan("• Makes API patch calls to update rendering settings for each specified screen")) + fmt.Println(" " + ansi.Cyan("• Watches for changes and automatically re-patches when assets are rebuilt")) + fmt.Println("") + + return prompt.Confirm("Proceed with connected mode?") +} + +func runConnectedMode(ctx context.Context, cli *cli, projectDir, port string, screensToWatch []string) error { + fmt.Println("\n🚀 " + ansi.Green("ACUL connected dev mode started for: "+ansi.Cyan(projectDir))) + + // Step 1: Do initial build. + fmt.Println("") + fmt.Println("🔨 " + ansi.Bold(ansi.Blue("Step 1: Running initial build..."))) + if err := buildProject(cli, projectDir); err != nil { + return fmt.Errorf("initial build failed: %w", err) + } + + // Always validate screens after build to ensure they have actual built assets. + screensToWatch, err := validateScreensAfterBuild(projectDir, screensToWatch) + if err != nil { + return fmt.Errorf("screen validation failed after build: %w", err) + } + + // Step 2: Ask user to host assets and get port confirmation. + fmt.Println("") + fmt.Println("📡 " + ansi.Bold(ansi.Blue("Step 2: Host your assets locally"))) + + if port == "" { + var portInput string + portQuestion := prompt.TextInput("port", "Enter port to serve assets:", "Example: 8080", "8080", true) + if err := prompt.AskOne(portQuestion, &portInput); err != nil { + return fmt.Errorf("failed to get port: %w", err) + } + + if _, err = strconv.Atoi(portInput); err != nil { + return fmt.Errorf("invalid port number: %s", portInput) + } + + port = portInput + } + + fmt.Println("💡 " + ansi.Yellow("Your assets must be served locally with CORS enabled.")) + + runServe := prompt.Confirm(fmt.Sprintf("Would you like to host the assets by running 'npx serve dist -p %s --cors' in the background?", port)) + + var serveStarted bool + + if runServe { + fmt.Println("🚀 " + ansi.Cyan("Starting local server in the background...")) + + serveCmd := exec.Command("npx", "serve", "dist", "-p", port, "--cors") + serveCmd.Dir = projectDir + + if cli.debug { + serveCmd.Stdout = os.Stdout + serveCmd.Stderr = os.Stderr + } + + if err := serveCmd.Start(); err != nil { + fmt.Println("⚠️ " + ansi.Yellow("Failed to start local server: ") + ansi.Bold(err.Error())) + fmt.Println(" You can manually run " + ansi.Cyan(fmt.Sprintf("'npx serve dist -p %s --cors'", port)) + " in a separate terminal.") + } else { + serveStarted = true + fmt.Println("✅ " + ansi.Green("Local server started successfully at ") + + ansi.Cyan(fmt.Sprintf("http://localhost:%s", port))) + time.Sleep(2 * time.Second) // Give server time to start. + } + } else { + fmt.Println("Please either run the following command in a separate terminal to serve your assets or host someway on your own") + fmt.Println(" " + ansi.Bold(ansi.Green(fmt.Sprintf("npx serve dist -p %s --cors", port)))) + fmt.Println("") + fmt.Println("This will serve your built assets at the specified port with CORS enabled.") + } + + assetsURL := fmt.Sprintf("http://localhost:%s", port) + + // Only ask confirmation if not started in background. + if !serveStarted { + assetsHosted := prompt.Confirm(fmt.Sprintf("Are your assets hosted and accessible at %s?", assetsURL)) + if !assetsHosted { + cli.renderer.Warnf("❌ Please host your assets first and run the command again.") + return nil + } + } + + // Step 3: Ask about build:watch. + fmt.Println("") + fmt.Println("🔧 " + ansi.Bold(ansi.Blue("Step 3: Continuous build watching (optional)"))) + fmt.Println(" " + ansi.Green("1. Manually run 'npm run build' after changes, OR")) + fmt.Println(" " + ansi.Green("2. Run 'npm run build:watch' for continuous updates")) + fmt.Println("") + fmt.Println("💡 " + ansi.Yellow("If auto-save is enabled in your IDE, build:watch will rebuild frequently.")) + + runBuildWatch := prompt.Confirm("Would you like to run 'npm run build:watch' in the background?") + + if runBuildWatch { + fmt.Println("🚀 " + ansi.Cyan("Starting 'npm run build:watch' in the background...")) + buildWatchCmd := exec.Command("npm", "run", "build:watch") + buildWatchCmd.Dir = projectDir + + // Only show command output if debug mode is enabled. + if cli.debug { + buildWatchCmd.Stdout = os.Stdout + buildWatchCmd.Stderr = os.Stderr + } + + if err := buildWatchCmd.Start(); err != nil { + fmt.Println("⚠️ " + ansi.Yellow("Failed to start build:watch: ") + ansi.Bold(err.Error())) + fmt.Println(" You can manually run " + ansi.Cyan("'npm run build'") + " whenever you update your code.") + } else { + fmt.Println("✅ " + ansi.Green("Build watch started successfully")) + } + } + + fmt.Println("") + fmt.Println("👀 " + ansi.Bold(ansi.Blue("Step 4: Start watching assets and auto-patching..."))) + + distPath := filepath.Join(projectDir, "dist") + + fmt.Println("🌐 Assets URL: " + ansi.Green(assetsURL)) + fmt.Println("👀 Watching screens: " + ansi.Cyan(strings.Join(screensToWatch, ", "))) + + // Fetch original head tags before starting watcher. + fmt.Println("💡 " + ansi.Yellow("Note: Your original rendering settings will be saved and can be restored on exit.")) + originalHeadTags, err := fetchOriginalHeadTags(ctx, cli, screensToWatch) + if err != nil { + fmt.Println("⚠️ " + ansi.Yellow(fmt.Sprintf("Could not fetch original settings: %v", err))) + fmt.Println(" " + ansi.Yellow("Restoration will be skipped since no previous settings could be retrieved.")) + originalHeadTags = nil // Continue without restoration capability. + } else { + fmt.Println("✅ " + ansi.Green(fmt.Sprintf("Saved original settings for %d screen(s)", len(originalHeadTags)))) + } + + fmt.Println() + fmt.Println(ansi.Magenta("💡 Tips:")) + fmt.Println(ansi.Cyan(" • Assets in '/dist/assets' are continuously monitored and patched when changes occur.")) + fmt.Println(ansi.Cyan(" • Run 'auth0 test login' anytime to preview your changes in real-time.")) + fmt.Println(ansi.Cyan(" • Press Ctrl+C to stop. You'll be prompted to restore your original settings.")) + fmt.Println() + + return watchAndPatch(ctx, cli, assetsURL, distPath, screensToWatch, originalHeadTags) +} + +func validateAculProject(projectDir string) error { + packagePath := filepath.Join(projectDir, "package.json") + if _, err := os.Stat(packagePath); os.IsNotExist(err) { + return fmt.Errorf("package.json not found. This doesn't appear to be a valid ACUL project") + } + return nil +} + +func buildProject(cli *cli, projectDir string) error { + cmd := exec.Command("npm", "run", "build") + cmd.Dir = projectDir + + // Only show command output if debug mode is enabled. + if cli.debug { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + fmt.Println("✅ " + ansi.Green("Build completed successfully")) + return nil +} + +func selectScreensSimple(cli *cli, projectDir string, screenDirs []string) ([]string, error) { + // 1. Screens provided via --screen flag. + if len(screenDirs) > 0 { + if len(screenDirs) == 1 && screenDirs[0] == "all" { + cli.renderer.Infof(ansi.Cyan("📂 Selecting all screens from src/screens")) + + return getScreensFromSrcFolder(filepath.Join(projectDir, "src", "screens")) + } + + cli.renderer.Infof(ansi.Cyan(fmt.Sprintf("📂 Using specified screens: %s", strings.Join(screenDirs, ", ")))) + + return screenDirs, nil + } + + // 2. No --screen flag: auto-detect from src/screens. + srcScreensPath := filepath.Join(projectDir, "src", "screens") + + if availableScreens, err := getScreensFromSrcFolder(srcScreensPath); err == nil && len(availableScreens) > 0 { + return validateAndSelectScreens(cli, availableScreens, nil, true) + } + + return nil, fmt.Errorf(`no screens found in project. + +Please either: +1. Specify screens using --screen flag: auth0 acul dev --connected --screen login-id,signup +2. Create a new ACUL project: auth0 acul init +3. Ensure your project has screens in src/screens/ folder`) +} + +func validateScreensAfterBuild(projectDir string, selectedScreens []string) ([]string, error) { + distAssetsPath := filepath.Join(projectDir, "dist", "assets") + + availableScreens, err := getScreensFromDistAssets(distAssetsPath) + + if err != nil { + return nil, fmt.Errorf("failed to read available screens from dist/assets: %w", err) + } + + if len(availableScreens) == 0 { + return nil, fmt.Errorf("no valid screens found in dist/assets after build") + } + + availableScreensMap := make(map[string]bool) + + for _, screen := range availableScreens { + availableScreensMap[screen] = true + } + + var validScreens, missingScreens []string + + for _, screen := range selectedScreens { + if availableScreensMap[screen] { + validScreens = append(validScreens, screen) + } else { + missingScreens = append(missingScreens, screen) + } + } + + if len(missingScreens) > 0 { + return nil, fmt.Errorf("⚠️ Missing built assets for: %s", strings.Join(missingScreens, ", ")) + } + + if len(validScreens) == 0 { + return nil, fmt.Errorf( + "none of the selected screens were built. Available built screens: %s", + strings.Join(availableScreens, ", "), + ) + } + + return validScreens, nil +} + +// getScreensFromDistAssets reads screen names from dist/assets folder. +func getScreensFromDistAssets(distAssetsPath string) ([]string, error) { + if _, err := os.Stat(distAssetsPath); os.IsNotExist(err) { + return nil, fmt.Errorf("dist/assets not found") + } + + dirs, err := os.ReadDir(distAssetsPath) + if err != nil { + return nil, fmt.Errorf("failed to read dist/assets: %w", err) + } + + var screens []string + for _, d := range dirs { + if d.IsDir() && d.Name() != "shared" { + screens = append(screens, d.Name()) + } + } + + return screens, nil +} + +// getScreensFromSrcFolder reads screen names from src/screens folder. +func getScreensFromSrcFolder(srcScreensPath string) ([]string, error) { + if _, err := os.Stat(srcScreensPath); os.IsNotExist(err) { + return nil, fmt.Errorf("src/screens not found") + } + + entries, err := os.ReadDir(srcScreensPath) + if err != nil { + return nil, fmt.Errorf("failed to read src/screens: %w", err) + } + + var screens []string + for _, entry := range entries { + if entry.IsDir() { + screens = append(screens, entry.Name()) + } + } + + return screens, nil +} + +// fetchOriginalHeadTags retrieves the current rendering settings for all screens before making changes. +func fetchOriginalHeadTags(ctx context.Context, cli *cli, screensToWatch []string) (map[string][]interface{}, error) { + originalTags := make(map[string][]interface{}) + + existing, err := cli.api.Prompt.ListRendering(ctx) + if err != nil { + return nil, err + } + + promptRenderingMap := make(map[string]*management.PromptRendering, len(existing.PromptRenderings)) + for _, r := range existing.PromptRenderings { + if r.Prompt != nil && r.Screen != nil { + key := string(*r.Prompt) + "|" + string(*r.Screen) + promptRenderingMap[key] = r + } + } + + // Collect only requested screens. + for _, screen := range screensToWatch { + promptType := management.PromptType(ScreenPromptMap[screen]) + screenType := management.ScreenName(screen) + key := string(promptType) + "|" + string(screenType) + + if r := promptRenderingMap[key]; r != nil && r.HeadTags != nil { + originalTags[screen] = r.HeadTags + } + } + + return originalTags, nil +} + +func applyPromptRenderings(ctx context.Context, cli *cli, screenTagMap map[string][]interface{}, debugPrefix string) error { + var updates []*management.PromptRendering + for screen, headTags := range screenTagMap { + p := management.PromptType(ScreenPromptMap[screen]) + s := management.ScreenName(screen) + updates = append(updates, &management.PromptRendering{ + Prompt: &p, + Screen: &s, + RenderingMode: &management.RenderingModeAdvanced, + HeadTags: headTags, + }) + } + + if len(updates) == 0 { + return fmt.Errorf("no renderings to apply") + } + + // Snapshot originals. + existing, err := cli.api.Prompt.ListRendering(ctx) + if err != nil { + return fmt.Errorf("failed to fetch current renderings: %w", err) + } + + promptRenderingMap := make(map[string]*management.PromptRendering, len(existing.PromptRenderings)) + for _, r := range existing.PromptRenderings { + if r.Prompt != nil && r.Screen != nil { + promptRenderingMap[string(*r.Prompt)+"|"+string(*r.Screen)] = r + } + } + + originals := make([]*management.PromptRendering, len(updates)) + for i, u := range updates { + originals[i] = promptRenderingMap[string(*u.Prompt)+"|"+string(*u.Screen)] + } + + const maxBatch = 20 + doBatchedPatch := func(list []*management.PromptRendering) error { + return cli.api.Prompt.BulkUpdateRendering(ctx, + &management.PromptRenderingBulkUpdate{PromptRenderings: list}) + } + + // --- Batching loop with rollback awareness ---. + for i := 0; i < len(updates); i += maxBatch { + end := i + maxBatch + if end > len(updates) { + end = len(updates) + } + + if err := doBatchedPatch(updates[i:end]); err != nil { + if debugPrefix == "Restoring" { + return fmt.Errorf("restore failed: %w", err) + } + + if cli.debug { + cli.renderer.Errorf("%s batch %d-%d failed: %v", debugPrefix, i+1, end, err) + cli.renderer.Infof("%s rollback starting for screens 1-%d...", debugPrefix, i) + } else { + // Removed the redundant log: cli.renderer.Errorf("patch failed; rolling back..."). + cli.renderer.Warnf("Patch failed. Attempting rollback...") + } + + // Rollback all fully-applied previous batches. + for r := 0; r < i; r += maxBatch { + rEnd := r + maxBatch + if rEnd > i { + rEnd = i + } + + if rbErr := doBatchedPatch(originals[r:rEnd]); rbErr != nil { + if cli.debug { + cli.renderer.Warnf("%s rollback failed for screens %d-%d: %v", debugPrefix, r+1, rEnd, rbErr) + } else { + cli.renderer.Warnf("Partial rollback failed for screens %d-%d.", r+1, rEnd) + } + } else { + if cli.debug { + cli.renderer.Infof("%s rollback restored screens %d-%d", debugPrefix, r+1, rEnd) + } + } + } + + cli.renderer.Infof("Rollback complete for all applied batches.") + + if cli.debug { + return fmt.Errorf("%s update failed at batch %d-%d: %w", debugPrefix, i+1, end, err) + } + + return fmt.Errorf("%s failed: %w", debugPrefix, err) + } + } + + return nil +} + +func watchAndPatch(ctx context.Context, cli *cli, assetsURL, distPath string, screensToWatch []string, originalHeadTags map[string][]interface{}) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create file system watcher: %w", err) + } + defer watcher.Close() + + if err := watcher.Add(distPath); err != nil { + return fmt.Errorf("failed to watch distribution path %s: %w", distPath, err) + } + + fmt.Println("👀 Watching: " + ansi.Yellow(strings.Join(screensToWatch, ", "))) + + // First, stop any existing global signal handlers (from root.go). + signal.Reset(os.Interrupt, syscall.SIGTERM) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigChan) + + const debounceWindow = 5 * time.Second + var ( + lastEventTime time.Time + lastHeadTags = make(map[string][]interface{}) + ) + + cleanup := func() { + time.Sleep(1 * time.Second) + fmt.Fprintln(os.Stderr, ansi.Yellow("\nShutting down ACUL connected mode...")) + if len(originalHeadTags) > 0 { + restore := prompt.Confirm("Would you like to restore your original rendering settings?") + + if restore { + fmt.Fprintln(os.Stderr, ansi.Cyan("Restoring original rendering settings...")) + + if err := applyPromptRenderings(ctx, cli, originalHeadTags, "Restoring"); err != nil { + fmt.Fprintln(os.Stderr, ansi.Yellow(fmt.Sprintf("Restoration failed: %v", err))) + } else { + fmt.Fprintln(os.Stderr, ansi.Green(fmt.Sprintf("Successfully restored rendering settings for %d screen(s).", len(originalHeadTags)))) + } + } else { + fmt.Fprintln(os.Stderr, ansi.Yellow("Restoration skipped. The patched assets will continue to remain active in your Auth0 tenant.")) + } + } + + fmt.Println() + fmt.Fprintln(os.Stderr, ansi.Green("👋 ACUL connected mode stopped.")) + + fmt.Fprintf(os.Stderr, "%s Use %s to see all available commands\n\n", + ansi.Yellow("💡 Tip:"), ansi.Cyan("auth0 acul --help")) + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + // Ignore non-asset events or irrelevant file ops. + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 || !strings.Contains(event.Name, "assets") { + continue + } + + now := time.Now() + if now.Sub(lastEventTime) < debounceWindow { + continue + } + lastEventTime = now + + time.Sleep(500 * time.Millisecond) // Let writes settle. + + newHeadTags := make(map[string][]interface{}) + changedScreens := make([]string, 0) + + for _, screen := range screensToWatch { + headTags, err := buildHeadTagsFromDirs(distPath, assetsURL, screen) + if err != nil { + if cli.debug { + cli.renderer.Warnf(ansi.Yellow(fmt.Sprintf("Skipping '%s': failed to build head tags: %v", screen, err))) + } + continue + } + + // Compare with last known tags. + if reflect.DeepEqual(lastHeadTags[screen], headTags) { + continue + } + + // Only record changed screens. + newHeadTags[screen] = headTags + changedScreens = append(changedScreens, screen) + } + + if len(changedScreens) == 0 { + if cli.debug { + fmt.Println(ansi.Yellow("No effective asset changes detected, skipping patch.")) + } + continue + } + + if cli.debug { + fmt.Println(ansi.Cyan(fmt.Sprintf("🔄 Changes detected in %d screen(s): %s", len(changedScreens), + strings.Join(changedScreens, ", ")))) + } else { + fmt.Println(ansi.Cyan("⚙️ Change detected, patching assets...")) + } + + if err = applyPromptRenderings(ctx, cli, newHeadTags, "Patching"); err != nil { + cli.renderer.Errorf("Patching assets failed: %v", err) + } else { + fmt.Println(ansi.Green("✅ Assets patched successfully!")) + for screen, headTags := range newHeadTags { + lastHeadTags[screen] = headTags + } + } + + case err := <-watcher.Errors: + cli.renderer.Warnf("File watcher internal error: %v", err) + + case <-sigChan: + cleanup() + return nil + + case <-ctx.Done(): + cleanup() + return ctx.Err() + } + } +} + +// buildHeadTagsFromDirs collects